Skip to content
Back to skills

Doc Structure Scan

ASecurity

Human docs vs docs: links, anchors, the docs gate, ADR graph, changelog grammar, translations, placeholders, nav.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentstypescriptpythonrustgobashreactnodegitapifrontend

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill doc-structure-scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Doc Structure Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Doc Structure Scan
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-doc-structure-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-doc-structure-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: doc-structure-scan
description: "Human docs vs docs: links, anchors, the docs gate, ADR graph, changelog grammar, translations, placeholders, nav."
kind: skill
pack: documentation
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: doc-structure-scan

## Premise

A doc set rots as a structure before any single page lies: a heading is renamed and twelve deep links from other pages, an alert's `runbook_url` and an error message now land on nothing; ADR 0003 says "Superseded by 0006" and 0006 never says it supersedes anything; the changelog lists 1.2.0 below 1.1.0; the Arabic setup page still tells readers to run a command the English page renamed; the nav points at a page that moved. And the docs CI is green throughout, because Docusaurus defaults `onBrokenAnchors` to `warn`, MkDocs leaves missing anchors at `info`, and `remark` without `--frail` exits 0 on warnings. Roughly 10% of links in code comments are dead (Hata et al., ICSE 2019).

This skill owns the **docs-against-docs** classes DL-1–DL-4, DK-3, DX-1–DX-3, DH-2, DI-1–DI-3, DF-1 and DF-2 (§1–§14). Docs against code and config is `doc-drift-scan`; samples and spec examples are `doc-sample-scan`.

**Every finding cites the doc line and the target it fails to reach (the file, the slug list of the target page, the reciprocal ADR, the source page of a translation, the gate config line), and the oracle that proves it.** An engine row is a candidate, never a finding. "Docs structure holds" is never printed without the class-coverage block (§ Output).

## The rule

A candidate becomes a finding only when all four hold:

1. **The doc is in scope.** A human doc, not a dated record (an ADR or changelog keeps only its doc-to-doc links, [precedents § 1](../../../../snippets/precedents/documentation.md)), tool config (§ 2), test data (§ 27), versioned docs (§ 12) or agent-facing knowledge (§ 28).
2. **The renderer was read.** Anchors are resolved with the slugger of the renderer that serves the page (GitHub, MkDocs `toc`, Docusaurus, Sphinx), detected from its config (§ 10); a gate is judged by the version and the options the repo pins.
3. **The target fails**: no file, no heading slug, no reciprocal line, a grammar rule broken, a translation that carries different code or links, a nav entry to nothing, a gate whose config cannot fail.
4. **No record makes it intentional**: an outdated banner under a declared lag policy (§ 11), a partial supersession (§ 30), a generated changelog's own grammar (§ 17).

## Boundaries

- An alert with no `runbook_url` → observability `alert-rule-lint`, when the observability pack is installed; §5 here takes a `runbook_url` that resolves to no file or anchor. Without that pack, the missing-URL half is out of scope and the coverage block says so.
- Accepted ADRs against the code → align `convention-oracle` AL-03/04 (when installed); this skill grades only ADR **metadata** (status, supersession, index, number) and never reads an ADR body as a claim. ADR coverage of decisions nobody recorded → `/setup-project-health` § 2 for Refract setups, else Hardening.
- The changelog version against the released version → devops `release-integrity-scan` RL-2 (when installed); breaking changes missing from the notes → `doc-drift-scan` §12.
- UI string catalogs → frontend `i18n-scan` (when installed); §10–§12 here are **doc pages**. TODO / FIXME debt in code → code-quality `debt-ledger`; §13 here is prose in published docs.
- Agent-facing knowledge (`ai/**` except runbooks and decisions, `CLAUDE.md`, `AGENTS.md`) → the learning pack's `knowledge-ref-scan`: the engine routes it, and a broken link inside it is learning's.
- Markdown style (MD040, heading increments, line length) is NIT unless the project configures and enforces the linter (§ 22).

## Lanes

The engine (`doc-drift-scan/scripts/doc-claims.py`, lanes `links`, `linksin`, `adr`, `changelog`, `i18n`, `placeholders`, `nav`, `gates`) always runs, offline. Tool lanes run only when the binary is installed in the repo (never `npx -y`), in a scratch copy where they write caches, with `CI=true DO_NOT_TRACK=1`; each prints `ran / NOT-RUN (<why>) / BLOCKED (exit n)` through its own exit map ([precedents § 32](../../../../snippets/precedents/documentation.md)).

