Skip to content
Back to skills

Doc Drift Scan

ASecurity

Human docs vs code and config: dead paths, symbols, scripts, flags, env, endpoints, pins, breaking notes, runbooks.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 20, 2026
ai-agentspythonrustgojavarubyphpc#shellbashreact

Works with

  • cursor
  • cli
  • api

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned October 20, 2026

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

Installs into .claude/skills of the current project.

Are you the author of Doc Drift Scan?

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

Security grade badge for Doc Drift Scan
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-doc-drift-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-doc-drift-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-drift-scan
description: "Human docs vs code and config: dead paths, symbols, scripts, flags, env, endpoints, pins, breaking notes, runbooks."
kind: skill
pack: documentation
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: doc-drift-scan

## Premise

A doc drifts when it tells a reader something the code and config no longer back: a path that moved, a script that was renamed, a flag the parser dropped, an env var nothing reads, an endpoint the spec does not serve, a `Node 20+` the pins exclude, a breaking change the changelog never announced, a CronJob a runbook still restarts by its old name. In the top 1,000 GitHub projects, 28.9% carry at least one doc reference to a deleted code element and such a reference survives 4.7 years on average (Tan, Wagner & Treude, EMSE 2024). Nothing fails when a doc lies, so nothing catches it unless the claim is resolved against its oracle.

This skill owns the **docs-against-code-and-config** classes DR-1–DR-5, DS-2, DS-3, DE-1–DE-3, DA-1, DH-1, DK-1 and DK-2 (§1–§14). Docs against docs (links, anchors, ADR graph, changelog grammar, translations, nav, the docs gate) are `doc-structure-scan`; samples and spec examples are `doc-sample-scan`; executing the setup is `quickstart-verify`.