| Renderer | Anchors (§2) | Nav (§14) | Gate that can fail (§4) | Locales (§10–§12) |
|---|---|---|---|---|
| **GitHub / plain repo** | github-slugger: lowercase, punctuation dropped, Unicode kept, `-1` duplicates; `<a name>`, `id=` | — | a lychee or remark CI step | `docs/<lc>/`, `README.<lc>.md` |
| **MkDocs** 1.6 | `toc` slugify: ASCII unless `pymdownx.slugs`, `_1` duplicates; `attr_list` `{#id}` | `nav`, `not_in_nav` | `strict: true` or `mkdocs build --strict`, and `validation.links.anchors: warn` (default `info`) | mkdocs-static-i18n (`fallback_to_default: true` hides gaps) |
| **Docusaurus** 3.10 | github-slugger, `{#id}` | `sidebars.*` doc ids | `onBrokenLinks: throw` (default), `onBrokenAnchors` and `onBrokenMarkdownLinks` (default `warn`) → `throw` | `i18n.locales`, `i18n/<lc>/docusaurus-plugin-content-docs/current/` |
| **Sphinx** 9.1 | labels; MyST heading anchors → CHECK | toctree | `sphinx-build -W --keep-going`, `-n` when nitpicky is policy | `locale_dirs`, sphinx-intl (`stat` exit is not a gate) |
| **mdBook** 0.5 | mdBook ids (lowercase, `-1`) | `SUMMARY.md` | `[output.linkcheck] warning-policy = "error"` (default `warn`) | — |
| **VitePress / Hugo** | GitHub-like [K?] | sidebar config [K?] | the build's dead-link option [K?] | `locales` / `languages` |

| Tool | Command (offline) | Exit map | Trap |
|---|---|---|---|
| lychee 0.24 | `lychee --offline --include-fragments --no-progress --format json './**/*.md'` | 0 ok · **2 broken links** · 1 runtime / config error · 3 config file | reversed against every linter; fragments only on GET; JS anchors invisible |
| markdownlint-cli2 0.23 | `node_modules/.bin/markdownlint-cli2 "**/*.md" "#node_modules"`, only when the repo has a config | 0 · 1 errors · 2 the tool failed | MD051 is same-file only; no MD057 |
| rumdl 0.3 | `rumdl check .` (pinned, pre-1.0) | 0 · 1 violations · 2 tool error | `rumdl fmt` exits 0 with violations left |
| remark-validate-links 13 | `node_modules/.bin/remark --use remark-validate-links --frail --quiet .` | `--frail`: warnings exit 1 | without `--frail` every warning exits 0 |
| python-kacl 0.7 | `kacl-cli verify --json` | **exit = error count** | the PyPI package `kacl` is another project |
| site builds | `mkdocs build --strict` · `sphinx-build -W --keep-going -b dummy` · `docusaurus build`, in a scratch copy with the pinned toolchain | non-zero on failure | builds need the install; never in the audited tree |

## Pre-flight

Run as **one** script from the repo root (bash 3.2-safe). `NETWORK=1` opts into the external-link lane (§3). Every grep ends in `ck`: exit 2 is a blocked class, never zero hits. `doc-claims exit 1` is candidates, not a failure; exit 2 is BLOCKED.

```bash
DC="${DC:-.claude/skills/doc-drift-scan/scripts/doc-claims.py}"; NETWORK="${NETWORK:-}"
OUT="$(mktemp -d "${TMPDIR:-/tmp}/doc-structure.XXXXXX")"; export GIT_OPTIONAL_LOCKS=0 CI=true DO_NOT_TRACK=1; : > "$OUT/stamp"
ck() { [ "$2" -lt 2 ] || echo "$1 BLOCKED (rg exit $2) — the class is blocked, never 0 hits"; }
python3 -I "$DC" . --lanes=links,linksin,adr,changelog,i18n,placeholders,nav,gates --out="$OUT/run" > "$OUT/claims.tsv" 2> "$OUT/ledger.txt"
echo "doc-claims exit $? — 0 clean · 1 candidates (not a failure) · 2 BLOCKED: read the ledger's last line"; cat "$OUT/ledger.txt"
cut -f2,3 "$OUT/claims.tsv" | sort | uniq -c | sort -rn
X=(-g '!**/node_modules/**' -g '!**/{dist,build,vendor,.venv,target,site,_build}/**' -g '!ai/**' -g '!.claude/**' -g '!**/{test,tests,fixtures,testdata}/**')
# S1. Every docs gate and its silencing option (§4), in CI, scripts and site configs only: the lines read before believing a green docs job.
#     --hidden: CI lives under .github/ and rg skips dot-directories without it (0 hits that read as "no gate").
G=(-g '.github/workflows/*.{yml,yaml}' -g '.gitlab-ci.yml' -g '.circleci/*.yml' -g '**/package.json' -g 'docusaurus.config.*' -g 'mkdocs.{yml,yaml}' -g 'book.toml' -g '**/conf.py' -g '**/Makefile' -g 'justfile' -g '.pre-commit-config.yaml' -g 'typedoc.json' -g 'lefthook.yml')
rg -n --hidden --pcre2 'onBroken(?:Links|Anchors|MarkdownLinks)\s*:|^strict\s*:|^\s+anchors\s*:\s*\w+|warning-policy|lychee|markdown-link-check|markdownlint|rumdl|\bremark\s+[-.\x27"]|remark-validate-links|sphinx-build|SPHINXOPTS|mkdocs\s+build|mdbook\s+(?:build|test)|treat(?:Validation)?WarningsAsErrors|oasdiff|openapi-diff|spectral\s+lint|fail\s*:\s*false|continue-on-error\s*:\s*true' "${G[@]}" -g '!**/node_modules/**' .; ck S1 $?
# S2. Links into the docs from code, alerts and config (§5): runbook_url and doc paths in strings.
rg -n --pcre2 'runbook_url\s*:|["\x27`](?:\./)?(?:docs?|runbooks?)/[\w./-]+\.mdx?(?:#[\w-]+)?["\x27`]|/blob/[^/\s"\x27]+/(?:docs?|runbooks?)/' -g '!**/{migrations,migrate,seeds}/**' -g '!*.{md,mdx,lock}' "${X[@]}" .; ck S2 $?
# S3. Declared locales and translation markers (§10–§12).
rg -n --pcre2 '\blocales\s*:|defaultLocale|^\s*-\s*locale\s*:|locale_dirs|fallback_to_default|source[-_ ]?commit\s*:|translated_from_sha' -g '*.{js,ts,mjs,yml,yaml,py,toml,md,mdx}' "${X[@]}" .; ck S3 $?
# Tool lanes: lychee offline (exit 2 = broken links, 1/3 = lychee failed), then the online lane only with NETWORK=1.
if command -v lychee >/dev/null; then lychee --offline --include-fragments --no-progress --format json --output "$OUT/lychee.json" './**/*.md' './**/*.mdx' >/dev/null 2>&1
  r=$?; case $r in 0) echo "lychee offline ran: 0 broken";; 2) echo "lychee offline ran: broken links in $OUT/lychee.json";; *) echo "lychee BLOCKED (exit $r: tool or config error)";; esac
  if [ -n "$NETWORK" ]; then lychee --include-fragments --no-progress --accept '200..=299,403,429' --format json --output "$OUT/lychee-net.json" './**/*.md' >/dev/null 2>&1; echo "lychee online exit $? (DL-3: no --cache here, it writes .lycheecache into the tree; rerun failures once before reporting)"; else echo "§3 N/A (offline by default; NETWORK=1 runs it)"; fi