**Every finding cites the doc line and the oracle line (or the oracle's silence after the written search), the axis, the class, and the recheck that proves it.** The engine `scripts/doc-claims.py` extracts every claim once and resolves it; an engine row is a candidate, never a finding. "No drift" is never printed without the class-coverage block (§ Output).

**Three axes, three fixes.** Every finding names its axis first, because a reader must know whether to delete a line or rewrite it, and an absence has no line to cite:

| Axis | The doc says… | …and the truth is | Classes |
|---|---|---|---|
| **DEAD** | X exists | X was deleted or renamed | DR-1–DR-4, DE-1, DA-1 (MISS), DK-1, DK-2 |
| **WRONG** | X behaves like this | X exists and behaves otherwise | DR-5, DS-2, DS-3, DE-3, DA-1 (method), DR-3 (wrong package) |
| **UNDOCUMENTED** | nothing | X exists and readers need it | DE-2, DH-1 |

`BROKEN = DEAD + WRONG`. UNDOCUMENTED is counted apart: nothing lies, something is missing.

## The rule

A candidate becomes a finding only when all four hold:

1. **The claim is real and in scope.** The doc is a human doc (README, CONTRIBUTING, `docs/**`, a package README, a runbook), not a dated record, tool config, test data, versioned docs or agent-facing knowledge ([precedents § 1, § 2, § 12, § 27, § 28](../../../../snippets/precedents/documentation.md)); the token is a claim, not an illustration (§ 6) or vocabulary (§ 9).
2. **The oracle was read and named.** The tree under the resolution ladder, the manifest, the parser, the pins, the env schema, the spec or router, git history, the manifests: the row cites which.
3. **The resolution is negative or the values differ**, and the recheck (`doc-claims.py --recheck=<id>`) still prints the row.
4. **No record makes it intentional.** Not a versioned doc set, not a generated doc fixed at its source (§ 17), not a third-party or live-infra name (§ 19).

**Direction is not decided here.** About half of the outdated references real projects fixed were fixed by restoring the code (Tan et al.). The finding reports both ends; `/doc-refresh --from-audit` decides which end moves, and a doc that is the contract (spec-first OpenAPI, a published reference, a versioned public CLI, an ADR-backed promise) routes to `/fix-bug` or `/backend-fix` instead of being edited to bless the code (§ 16).

## Boundaries

- **Agent-facing knowledge** (`ai/**` except `ai/runbooks/**` and `ai/decisions/**`, `CLAUDE.md`, `AGENTS.md`, `.claude/`) → the learning pack's `knowledge-ref-scan`; the engine prints those docs as `-> learning` in its ledger and never grades them. Refract's own `ai/` setup artifacts → `/setup-project-health`.
- `.env.example` against the env schema and `.env` → backend `env-diff`, when the backend pack is installed; §8–§10 take **prose** docs against the same oracle and cite an `env-diff` row as corroboration. Without the backend pack, the example-file half is out of scope here and the coverage block says so.
- The committed spec against code or runtime → backend `http-contract-scan` §10 (when installed); §11 takes prose endpoints against the spec and the router. The changelog version against the release → devops `release-integrity-scan` RL-2 (when installed); §12 takes the breaking set against the notes.
- Accepted ADRs and convention docs against code → align `convention-oracle` AL-03/04 (when installed); this skill never reads an ADR body as a claim (§ 1, § 13).
- Links, anchors, ADR metadata, changelog grammar, translations, placeholders, nav and the docs gate → `doc-structure-scan`; fences that do not compile, doctests, embedded snippets and spec examples → `doc-sample-scan`; the setup run → `quickstart-verify`; docstrings → `docstring-coverage`; diagrams → `diagram-sync`.

## Lanes

The engine reads every renderer's source format and every stack's oracle below. Read each tool **and its version** from the lockfile before trusting a default.

| Surface | Read as | Notes |
|---|---|---|
| Markdown / MDX (GitHub, MkDocs, Docusaurus, VitePress, mdBook, Hugo) | fence-aware, front matter and HTML comments skipped; inline code spans and shell fences carry the claims | the renderer is detected from `mkdocs.yml`, `docusaurus.config.*`, `book.toml`, `.vitepress/config.*`, `hugo.*` |
| reStructuredText (Sphinx) | `code-block` and `::` blocks, ``literal`` spans, `` `t <target>`_ `` links | partial: anchors print CHECK |

| Stack | Scripts (§3) | Flags (§4) | Env (§8–§10) | Endpoints (§11) | Pins (§6–§7) |
|---|---|---|---|---|---|
| **Node / TS** | `package.json` scripts in every workspace, dependency bins, `turbo.json` tasks; pnpm / npm / yarn / bun forms, `--filter`, `-C`, `cd` | the script file a doc runs (`node`, `tsx`, `./x.sh`) and its one-hop imports; `bin` entries | `process.env`, `import.meta.env`, zod / envalid / t3-env / Joi schemas, `configService.get` | Nest `@Controller` + `@Get`, Express / Fastify `router.get`, `setGlobalPrefix` | `.nvmrc`, `engines`, `packageManager`, `volta`, Dockerfile, CI `node-version` |
| **Python** | `[tool.poe.tasks]`, `[project.scripts]`, tox envs | argparse / click / typer names in the script and imports | `os.getenv`, `os.environ`, pydantic-settings (`env_prefix`) | FastAPI `@router.get` + `APIRouter(prefix=)`, Flask `@app.route` | `.python-version`, `requires-python`, `.tool-versions` |
| **Go** | Makefile, Taskfile | `go run ./cmd/x` package sources (cobra, `flag`) | `os.Getenv`, `os.LookupEnv`, envconfig tags | `HandleFunc`, gin / chi / echo routes | `go.mod` `go` line |
| **Rust** | Makefile, justfile | `cargo run` package sources (clap) | `env::var` | — (partial) | `rust-toolchain(.toml)` |
| **JVM / .NET** | Gradle tasks [K?], Makefile | picocli / System.CommandLine sources | `System.getenv`, `@Value("${x:d}")`, `GetEnvironmentVariable` | Spring `@RequestMapping` + `@GetMapping` | `pom.xml`, `.java-version`, Dockerfile |
| **PHP / Ruby** | `composer.json` scripts, Rakefile [K?] | artisan sources [K?] | `env('X')`, `getenv`, `ENV.fetch` | `Route::get` | `composer.json` `php`, `.ruby-version` |
| **Deploy** | — | — | compose, k8s, Helm values, CI env | — | compose / k8s `image:` tags |

A stack the table marks [K?] or partial prints `checked · partial` in the coverage block, never plain `checked`.

## Pre-flight

Run as **one** script from the repo root (bash 3.2-safe; shell state does not survive between tool calls). `DC` is the installed engine. `DOCS` narrows the claims to changed docs on a diff run (the oracles stay whole-repo). 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, and the ledger's last line names the lane.

```bash
DC="${DC:-.claude/skills/doc-drift-scan/scripts/doc-claims.py}"; DOCS="${DOCS:-}"
OUT="$(mktemp -d "${TMPDIR:-/tmp}/doc-drift.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=paths,symbols,scripts,flags,versions,tools,env,endpoints,breaking,resources ${DOCS:+--docs="$DOCS"} --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                          # status · class
cut -f3,7 "$OUT/claims.tsv" | sort | uniq -c | sort -rn | head -20                 # one root cause = one claim value
X=(-g '!**/node_modules/**' -g '!**/{dist,build,vendor,.venv,target,coverage}/**' -g '!ai/**' -g '!.claude/**' -g '!{CLAUDE,AGENTS}.md' -g '!**/{test,tests,fixtures,testdata}/**')
# D1. The env schema files §8–§10 read: files that read the environment AND build a schema (the refuter reads these).
rg -l0 --pcre2 'process\.env|import\.meta\.env|os\.environ|os\.getenv|BaseSettings' -g '*.{ts,js,mjs,py}' "${X[@]}" . | xargs -0 -r rg -l --pcre2 'z\.object\(|cleanEnv\(|createEnv\(|\(BaseSettings\)|Joi\.object\(|envalid|registerAs\('; ck D1 $?
# D1b. An env schema declared apart from its loader (`env.schema.ts` holds the zod object, `config.loader.ts` reads process.env): named for env, built as a schema.
rg -l --pcre2 '\b\w*[Ee]nv\w*\s*=\s*(?:z\.object|cleanEnv|createEnv|Joi\.object)\(|class\s+\w*(?:Settings|Env)\w*\((?:pydantic_settings\.)?BaseSettings\)' -g '*.{ts,js,mjs,py}' "${X[@]}" .; ck D1b $?
# D2. Docs that run the project's own CLIs: bin and console-script names §4 resolves against their parser.
rg -n --pcre2 '"bin"\s*:|^\[project\.scripts\]|^\[tool\.poetry\.scripts\]|^\[\[bin\]\]' -g 'package.json' -g 'pyproject.toml' -g 'Cargo.toml' "${X[@]}" .; ck D2 $?
# D3. STALE: `Updated:` / `last_reviewed:` stamps in human docs (informational; never counted in BROKEN).
rg -n --no-heading --pcre2 '^(?:Updated|Last updated|Last reviewed|last_reviewed|updated)\s*:\s*\d{4}-\d{2}-\d{2}' -g '*.{md,mdx}' "${X[@]}" . > "$OUT/stamps.txt"; ck D3 $?
python3 -I -c 'import re,sys,datetime as d;t=d.date.today();[print("STALE %s (%d days)"%(l.split(":")[0],(t-d.date.fromisoformat(m.group(1))).days)) for l in open(sys.argv[1]) for m in [re.search(r"(\d{4}-\d{2}-\d{2})",l)] if m and (t-d.date.fromisoformat(m.group(1))).days>30]' "$OUT/stamps.txt"
# Tool lane (§12): spec breaking changes between the last tag and HEAD. oasdiff exits 0 on a breaking diff without --fail-on.
TAG=$(git describe --tags --abbrev=0 2>/dev/null); SPEC=$(rg --files -g '{openapi,swagger}*.{json,yaml,yml}' "${X[@]}" . | head -1)
if [ -z "$TAG" ]; then echo "§12 oasdiff N/A (no release tag)"; elif [ -z "$SPEC" ]; then echo "§12 oasdiff N/A (no committed spec)"
elif ! command -v oasdiff >/dev/null; then echo "§12 oasdiff NOT-RUN (not installed; never npx -y)"
else oasdiff breaking --fail-on ERR "$TAG:${SPEC#./}" "HEAD:${SPEC#./}" > "$OUT/oasdiff.txt"; echo "§12 oasdiff exit $? — 0 none · 1 breaking (each must be in the notes) · other BLOCKED"; fi
find . -newer "$OUT/stamp" -type f -not -path './node_modules/*' -not -path './.git/*' | head -3; echo "write check done (empty = nothing written)"
```

The engine's ledger comes first: a `blocked` lane, a `-> learning` count that looks like most of the corpus, or a write-check hit is settled before any row is read. Its row format is `id · status · class · surface · doc · line · claim · detail`; `status` is MISS (DEAD), WRONG, or CHECK (ambiguous: read it). The `id` is stable for the recheck. `--all` adds the resolved and skipped rows, each skip with its precedent §.

## Measured

Measured 2026-10-19 with `doc-claims 1.0` (self-test 46/46: one seed and one decoy per lane).

- **A real NestJS + React monorepo** (read-only, write check empty, 15 s, 13,621 files). Scope: 107 human docs in scope (66 docs, 40 ADRs, 1 changelog; 47 runbooks), 329 agent-facing docs routed `-> learning`, 553 tool-config docs § 2, 3 test-data § 27, 2 templates § 6. Today's step 1, run verbatim, printed 3,446 MISS (3,250 in dated audits) and step 8 exited 2 on `src apps libs`. The engine's first run printed 309 candidates; after the scope, resolution and parsing fixes recorded below it prints **25**: DR-1 0 MISS (1,047 claims; 11 per-module layout names § 3, 18 build outputs § 4), DR-2 7 (592 claims; third-party vocabulary skipped § 9), DR-3 2 CHECK (package-local scripts run from the root, § 31), DR-4 0 (202 flags, 198 on third-party CLIs § 19), DS-2 0 (17 claims; `moduleResolution node10` was a false hit before the separator fix), DE-1 2 CHECK (in `.env.example`, read by no code), DA-1 0 (27 claims; 14 curls to Prometheus, Loki and Alertmanager ports § 19), DK-1 5 (a deleted health-controller path, a dated plan the runbook still links), DK-2 7 (from 43: `kubectl create secret generic` parsed `generic` as a name; third-party chart Services and out-of-band Secrets now § 19; a KEDA-derived HPA resolves through its ScaledObject; still open: an alert name no rule declares, and an HPA the runbook reads by the workload's name while KEDA names it `keda-hpa-<name>`). DH-1 N/A (0 tags). Greps: D1 2 (the env schema and the config loader; it printed 300+ zod contract files before it required an env read in the same file) · D2 0 · D3 0; no grep exited 2.
- **The 23 fixtures** carry no doc corpus beyond their own `DEFECTS.md` answer keys; every row there is a reference to the parent repo (`benchmarks/run.sh`, `templates/packs/…`) or framework vocabulary, so the seeded corpus is the engine's `--self-test` until a doc fixture lands (integrator request). Greps: D1 1 on `backend-defects`, `ds-defects` and `quality-mix`, 0 elsewhere; D2 and D3 0; no grep exited 2.
- **The `doc-drift` fixture** (`benchmarks/fixtures/doc-drift`, 2026-10-19; staged with `run.sh`, whose key line `Stage with: --git --history` runs the fixture's history-staging script: 14 commits, tags v1.4.0 and v1.5.0; write check empty): 42 docs in scope, renderer mkdocs (`docs/guide`), 3 package roots. The pre-flight printed **29 candidates, two per class for all fourteen §s** — the 28 doc-drift-scan seeds, every one, plus one same-cause twin (DR-3 on the Arabic setup page's `pnpm dev:all`, which is the DI-3 seed's root cause); 0 of the fixture's 22 decoys printed as a candidate (platform env § 8, third-party flags § 19 (11), gitignored paths § 4, a provenance banner § 20 and `/metrics` § 7 printed as skips). Without the history script DH-1 prints `N/A (no release tags)`. Greps: D1 0 (the schema file reads no `process.env`; its loader does), D1b 1 (`env.schema.ts`; added after this run: D1b is 1 on `backend-defects`, 0 on every other fixture), D2 1, D3 0; no grep exited 2; oasdiff NOT-RUN (not installed). Gaps the run found in the engine, fixed in the integration pass the same day with failing-first self-test cases (`--self-test` 53 checks; `tests/doc-claims/run.sh` pins the 61 fixture rows): DH-1 counted a note as announced on a section-wide word vote (now one line must carry half the subject's words as whole words plus a breaking or removal signal); DE-2 rows carried no `file:line` (now the schema line, surface primary).

Fixes the measurement forced: route matching is exact-first with a suffix match only on routes of two or more static segments (a `/rules` suffix matched a pricing route and printed a false method mismatch); curl claims to a port that is not the app's are third-party (§ 19); `kubectl` value flags (`--field-selector`, `-l`) no longer become resource names; YAML metadata is read line by line (a backtracking regex hung on the deploy manifests); env reads include test harnesses (load-test and e2e variables documented in runbooks).

## The 3,000-row path

1. **Check the scope first.** An area holding more than 30% of the rows is checked against precedents § 1, § 2, § 27, § 28 before a single row is read. A ledger with most of the corpus in scope and `ai/` missing from the skip list means the scope rules did not load.
2. **Group by claim value, not by line.** `cut -f3,7 claims.tsv | sort | uniq -c`: one renamed script, path, env var or Deployment is **one finding with N sites**; `--claim=<value>` lists them.
3. **Read in this order:** primary surface rows (README, quickstart, CONTRIBUTING, runbooks), then WRONG over DEAD over UNDOCUMENTED, then secondary docs.
4. **Close a group with one checked reason** (`14 DA-1 curls to :9090 and :3100: Prometheus and Loki APIs, § 19`).
5. **Ledger the rest** as `Live, unreviewed (k groups / n rows)` with the `--class` command that continues it; never `checked`.

## Scans for

Severity is set by who follows the doc and when ([precedents](../../../../snippets/precedents/documentation.md), the claim). **Primary surface**: README, quickstart and setup docs, CONTRIBUTING, runbooks, published API or SDK docs. **Secondary**: internal guides and how-tos (one step lower). **BLOCKER** is reserved for a runbook step that acts on the wrong resource (a renamed workload whose old name now belongs to something else) and a setup step that destroys data.

| § | Class | Engine lane | Finding when | Default (primary) | Oracle |
|---|---|---|---|---|---|
| 1 | DR-1 dead path | paths | MISS after the ladder, gitignore and rename lookup | MEDIUM | V433 |
| 2 | DR-2 dead symbol | symbols | a project-shaped name defined nowhere in source or config | MEDIUM | V433 |
| 3 | DR-3 dead script / target | scripts | no manifest or task file defines it where the doc runs it | HIGH | V434 |
| 4 | DR-4 dead flag | flags | the project CLI's parser does not declare it | MEDIUM | V434 |
| 5 | DR-5 deprecated as current | symbols, endpoints | a deprecation marker on a symbol the doc presents as current | MEDIUM | V435 |
| 6 | DS-2 version claim ≠ pins | versions | the claimed version is admitted by no pin | MEDIUM | V436 |
| 7 | DS-3 tool mismatch | tools | the command uses a package manager the lockfile does not | MEDIUM | V436 |
| 8 | DE-1 dead env | env | in an env context, read by nothing, in no example, deploy file or schema | MEDIUM | V438 |
| 9 | DE-2 required env undocumented | env | required with no default, named in no doc and no example | HIGH | V438 |
| 10 | DE-3 env default / values wrong | env | the doc's default or value set ≠ the schema's | MEDIUM; HIGH on a security- or money-relevant setting | V438 |
| 11 | DA-1 prose endpoint | endpoints | no spec path and no router declaration, or another method | MEDIUM; HIGH in public API docs | V439 |
| 12 | DH-1 breaking change unannounced | breaking + oasdiff lane | a `!:` / `BREAKING CHANGE:` commit or an oasdiff ERR missing from its section | HIGH for a published package or API, else MEDIUM | V445 |
| 13 | DK-1 runbook command | paths, scripts, flags in runbooks | §1, §3 or §4 inside a runbook | HIGH | V434 |
| 14 | DK-2 runbook resource | resources | a kind/name, namespace, compose service, alert or queue no manifest declares | HIGH; BLOCKER when the name now belongs to another resource | V446 |

### 1. Dead path (DR-1)

```
BAD:   README.md      "Config lives in `apps/api/src/config/settings.ts`."   (moved to apps/api/src/config/index.ts)
GOOD:  README.md      "Config lives in `apps/api/src/config/index.ts`."        · or the file restored when the doc is the contract
```
**Read the docs and the code:** Does the ladder miss it everywhere: root, the doc's directory, every package root, a unique suffix? Is it gitignored (`git check-ignore`, the directory form too) or a build output (§ 4)? Does the line say a command produces it (§ 5, CHECK)? What does the engine's detail say about history: `renamed -> <new> (<sha>)` gives the repoint; `deleted in <sha>` asks whether the deletion was intended. **Finding when** the path resolves nowhere and no precedent applies. **Proof:** V433, `--recheck=<id>` fails before and passes after; `--claim=<path>` prints no row in any doc.

### 2. Dead symbol (DR-2)

```
BAD:   docs/architecture.md   "`OrderReconciler` retries failed payouts."          (renamed PayoutReconciler)
GOOD:  "`PayoutReconciler` retries failed payouts."
```
**Read the docs and the code:** The engine reports only a project-shaped name (`…Service`, `…Processor`, `…Input`) or a member of a project receiver (`ordersService.cancelAll()`); framework vocabulary is § 9. Is the name a third-party API the project merely calls (search the lockfile's packages)? A rename in `git log -S<name>`? **Finding when** the project defines nothing by that name in source or config. **Proof:** V433.

### 3. Dead script or target (DR-3)

```
BAD:   README.md   ```bash  pnpm dev:all  ```          (root package.json: dev, dev:api, dev:web)
       runbook     make restore-db                      (Makefile: db-restore)
GOOD:  pnpm dev     ·   make db-restore
```
**Read the docs and the code:** Which package does the command run in: the root, a `--filter` / `-F` / `-C` target, a preceding `cd`? A script defined only in a sub-package and run from the root is CHECK (§ 31): does the doc `cd` first? Is the token a dependency bin (`pnpm vitest`)? A prose word ("npm package") is never a claim: the engine reads code spans and shell fences only. **Finding when** no manifest or task file defines it where the doc runs it. **Proof:** V434, the command's manifest lookup, plus every form of the same script across docs and CI.

### 4. Dead flag (DR-4)

```
BAD:   README.md   node tools/import.js --legacy-import     (tools/import.js declares --import, --format)
GOOD:  node tools/import.js --import
```
**Read the docs and the code:** Is the CLI the project's own: a script file the doc runs, a `bin` entry, a `[project.scripts]` name, `go run ./cmd/x`? A third-party CLI's flag is § 19 (the engine skips it and counts it). Is the flag built from a variable, a `--no-` negation, or declared in an imported module the one-hop read missed? **Finding when** the parser and its imports declare no such option. **Proof:** V434, the parser declaration (or a `--help` capture at the pinned version).

### 5. Deprecated API presented as current (DR-5)

```
BAD:   docs/sdk.md   "Call `getOrders()` to page through orders."     (orders.ts: /** @deprecated use listOrders */)
GOOD:  "Call `listOrders()` …"   ·   or "`getOrders()` is deprecated; use `listOrders()`."
```
**Read the docs and the code:** Which marker: JSDoc `@deprecated`, `#[deprecated]`, `// Deprecated:`, `@Deprecated`, `DeprecationWarning`, OpenAPI `deprecated: true`? Is the doc a migration guide naming the old API on purpose? **Finding when** the doc steers a reader to the marked API with no deprecation note. **Proof:** V435.

### 6. Version claim against the pins (DS-2)

```
BAD:   README.md   "Requires Node 20+."            (.nvmrc 22; engines.node >=22.12)
       CONTRIBUTING "Python 3.10"                   (requires-python >=3.12)
GOOD:  "Requires Node 22.12+."
```
**Read the docs and the code:** Which pins exist (the engine lists them): `.nvmrc`, `engines`, `.tool-versions`, `.python-version`, `requires-python`, `go.mod`, Dockerfile `FROM`, compose `image:`, the CI matrix? A documented support matrix wider than the dev pin is fine when CI tests it. A claim for a runtime with no pin prints `skip no-pin` in the ledger: UNVERIFIED, not a finding. **Finding when** no pin admits the claimed version. **Proof:** V436.

### 7. Package manager or tool mismatch (DS-3)

```
BAD:   README.md   npm install && npm run dev        (pnpm-lock.yaml; packageManager pnpm@10.29.2)
GOOD:  pnpm install && pnpm dev
```
**Read the docs and the code:** Is the doc for contributors or for consumers installing the published package (`npm install <name>` is fine)? Does corepack enforce `packageManager`? **Finding when** a contributor step uses a tool the lockfile does not. **Proof:** V436.

### 8. Dead env var (DE-1)

```
BAD:   docs/config.md   | `SEARCH_INDEX_URL` | search backend |       (no read, no schema key, no example, no manifest)
GOOD:  the row removed, or the variable wired again
```
**Read the docs and the code:** Is the line an env context (an env table, `.env`, `export`, `X=` in a fence)? Is it platform- or SDK-read (§ 8)? Is it a constant (§ 9)? Is it read under another name (a pydantic `env_prefix`, a `configService.get` key, a k6 `__ENV`)? A CHECK row "in `.env.example` but no code reads it" is `env-diff`'s to confirm. **Finding when** nothing reads it and nothing sets it. **Proof:** V438.

### 9. Required env var undocumented (DE-2)

```
BAD:   env.schema.ts   PAYMENTS_WEBHOOK_SECRET: z.string(),        (no default; in no doc and no .env example)
GOOD:  documented in the setup section and in .env.example with a comment
```
**Read the docs and the code:** Is it really required at boot (no `.optional()`, `.default()`, `devDefault`)? Is it mentioned anywhere: the manual, `.env` example comments, error messages, schema descriptions (§ 24)? **Finding when** no doc surface names it. **Proof:** V438. Corroborate with `env-diff` when the backend pack is installed.

### 10. Env default or values wrong (DE-3)

```
BAD:   docs/config.md   "`RETRY_LIMIT` (default `5`)"          env.ts: RETRY_LIMIT: z.coerce.number().default(3)
       docs/config.md   "`LOG_FORMAT`: `json` or `text`"       env.ts: z.enum(["json", "pretty"])
GOOD:  default `3` · `json` or `pretty`
```
**Read the docs and the code:** Is the doc describing another deploy profile (a Helm values default that overrides the schema)? Is the value security- or money-relevant (a token TTL, a rate limit, a currency): then HIGH. **Finding when** both values are in hand and differ. **Proof:** V438.

### 11. Prose endpoint not served (DA-1)

```
BAD:   docs/api.md   `POST /orders/{id}/void`             (spec and router: POST /orders/{id}/cancel)
GOOD:  `POST /orders/{id}/cancel`
```
**Read the docs and the code:** Did the engine resolve the global prefix and version (`setGlobalPrefix`, `servers[].url`)? For a Nest API, when the backend pack is installed, the router oracle is backend `validation-boundary-scan`'s `dto-types.py --routes <api src>` (run from that installed skill's folder): stdout is a header plus `HANDLER` rows only (verb in column 4, the mounted path with prefix, version and `exclude` applied in column 9; the statistics go to stderr), so it needs none of the backend audit's F-lines (measured on the `doc-drift` API: 12 handlers, 10 under the `v1` prefix, `metrics` and `health` excluded). Is the route excluded from the spec by design (§ 7)? Is the line a how-to or an illustration (§ 6, CHECK)? Is a curl aimed at a third-party port (§ 19)? A method mismatch on an exact path is WRONG; on a suffix match only it is CHECK. **Finding when** neither the spec nor the router serves it. **Proof:** V439; route to backend `http-contract-scan` §10 when the spec itself is stale.

### 12. Breaking change shipped with no release note (DH-1)

```
BAD:   git log v1.4.0..v1.5.0:  feat(api)!: drop the page param       CHANGELOG ## [1.5.0]: no mention
GOOD:  ## [1.5.0] ### Changed — **BREAKING** `page` removed; use `cursor`   (retro-adding is endorsed, § 25)
```
**Read the docs and the code:** Is the package published (an npm or PyPI name, a public API)? Is the changelog generated (§ 17: fix the commit footer or the generator config, then regenerate)? Per-package tags (§ 18) map to per-package changelogs. The oasdiff lane adds spec ERRs between the last tag and HEAD. No tags means N/A, never PASS. **Finding when** a breaking item is missing from the section that covers it. **Proof:** V445.

### 13. Runbook command (DK-1)

```
BAD:   docs/runbooks/restore.md   make restore-db · node tools/failover.js --region   (target renamed; flag removed)
GOOD:  make db-restore · node tools/failover.js --to <region>
```
**Read the docs and the code:** §1, §3 and §4 applied inside a runbook (by path, `type: runbook`, or Rollback / Mitigation headings). A runbook step fails when nobody can think; every row is HIGH. **Finding when** §1, §3 or §4 holds in a runbook. **Proof:** V434; the drill itself stays `/add-runbook`'s hygiene.

### 14. Runbook resource (DK-2)

```
BAD:   docs/runbooks/nightly-jobs.md   kubectl -n app get cronjob reports-nightly    (deploy/k8s/cron.yaml: reports-daily)
GOOD:  kubectl -n app get cronjob reports-daily
```
**Read the docs and the code:** Is the name in the repo's manifests, Helm values or compose file at all? A Service a third-party chart creates (a release or chart name in the resource name), a Secret created out of band, a KEDA-derived HPA, and a name created earlier in the same runbook are § 19 or resolved. Does the old name now belong to **another** resource (BLOCKER: the step acts on the wrong thing)? **Finding when** the repo declares the kind but not the name. **Proof:** V446.

Two claim types ride on this § (read lane; `doc-claims.py` does not extract them, so list each as `Live, unreviewed` until read): **a size or slug in a create command** (`--size`, `--node-size`, `--machine-type`, `--instance-type`) contradicted by the repo's own inventory table or IaC for the same resource — re-running the step builds a different, often costlier, resource (MEDIUM; finops `candidate-pricing` prices the difference when that pack is installed); and **a table named as absent** (`no longer exists`, `was dropped`) in a doc or comment while a migration or the schema still creates it (MEDIUM; the database owner confirms which side is current).

## Output

```
doc-drift-scan — doc-claims 1.0 · renderer github · 107 docs (329 -> learning) · pins: .nvmrc, engines, packageManager
Doc drift — DEAD=3  WRONG=2  UNDOCUMENTED=1  STALE=2   (BROKEN = DEAD + WRONG = 5)

Findings: 4 · Unconfirmed: 2

1. README.md:41                                              [report-with-fix]  HIGH · DEAD · §3
   `pnpm dev:all` — no script dev:all in 6 manifests (root: dev, dev:api, dev:web). Recheck 3fa91c02d1 fails.
   Fix: `pnpm dev` (2 more sites: CONTRIBUTING.md:18, docs/de/setup.md:12 → one finding, 3 sites).
2. docs/runbooks/nightly-jobs.md:22                          [report-with-fix]  HIGH · DEAD · §14
   CronJob/reports-nightly — deploy/k8s/cron.yaml:4 declares reports-daily (renamed in 9c1e2ab). Fix: the new name.
3. docs/configuration.md:12                                     [report-with-fix]  MEDIUM · WRONG · §10
   `RETRY_LIMIT` default 5 — apps/api/src/config/env.ts:9 `.default(3)`. Fix: 3.
4. CHANGELOG.md:7                                            [report-flagged]   HIGH · UNDOCUMENTED · §12
   6e0f1d2 feat(api)!: drop the page param — not in ## [1.5.0]. Unproven link: is @acme/api published? (package.json private: false)

Verification: 25 candidates → 4 findings · 2 unconfirmed · 6 hardening · 0 pre-existing · 13 refuted

Class coverage:
  §1  dead path              checked (1,047 claims → 0; 11 layout names § 3, 18 build outputs § 4)
  §2  dead symbol            checked (592 claims → 7 → 0; third-party vocabulary § 9)
  §3  dead script            checked (117 claims → 2 → 1)
  §4  dead flag              checked (202 claims → 0; 198 third-party § 19)
  §5  deprecated as current  checked (0 markers on documented symbols)
  §6  version vs pins        checked (17 claims → 0) · 3 UNVERIFIED (no pin for postgres: skip no-pin)
  §7  tool mismatch          checked (0 claims)
  §8  dead env               checked (72 claims → 2 CHECK → env-diff)
  §9  required undocumented  checked (14 schema keys → 0)
  §10 env default / values   checked (3 claims → 1)
  §11 prose endpoint         checked (27 claims → 0; 14 third-party ports § 19)
  §12 breaking unannounced   N/A (no release tags) · oasdiff N/A
  §13 runbook command        checked (448 claims → 5 → 1)
  §14 runbook resource       checked (265 claims → 7 → 1) · Live, unreviewed (2 groups / 4 rows — continue with --class=DK-2)
  routed                     agent-facing docs → learning knowledge-ref-scan (329) · .env.example → backend env-diff (2)
```

`[report-with-fix]`: both ends cited, the recheck fails, the fix is a local edit (or the code restored, § 16). `[report-flagged]`: one link unproven (publication, a contract question, a resource a chart might create); the row names it and the probe that settles it. The header line is a contract `/doc-refresh` reads: keep `Doc drift — DEAD=… WRONG=… UNDOCUMENTED=… STALE=…`. STALE counts docs whose `Updated:` stamp is older than 30 days (D3) and is never part of BROKEN. Every run prints **all fourteen** §s as `checked (k claims → m)`, `N/A (<reason>)`, `blocked (<lane>: <reason>)`, `→ <owner> (k)` or `Live, unreviewed`; a [K?] lane says `checked · partial`.

## 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, § 3 package roots, § 4 gitignored outputs, § 5 produced paths, § 8 platform env, § 9 constants and vocabulary, § 16 the doc is the contract, § 17 generated docs, § 19 third-party and live infra, § 28 agent-facing knowledge, § 31 package-local scripts. The oracles are [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Documentation, V433–V439, V445, V446.

## When to run

- On every whole-repo `/doc-refresh --audit`, printing all fourteen §s.
- On a diff that renames or deletes a file, script, flag, env key, route, export or k8s resource, or bumps a pin: run with `DOCS` unset (the oracle changed, every doc may now lie); on a diff that only edits docs, set `DOCS` to the changed docs.
- Before a release: §12 with the oasdiff lane.

## 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 on every BLOCKER and HIGH; 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 its axis, the doc line, the oracle line (or the written search), the engine row id and the V-number.
- Do not decide the direction here, and never propose editing a contract doc to match the code (§ 16); never hand-edit a generated doc (§ 17).
- Do not read a row from a lane the ledger prints `blocked`; its §s print `blocked`, never `checked`. `doc-claims exit 2` with no ledger is a blocked run.
- Do not grade agent-facing knowledge, dated records or tool config; the ledger's skip counts are the record.
- Write nothing into the repo: no `--out` or `--fences` inside it (the engine refuses), no doc edits from this skill; the write check must print nothing.
- Do not print "no drift" without the class-coverage block.

## Related

- `scripts/doc-claims.py` (this skill) — the engine; `doc-structure-scan` and `doc-sample-scan` run its other lanes, and `/doc-refresh --from-audit` re-runs `--recheck` and `--claim` to close findings failing first.
- `quickstart-verify` executes the setup; `docstring-coverage` grades the API reference; `diagram-sync` checks diagrams; `changelog-generate` writes the notes §12 checks.
- Backend `env-diff`, `http-contract-scan` §10 and `dto-types.py --routes` (backend pack), devops `release-integrity-scan` RL-2, align `convention-oracle`, 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.