else echo "lychee NOT-RUN (not installed) — the engine's links lane is the oracle"; fi
if [ -x node_modules/.bin/markdownlint-cli2 ] && ls .markdownlint* >/dev/null 2>&1; then node_modules/.bin/markdownlint-cli2 "**/*.md" "#node_modules" > "$OUT/mdl.txt" 2>&1; r=$?; [ $r -ge 2 ] && echo "markdownlint BLOCKED (exit $r)" || echo "markdownlint ran: exit $r (only MD042/MD051/MD052 rows feed §1–§2; style is NIT)"; else echo "markdownlint NOT-RUN (no config or binary)"; fi
if command -v kacl-cli >/dev/null && [ -f CHANGELOG.md ]; then kacl-cli verify --json > "$OUT/kacl.json" 2>&1; echo "kacl-cli exit $? = error count (0 valid)"; else echo "kacl-cli NOT-RUN — the engine's changelog lane is the oracle"; fi
find . -newer "$OUT/stamp" -type f -not -path './node_modules/*' -not -path './.git/*' | head -3; echo "write check done (empty = nothing written)"
```

A site build (`mkdocs build --strict`, `sphinx-build -W`, `docusaurus build`) is the strongest oracle for §1, §2 and §14 when the toolchain is pinned: run it in `git archive HEAD | tar -x -C "$OUT/site"` after a scratch install, never in the tree; without the install it is `NOT-RUN (no install)`, and the engine stands.

## Measured

Measured 2026-10-19 (`doc-claims 1.0`, self-test 46/46).

- **A real NestJS + React monorepo** (read-only, write check empty): 107 human docs in scope, plain GitHub rendering (no docs site). DL-1 277 links → 0 (6 broken links the eval found live in installed `ai/patterns`, now routed `-> learning`), DL-2 45 fragments → 0, DL-3 1 external link counted, offline. DK-3 63 → 60 links into docs after migration files are skipped as history (one migration comment pointed at a deleted doc), → 0. ADRs 40 + an index: DX-1 2 supersessions → 0 (the one partial "Supersedes: the chrome clause of [0016]" no longer flips 0016, § 30), DX-2 41 → 1 (a "retired 2026-10-09" status outside the installed template's vocabulary), DX-3 0. DH-2: 1 changelog, 0. DI, DF-2, DL-4: N/A (no doc locales, no site nav, no docs gate). Greps: S1 0 (no docs gate in CI, scripts or a site config) · S2 42 (30 alert `runbook_url`s into `ai/runbooks/`, all resolving) · S3 31 (UI-catalog `locales:` keys of the web app: frontend `i18n-scan`'s, not doc locales). S1 first matched `remark` as a code identifier and `strict: true` in a TypeScript config (300+ lines); it now reads CI, scripts and site configs only.
- **Fixtures**: `backend-defects` DL-4 2 (`oasdiff breaking` with no `--fail-on` in a workflow and a package script: the backend seed, seen from the gate side); `infra-ops` DX-2 1 (an ADR with no status); `mobile-defects` DF-1 1 (a `TODO` in prose). Greps: S1 1 (`backend-defects`), S2 26 on `obs-defects` (its alert rules), ≤ 2 elsewhere, S3 ≤ 2. Every other fixture prints 0 structure rows: they carry no docs site, ADR set or translation, so the seeded corpus is the engine's `--self-test` until a doc fixture lands (integrator request).
- **The `doc-drift` fixture** (2026-10-19, history staged): the pre-flight printed **28 candidates, two per class for every engine §** (DL-1 3: the third is the DI-3 seed's Arabic link, one root cause; DL-4 3: the CI `mkdocs build` and `mkdocs.yml` `strict` rows are one finding), all 26 engine-detectable doc-structure-scan seeds; DL-3 `N/A (offline)` with 10 external links counted. 0 of 22 decoys printed as a candidate (the `#L42` anchor, the banner page § 11 and `docs/v1/` § 12 printed as skips). DI-2 needs the history script: on the one-commit copy every pair compares a commit with itself and prints `checked (6 claims -> 0)`, which reads as clean (engine gap; integrator). **S1 printed 0 workflow lines on its first run**: rg skips `.github/` without `--hidden`, so a lychee step with `fail: false` was invisible; S1 now passes `--hidden` and prints 5 lines here, and on the other fixtures 7 on `backend-defects`, 2 on `ci-defects`, 1 on `brittle-suite`, 0 elsewhere. S2 7 (4 alert `runbook_url`s, 3 error-message doc URLs), S3 3; lychee, markdownlint and kacl-cli NOT-RUN (not installed).

## The 3,000-row path

1. **Scope and renderer first.** A ledger with a wrong renderer (MkDocs slugs read as GitHub ones) turns every non-ASCII anchor into a row; fix the detection before reading any. An area over 30% of the rows is checked against precedents § 1, § 2, § 12, § 28.
2. **Group by target, not by line.** One renamed heading is **one finding with N inbound links**: `cut -f3,7 claims.tsv | sed 's/#.*//' | sort | uniq -c`. One deleted page: one finding.
3. **Read in this order:** primary surface (README, quickstart, runbooks), DK-3 inbound links from code and alerts, the gate (§4), ADR and changelog metadata, translations, hygiene.
4. **Close a group with one checked reason** (`40 DI-1 rows under docs/ar/: lag policy declared in docs/translations.md:4, § 11`).
5. **Ledger the rest** as `Live, unreviewed (k groups / n rows)` with the `--class` command that continues it.

## Scans for

| § | Class | Lane | Finding when | Default (primary) | Oracle |
|---|---|---|---|---|---|
| 1 | DL-1 relative link to a missing file | links | no file after the doc dir, the site root and the renderer's route forms | MEDIUM | V430 |
| 2 | DL-2 anchor missing | links | no slug of the target page under its renderer | MEDIUM | V430 |
| 3 | DL-3 external URL dead | lychee online (`NETWORK=1`) | 404 / 410 / DNS twice, with a token | LOW; MEDIUM in a quickstart | V431 |
| 4 | DL-4 the docs gate cannot fail | gates | a config or CI line silences the gate, or the gate checks nothing | MEDIUM | V432 |
| 5 | DK-3 link into the docs resolves to nothing | linksin | a `runbook_url`, doc path in code or blob URL of this repo with no file or anchor | MEDIUM; HIGH for an alert's runbook | V430 |
| 6 | DX-1 supersession one-way or target missing | adr | the target is absent, or the superseded ADR still reads Accepted | MEDIUM | V447 |
| 7 | DX-2 status vocabulary and index | adr | a status outside the template's vocabulary, or the index disagrees with or misses the file | LOW | V447 |
| 8 | DX-3 duplicate or colliding number | adr | two files share a number, or the title number ≠ the file number | LOW | V447 |
| 9 | DH-2 changelog structure | changelog | order, duplicates, non-ISO dates, compare links, `[Unreleased]` placement | LOW | V445 |
| 10 | DI-1 translation missing | i18n | a declared locale lacks the page, with no lag policy | LOW | V448 |
| 11 | DI-2 translation stale | i18n | commits touched the source after the translation's marker, with no outdated banner | LOW; MEDIUM on a setup page | V448 |
| 12 | DI-3 translation diverges | i18n | the translation carries a code block or link target the source does not | MEDIUM | V448 |
| 13 | DF-1 placeholder in published prose | placeholders | `TBD`, `TODO`, `FIXME`, `lorem ipsum`, `<insert …>` outside fences | LOW | V449 |
| 14 | DF-2 orphan page or nav to nothing | nav | a nav entry to a missing page, or a page in no nav and linked from nowhere | LOW | V449 |

### 1. Relative link to a missing file (DL-1)

```
BAD:   README.md   [Deploy guide](docs/deploy.md)          (moved to docs/operations/deploy.md)
GOOD:  [Deploy guide](docs/operations/deploy.md)            · and every other doc that links the old path
```
**Read the docs:** Did the engine try the renderer's forms (extensionless routes, `index.md`, `/README.md`, `.html` → `.md`)? Is the target gitignored or generated at build (§ 4)? A case-only mismatch prints CHECK: it works on macOS and breaks on Linux CI. What does the rename lookup say (`renamed -> …`)? **Finding when** no form resolves. **Proof:** V430; lychee offline exit 0 and `--claim=<target>` prints no row.

### 2. Anchor missing (DL-2)

```
BAD:   docs/setup.md#install-steps        (heading is "## Installing", slug `installing`)
GOOD:  docs/setup.md#installing   ·   or `## Install steps {#install-steps}` where the renderer honours custom ids
```
**Read the docs:** Which renderer serves the target (the ledger names it)? MkDocs strips non-ASCII unless `pymdownx.slugs`; duplicates are `_1` there and `-1` on GitHub. Is the anchor from an HTML `id=`, an MDX component, or a plugin (mkdocstrings) the engine cannot see (CHECK)? `#L42` and `#top` are fine (§ 10). **Finding when** no slug or id matches under the renderer in use. **Proof:** V430 (lychee `--include-fragments`, or the strict site build).

### 3. External URL dead (DL-3)

```
BAD:   docs/integrations.md   https://api.vendor.invalid/v1/docs      (DNS failure, twice)
GOOD:  the current URL, or the link removed with the integration
```
**Read the docs:** Offline by default: the coverage block prints `N/A (offline)`. Under `NETWORK=1`, is it 403, 429 or 999 (an auth wall or rate limit, § 15)? A GitHub URL needs `GITHUB_TOKEN`. **Finding when** 404, 410 or DNS failure reproduces twice. **Proof:** V431.

### 4. The docs gate cannot fail (DL-4)

```
BAD:   docusaurus.config.ts   (no onBrokenAnchors)          → default 'warn': a broken anchor ships green
       .github/workflows/docs.yml   uses: lycheeverse/lychee-action@v2  with: fail: false
       "lint:docs": "remark . --use remark-validate-links"   → no --frail: warnings exit 0
       oasdiff breaking a.yaml b.yaml                        → no --fail-on: exit 0 on a breaking diff
GOOD:  onBrokenAnchors: 'throw'  ·  fail: true  ·  remark --frail  ·  oasdiff breaking --fail-on ERR
```
**Read the docs and the config:** Does the gate run on PRs, or only on `main`? Is `continue-on-error: true` on the step or the job? For MkDocs, `strict: true` without `validation.links.anchors: warn` still passes missing anchors (§ 32). Spectral with no ruleset lints nothing. **Finding when** a planted defect would pass the gate as configured. **Proof:** V432, the mutation proof (plant a broken link and a broken anchor in a scratch copy, run the gate, assert non-zero through its exit map).

### 5. Link into the docs resolves to nothing (DK-3)

```
BAD:   deploy/alerts/api-alerts.yml   runbook_url: docs/runbooks/api-down.md#mitigate    (file renamed api-outage.md)
       src/errors.ts             'See docs/troubleshooting.md#tls'                   (heading renamed)
GOOD:  the URL repointed in the alert and the code string, in the same change as the rename
```
**Read the docs and the code:** Is the URL this repo's (relative, or a blob URL of the `origin` remote)? Templated (`{{ $labels.x }}`) is § 6. A migration comment is history (§ 1). **Finding when** the file or anchor is absent. **Proof:** V430. The alert with no `runbook_url` at all is observability's.

### 6. Supersession one-way or target missing (DX-1)

```
BAD:   0003-cache.md   Status: Superseded by [0006](0006-redis.md)      0006-redis.md: no "Supersedes"
       0007-auth.md    Status: Accepted · Supersedes [0004](0004-jwt.md) 0004-jwt.md: Status: Accepted
GOOD:  0006: "Supersedes [0003](0003-cache.md)" · 0004: "Status: Superseded by [0007](0007-auth.md)"
```
**Read the docs:** Are the ids from links or `ADR-n` tokens (a date is not an id, § 26)? Is the supersession partial ("the chrome clause of", "amends", § 30)? Did `adr-tools new -s` write both ends? **Finding when** a target is absent, or a whole supersession left the older ADR reading Accepted (readers follow a dead decision). **Proof:** V447.

### 7. Status vocabulary and index (DX-2)

```
BAD:   0012-x.md   (no status line)          docs/adr/README.md   | 0002 | … | Proposed |   (file: Accepted)
GOOD:  Status: Accepted in the file, and the index row the same
```
**Read the docs:** Where does the vocabulary come from: the project's ADR template or `adr-template.md` lifecycle (then out-of-vocabulary is WRONG), else MADR / Nygard, an open set (CHECK)? Free-text qualifiers after the first word ("Accepted · amended by …") are fine. Is the index generated (log4brains, adr-tools `toc`)? **Finding when** a status is missing or outside the declared vocabulary, or the index disagrees with or omits a file. **Proof:** V447.

### 8. Duplicate or colliding number (DX-3)

```
BAD:   docs/adr/0009-queue.md and docs/adr/0009-events.md     ·   0011-x.md titled "# 10. X"
GOOD:  0010-events.md, never reusing a number (Nygard)
```
**Read the docs:** A renumbered import with a redirect note is not a collision. **Finding when** two files share a number or a title contradicts its file. **Proof:** V447.

### 9. Changelog structure (DH-2)

```
BAD:   ## [1.1.0] - 2026-02-01   above   ## [1.2.0] - 2026-03-01     ·   ## [1.0.0] - 01/01/2026
       [Unreleased]: …/compare/v1.1.0...HEAD   while the latest release is 1.2.0
GOOD:  descending versions, ISO dates, one link reference per version, [Unreleased] first and comparing from the latest
```
**Read the docs:** Which generator writes it (release-please, changesets, git-cliff, semantic-release, § 17)? Fix at the generator. Changesets has no dates; release-please puts them in parentheses. Per-package changelogs version independently (§ 18). **Finding when** the grammar the file follows is broken. **Proof:** V445 (`kacl-cli verify`, exit = error count, or the engine lane).

### 10. Translation missing (DI-1)

```
BAD:   docusaurus i18n.locales: [en, ar]      i18n/ar/docusaurus-plugin-content-docs/current/setup.md  absent
GOOD:  the page translated, or a declared policy naming the minimum content per locale
```
**Read the docs:** Is the locale declared in config (MISS) or inferred from a `docs/<lc>/` folder? Does a doc declare a lag or minimum-content policy (§ 11)? mkdocs-static-i18n `fallback_to_default: true` renders the source page instead and hides the gap. **Finding when** a declared locale lacks a page and no policy allows it. **Proof:** V448.

### 11. Translation stale (DI-2)

```
BAD:   docs/de/setup.md   <!-- source-commit: 1a2b3c4 -->      git log 1a2b3c4..HEAD -- docs/setup.md: 3 commits
GOOD:  re-translated and the marker moved, or an outdated banner under the declared policy
```
**Read the docs:** With a marker, the engine counts commits since it; without one it compares commit times (CHECK). A shallow clone breaks both (use full history). **Finding when** the source changed after the marker and no banner says so. **Proof:** V448.

### 12. Translation diverges (DI-3)

```
BAD:   docs/de/setup.md   ```bash pnpm dev:all ```       docs/setup.md   ```bash pnpm dev ```
GOOD:  the translated page carries the source's commands, code and link targets; only prose is localised
```
**Read the docs:** Strings and comments inside code are normalised away (localised example values are fine); what differs is a command, a statement or a link target. **Finding when** the translation carries a code block or link the source no longer has: its reader runs an old command. **Proof:** V448.

### 13. Placeholder in published prose (DF-1)

```
BAD:   docs/billing.md   "Refunds: TBD."   ·   "<insert the webhook URL here>"
GOOD:  the content, or the page kept out of the nav until it exists
```
**Read the docs:** A placeholder inside a config fence (`<your-api-key>`) is § 6; a roadmap or TODO file is its own content. A proposed ADR with open questions is normal. **Finding when** published prose ships the placeholder. **Proof:** V449.

### 14. Orphan page or nav to nothing (DF-2)

```
BAD:   mkdocs.yml   nav: - Billing: billing/overview.md      (file moved)      docs/old-login.md: in no nav, linked from nowhere
GOOD:  nav repointed; the orphan linked, listed in not_in_nav, or deleted
```
**Read the docs:** Is the page reachable through another page's link (by design)? `not_in_nav` patterns? Autogenerated sidebars have no orphans. **Finding when** a nav entry resolves to no page, or a page is reachable from nothing. **Proof:** V449 (the strict site build, or the engine lane).

## Output

```
doc-structure-scan — doc-claims 1.0 · renderer mkdocs (docs/) · 212 docs · locales ar (mkdocs-static-i18n) · lychee 0.24.2 offline

Findings: 3 · Unconfirmed: 1

1. docs/setup.md:14 ← 12 inbound links                         [report-with-fix]  MEDIUM · §2
   Heading "Install steps" renamed "Installing": 11 docs and deploy/alerts/api-alerts.yml:31 link #install-steps
   (mkdocs slugger). Fix: one rename across 12 sites, or {#install-steps} on the heading.
2. mkdocs.yml:1                                                 [report-with-fix]  MEDIUM · §4
   No strict and validation.links.anchors at info: a planted broken anchor builds green (mutation proof:
   mkdocs build exit 0). Fix: strict: true, validation.links.anchors: warn.
3. docs/de/setup.md:9                                           [report-with-fix]  MEDIUM · §12
   ```bash pnpm dev:all``` — the source page docs/setup.md:9 now runs `pnpm dev`.
4. docs/adr/0003-cache.md:3                                     [report-flagged]   MEDIUM · §6
   Superseded by 0006; 0006 never says Supersedes. Unproven link: is 0006 a whole replacement (§ 30)?

Verification: 31 candidates → 3 findings · 1 unconfirmed · 9 hardening · 0 pre-existing · 18 refuted

Class coverage:
  §1  missing file          checked (277 links → 0)
  §2  missing anchor        checked (45 fragments → 12 → 1 group)
  §3  external link         N/A (offline by default; NETWORK=1 runs lychee online)
  §4  gate cannot fail      checked (2 gates → 1; mutation proof recorded)
  §5  links into docs       checked (60 links → 1, folded into §2)
  §6  supersession          checked (3 pairs → 1 unconfirmed)
  §7  status / index        checked (41 statuses, 1 index → 0)
  §8  numbering             checked (40 files → 0)
  §9  changelog structure   checked (1 changelog, generator none → 0) · kacl-cli NOT-RUN
  §10 translation missing   checked (36 pages × ar → 0; lag policy docs/translations.md:4, § 11)
  §11 translation stale     checked (36 pairs → 4 CHECK → 0; banners present)
  §12 translation diverges  checked (36 pairs → 1)
  §13 placeholders          checked (212 docs → 0)
  §14 orphan / nav          checked (48 nav entries → 0)
  routed                    agent-facing docs → learning knowledge-ref-scan (329) · alerts without runbook_url → observability alert-rule-lint
```

`[report-with-fix]`: the target is cited, the oracle fired (engine, lychee, the build or the mutation), the fix is a docs edit. `[report-flagged]`: one link unproven (a renderer that may define the anchor, a partial supersession, a policy), named with its settling probe. Every run prints **all fourteen** §s as `checked (k → m)`, `N/A (<reason>)`, `blocked (<lane>: <reason>)`, `→ <owner> (k)` or `Live, unreviewed`; a tool lane prints `NOT-RUN (<why>)`, never PASS.

## Precedents

The verifier reads [`precedents/documentation.md`](../../../../snippets/precedents/documentation.md) and [`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 4. The ones this skill meets most: § 1 dated records keep only doc-to-doc links, § 10 renderer slugs, § 11 translation lag, § 12 versioned docs, § 15 external status codes, § 17 generated changelogs, § 22 style is not a gate, § 26 the ADR id parser, § 29 a gate is proven by failing, § 30 partial supersession, § 32 per-tool exit codes. The oracles are [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Documentation, V430–V432, V445, V447–V449.

## When to run

- On every whole-repo `/doc-refresh --audit`, printing all fourteen §s.
- On a diff that renames or deletes a doc or a heading, edits a docs-site config, a nav or sidebar file, an ADR, the changelog or a translated page, or a CI step that runs a docs tool.
- Before relying on a green docs job: §4 with the mutation proof.

## Halt conditions

- Do not report a HIGH or BLOCKER row as confirmed until a verifier other than the run that found it has passed it through [`refute-before-report.md`](../../../../snippets/refute-before-report.md); under `/doc-refresh --audit` that is `@verifier` in refute mode. Run standalone, apply the protocol as a separate second pass, tag those rows `self-verified`, and keep the rest Unconfirmed, Hardening or Refuted. Never drop them.
- Halt on any finding without the doc line, the target it fails to reach (file, slug list, reciprocal ADR, source page or gate line) and the V-number.
- Do not trust an exit code across tools: read each through its map (lychee 2 = broken links); a NOT-RUN tool lane is never PASS (§ 33).
- Do not grade a gate as working because the job is green: §4 needs the mutation proof (§ 29).
- Do not run a site build, lychee cache or linter cache inside the tree; scratch copies only, and the write check must print nothing.
- Do not report Markdown style as a finding unless the project enforces the linter (§ 22), and do not print "docs structure holds" without the class-coverage block.

## Related

- `doc-drift-scan` (this pack) owns the engine `scripts/doc-claims.py` and the docs-against-code classes; `doc-sample-scan` the fences and spec examples; `/doc-refresh --audit` runs all three and `--from-audit` closes the findings failing first.
- `/add-adr` writes ADRs with both supersession ends; `/add-runbook` writes runbooks this skill's §5 links must reach; `changelog-generate` writes the changelog §9 parses.
- Observability `alert-rule-lint`, devops `release-integrity-scan` RL-2, align `convention-oracle`, frontend `i18n-scan` and the learning pack's `knowledge-ref-scan`, each when its pack is installed.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.