docverity 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Devesh Agarwal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # Docverity
2
+
3
+ **Catch documentation that lies about your code.**
4
+
5
+ Docverity reads your docs, extracts the concrete claims they make about your
6
+ codebase, and checks each one against the actual source. When a documented flag
7
+ gets renamed, a config default changes, or a referenced file is deleted, your
8
+ docs silently start lying. Docverity turns that into a failing check, in CI,
9
+ before your users hit it.
10
+
11
+ Existing tools run the code blocks in your docs (`doctest`, `mdbook test`) or
12
+ help you author and host docs. None of them verify that the *prose* still tells
13
+ the truth about the code. That is the gap Docverity fills.
14
+
15
+ ## Two engines
16
+
17
+ Docverity ships with two complementary engines:
18
+
19
+ - **Reference checker** — deterministic, no API key, instant. Catches docs that
20
+ mention files, CLI flags, environment variables, or symbols that no longer
21
+ exist anywhere in the source.
22
+ - **Claim verifier** — LLM-backed, catches prose-level semantic drift: "the
23
+ default timeout is 30s", "this returns a list", "set `FOO=bar` to enable X".
24
+ Runs when `ANTHROPIC_API_KEY` (or `ANTHROPIC_AUTH_TOKEN`) is set.
25
+
26
+ Works free out of the box. Gets smarter with a key.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ npm install -g docverity
32
+ ```
33
+
34
+ Or run without installing:
35
+
36
+ ```bash
37
+ npx docverity
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ From your repo root:
43
+
44
+ ```bash
45
+ docverity
46
+ ```
47
+
48
+ By default it checks `README.md` and every `.md` file under `docs/`. Pass files
49
+ explicitly to narrow the scope, and use `-C` to point at another root:
50
+
51
+ ```bash
52
+ docverity -C path/to/repo
53
+ ```
54
+
55
+ Skip the LLM engine for a fast, deterministic-only run:
56
+
57
+ ```bash
58
+ docverity --no-llm
59
+ ```
60
+
61
+ ### Options
62
+
63
+ | Flag | Description |
64
+ | --- | --- |
65
+ | `--no-llm` | Deterministic checks only; no API calls. |
66
+ | `--model <id>` | Model for the LLM engine (default `claude-opus-4-8`). |
67
+ | `--fail-confidence <n>` | Minimum confidence (0..1) to fail the build on. Default `0.7`. |
68
+ | `--strict` | Also fail on unverifiable claims. |
69
+ | `--format <fmt>` | `pretty` (default), `json`, or `github`. |
70
+
71
+ Docverity exits non-zero when it finds drift above the confidence threshold, so
72
+ it fails CI the way a linter would.
73
+
74
+ ## In CI (GitHub Actions)
75
+
76
+ ```yaml
77
+ name: docs
78
+ on: [pull_request]
79
+ jobs:
80
+ docverity:
81
+ runs-on: ubuntu-latest
82
+ steps:
83
+ - uses: actions/checkout@v4
84
+ - uses: actions/setup-node@v4
85
+ with:
86
+ node-version: 22
87
+ - run: npx docverity --format github
88
+ env:
89
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
90
+ ```
91
+
92
+ With `--format github`, drifted claims appear as inline annotations on the
93
+ changed lines of the pull request.
94
+
95
+ ## Use it from an agent (MCP)
96
+
97
+ Docverity ships an MCP server so a coding agent can check docs as a tool and fix
98
+ drift in the same turn it changed the code. Add it to your MCP client:
99
+
100
+ ```json
101
+ {
102
+ "mcpServers": {
103
+ "docverity": {
104
+ "command": "npx",
105
+ "args": ["docverity", "mcp"]
106
+ }
107
+ }
108
+ }
109
+ ```
110
+
111
+ This exposes one tool, `check_docs`, which returns each drifted claim with its
112
+ file, line, the stale text, code evidence, a confidence score, and a suggested
113
+ fix the agent can apply directly. It runs the deterministic engine by default
114
+ (fast, free, no key); pass `llm: true` to also verify prose claims.
115
+
116
+ A [`SKILL.md`](SKILL.md) is included so the agent knows when to reach for it
117
+ (after editing code, before a release, when asked whether the docs are correct).
118
+
119
+ ## How it works
120
+
121
+ 1. **Extract** — parse each doc into atomic claims (a flag, a path, an env var,
122
+ a symbol, or a prose assertion).
123
+ 2. **Locate** — search the source tree (via ripgrep when available) for evidence
124
+ of each claim. Documentation files are excluded from evidence: a claim must
125
+ be backed by code, not by the docs restating it.
126
+ 3. **Verify** — the reference engine checks for hard evidence; the LLM engine
127
+ judges prose claims against the located evidence and returns
128
+ ok / drifted / unverifiable with a specific reason.
129
+ 4. **Report** — pretty output, machine-readable JSON, or GitHub annotations.
130
+
131
+ ## License
132
+
133
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import path from "node:path";
4
+ import kleur from "kleur";
5
+ import { extractClaims } from "./extract.js";
6
+ import { verifyReference } from "./verify-reference.js";
7
+ import { verifyLlm } from "./verify-llm.js";
8
+ import { hasApiKey } from "./llm.js";
9
+ import { printReport, printGithubAnnotations, toJson, summarize } from "./report.js";
10
+ import { discoverDocs } from "./discover.js";
11
+ import { runMcpServer } from "./mcp.js";
12
+ const program = new Command();
13
+ program
14
+ .name("docverity")
15
+ .description("Catch documentation that lies about your code.")
16
+ .version("0.1.0");
17
+ program
18
+ .command("check", { isDefault: true })
19
+ .description("Check docs for claims that no longer match the code.")
20
+ .argument("[docs...]", "doc files to check (default: README + docs/**/*.md)")
21
+ .option("-C, --root <dir>", "repo root", process.cwd())
22
+ .option("--no-llm", "skip the LLM claim verifier (deterministic checks only)")
23
+ .option("--model <id>", "model for the LLM engine", "claude-opus-4-8")
24
+ .option("--fail-confidence <n>", "min confidence to fail on (0..1)", "0.7")
25
+ .option("--strict", "also fail on unverifiable claims", false)
26
+ .option("--format <fmt>", "output format: pretty | json | github", "pretty")
27
+ .action(async (docs, rawOpts) => {
28
+ const root = path.resolve(rawOpts.root);
29
+ const docFiles = docs.length ? docs : discoverDocs(root);
30
+ if (!docFiles.length) {
31
+ console.error(kleur.yellow("No documentation files found."));
32
+ process.exit(0);
33
+ }
34
+ const useLlm = rawOpts.llm && hasApiKey();
35
+ if (rawOpts.llm && !hasApiKey() && rawOpts.format === "pretty") {
36
+ console.error(kleur.dim("No ANTHROPIC_API_KEY set — running deterministic checks only. Set a key to verify prose claims."));
37
+ }
38
+ const opts = {
39
+ root,
40
+ docFiles,
41
+ useLlm,
42
+ model: rawOpts.model,
43
+ failConfidence: Number(rawOpts.failConfidence),
44
+ strict: Boolean(rawOpts.strict),
45
+ };
46
+ const verdicts = [];
47
+ for (const doc of docFiles) {
48
+ const claims = extractClaims(root, doc);
49
+ verdicts.push(...(await verifyReference(root, claims)));
50
+ if (useLlm) {
51
+ try {
52
+ verdicts.push(...(await verifyLlm(root, doc, opts.model)));
53
+ }
54
+ catch (err) {
55
+ console.error(kleur.yellow(`LLM engine failed on ${doc}: ${err?.message ?? err}`));
56
+ }
57
+ }
58
+ }
59
+ let shouldFail;
60
+ if (rawOpts.format === "json") {
61
+ console.log(toJson(verdicts, opts));
62
+ shouldFail = summarize(verdicts, opts).failures.length > 0;
63
+ }
64
+ else if (rawOpts.format === "github") {
65
+ printGithubAnnotations(verdicts, opts);
66
+ shouldFail = summarize(verdicts, opts).failures.length > 0;
67
+ }
68
+ else {
69
+ shouldFail = printReport(verdicts, opts);
70
+ }
71
+ process.exit(shouldFail ? 1 : 0);
72
+ });
73
+ program
74
+ .command("mcp")
75
+ .description("Run as an MCP server (stdio) so agents can check docs as a tool.")
76
+ .action(async () => {
77
+ await runMcpServer();
78
+ });
79
+ program.parseAsync();
@@ -0,0 +1,27 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ /** Default doc discovery: README at root plus markdown under docs/. */
4
+ export function discoverDocs(root) {
5
+ const found = [];
6
+ for (const name of ["README.md", "README.markdown", "readme.md"]) {
7
+ if (existsSync(path.join(root, name))) {
8
+ found.push(name);
9
+ break;
10
+ }
11
+ }
12
+ const docsDir = path.join(root, "docs");
13
+ if (existsSync(docsDir) && statSync(docsDir).isDirectory()) {
14
+ walkMarkdown(docsDir, root, found);
15
+ }
16
+ return found;
17
+ }
18
+ function walkMarkdown(dir, root, out) {
19
+ for (const entry of readdirSync(dir)) {
20
+ const full = path.join(dir, entry);
21
+ const st = statSync(full);
22
+ if (st.isDirectory())
23
+ walkMarkdown(full, root, out);
24
+ else if (entry.endsWith(".md"))
25
+ out.push(path.relative(root, full));
26
+ }
27
+ }
@@ -0,0 +1,123 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ // Patterns for the kinds of token a doc can reference that we can check
4
+ // deterministically against the source tree.
5
+ const FLAG_RE = /(^|[\s(`"'])(--[a-zA-Z][a-zA-Z0-9-]+)/g;
6
+ const ENV_RE = /\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+){1,})\b/g;
7
+ const PATH_RE = /([\w./-]+\/[\w./-]+\.[a-zA-Z0-9]+|[\w-]+\.[a-zA-Z]{2,4})/g;
8
+ // Common English ALL_CAPS that are not env vars.
9
+ const ENV_STOPWORDS = new Set([
10
+ "NOTE",
11
+ "TODO",
12
+ "FIXME",
13
+ "WARNING",
14
+ "JSON",
15
+ "HTTP",
16
+ "HTTPS",
17
+ "API",
18
+ "URL",
19
+ "CLI",
20
+ "MIT",
21
+ "README",
22
+ ]);
23
+ // File-ish tokens that are usually prose, not real paths.
24
+ const PATH_STOPWORDS = new Set(["e.g.", "i.e.", "etc.", "vs.", "a.k.a."]);
25
+ /** Extract deterministically-checkable claims from a single doc file. */
26
+ export function extractClaims(root, docFile) {
27
+ const abs = path.isAbsolute(docFile) ? docFile : path.join(root, docFile);
28
+ const rel = path.relative(root, abs);
29
+ const text = readFileSync(abs, "utf8");
30
+ const lines = text.split("\n");
31
+ const claims = [];
32
+ let inFence = false;
33
+ let fenceLang = "";
34
+ let counter = 0;
35
+ const seen = new Set();
36
+ const push = (kind, line, tok, assertion, hints) => {
37
+ const key = `${kind}:${tok}`;
38
+ if (seen.has(key))
39
+ return; // one claim per distinct token keeps noise down
40
+ seen.add(key);
41
+ claims.push({
42
+ id: `${rel}#${++counter}`,
43
+ docFile: rel,
44
+ line,
45
+ kind,
46
+ text: tok,
47
+ assertion,
48
+ searchHints: hints,
49
+ });
50
+ };
51
+ for (let i = 0; i < lines.length; i++) {
52
+ const line = lines[i];
53
+ const lineNo = i + 1;
54
+ const fenceMatch = line.match(/^\s*```(\w+)?/);
55
+ if (fenceMatch) {
56
+ if (!inFence) {
57
+ inFence = true;
58
+ fenceLang = (fenceMatch[1] ?? "").toLowerCase();
59
+ }
60
+ else {
61
+ inFence = false;
62
+ fenceLang = "";
63
+ }
64
+ continue;
65
+ }
66
+ // Inside a shell code block, capture commands as claims.
67
+ if (inFence) {
68
+ if (["bash", "sh", "shell", "console", "zsh"].includes(fenceLang)) {
69
+ const cmd = line.replace(/^\s*\$\s?/, "").trim();
70
+ if (cmd && !cmd.startsWith("#")) {
71
+ extractFromCommand(cmd, lineNo, push);
72
+ }
73
+ }
74
+ continue;
75
+ }
76
+ // Outside code: scan inline code spans plus the raw line for tokens.
77
+ const inlineSpans = [...line.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
78
+ const scanText = line;
79
+ for (const m of scanText.matchAll(FLAG_RE)) {
80
+ const flag = m[2];
81
+ push("flag", lineNo, flag, `the CLI flag ${flag} exists`, [flag]);
82
+ }
83
+ for (const m of scanText.matchAll(ENV_RE)) {
84
+ const env = m[1];
85
+ if (ENV_STOPWORDS.has(env))
86
+ continue;
87
+ push("env", lineNo, env, `the environment variable ${env} is used`, [env]);
88
+ }
89
+ // Only treat path-looking tokens inside inline code as path claims, to
90
+ // avoid matching ordinary prose words with dots.
91
+ for (const span of inlineSpans) {
92
+ for (const m of span.matchAll(PATH_RE)) {
93
+ const p = m[1];
94
+ if (PATH_STOPWORDS.has(p))
95
+ continue;
96
+ if (p.startsWith("--"))
97
+ continue;
98
+ push("file", lineNo, p, `the path ${p} exists`, [p]);
99
+ }
100
+ // A bare identifier in backticks used like a function call.
101
+ const callMatch = span.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\(\)?$/);
102
+ if (callMatch) {
103
+ const sym = callMatch[1];
104
+ if (sym.length > 2) {
105
+ push("symbol", lineNo, sym, `the symbol ${sym} exists in the code`, [sym]);
106
+ }
107
+ }
108
+ }
109
+ }
110
+ return claims;
111
+ }
112
+ function extractFromCommand(cmd, lineNo, push) {
113
+ for (const m of cmd.matchAll(FLAG_RE)) {
114
+ const flag = m[2];
115
+ push("flag", lineNo, flag, `the CLI flag ${flag} exists`, [flag]);
116
+ }
117
+ for (const m of cmd.matchAll(ENV_RE)) {
118
+ const env = m[1];
119
+ if (ENV_STOPWORDS.has(env))
120
+ continue;
121
+ push("env", lineNo, env, `the environment variable ${env} is used`, [env]);
122
+ }
123
+ }
package/dist/llm.js ADDED
@@ -0,0 +1,42 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ let client = null;
3
+ export function hasApiKey() {
4
+ return Boolean(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN);
5
+ }
6
+ function getClient() {
7
+ if (client)
8
+ return client;
9
+ // Prefer an API key; otherwise fall back to a Bearer/OAuth token (e.g. from
10
+ // `ant auth login`), which needs the oauth beta header on every request.
11
+ if (!process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_AUTH_TOKEN) {
12
+ client = new Anthropic({
13
+ authToken: process.env.ANTHROPIC_AUTH_TOKEN,
14
+ defaultHeaders: { "anthropic-beta": "oauth-2025-04-20" },
15
+ });
16
+ }
17
+ else {
18
+ client = new Anthropic();
19
+ }
20
+ return client;
21
+ }
22
+ /**
23
+ * Call the model with a forced JSON schema and return the parsed object.
24
+ * Uses output_config.format so the first text block is guaranteed valid JSON.
25
+ */
26
+ export async function structuredCall(model, system, user, schema) {
27
+ // Built as an untyped param: `adaptive` thinking and `output_config` are
28
+ // supported by the API but newer than this SDK version's type definitions.
29
+ const params = {
30
+ model,
31
+ max_tokens: 8000,
32
+ thinking: { type: "adaptive" },
33
+ system,
34
+ messages: [{ role: "user", content: user }],
35
+ output_config: { format: { type: "json_schema", schema } },
36
+ };
37
+ const res = await getClient().messages.create(params);
38
+ const block = res.content.find((b) => b.type === "text");
39
+ if (!block)
40
+ throw new Error("Model returned no text content.");
41
+ return JSON.parse(block.text);
42
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,144 @@
1
+ import path from "node:path";
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { extractClaims } from "./extract.js";
6
+ import { verifyReference } from "./verify-reference.js";
7
+ import { verifyLlm } from "./verify-llm.js";
8
+ import { hasApiKey } from "./llm.js";
9
+ import { discoverDocs } from "./discover.js";
10
+ // The description is the agent's selection signal: it must say *when* to call
11
+ // the tool, not just what it does. Recent models under-reach for tools, so the
12
+ // trigger conditions are load-bearing.
13
+ const CHECK_DESCRIPTION = `Check whether a project's documentation still matches its source code, and report the claims that have drifted (gone stale or wrong).
14
+
15
+ Call this AFTER you edit, rename, move, or delete source files, CLI flags, environment variables, or functions — to catch documentation you may have just made inaccurate. Also call it when the user asks whether the README or docs are still correct, before cutting a release, or when reviewing a pull request that changes code.
16
+
17
+ Returns each drifted documentation claim with its file and line, the exact stale text, supporting code evidence, a confidence score, and a suggested fix you can apply directly with an edit. Fast and free by default (deterministic checks, no API key required); pass llm=true to additionally verify prose-level claims such as default values, return types, and described behavior.`;
18
+ const CHECK_INPUT_SCHEMA = {
19
+ type: "object",
20
+ properties: {
21
+ root: {
22
+ type: "string",
23
+ description: "Repository root to check. Defaults to the current working directory.",
24
+ },
25
+ docs: {
26
+ type: "array",
27
+ items: { type: "string" },
28
+ description: "Specific doc files to check, relative to root. Defaults to README plus docs/**/*.md.",
29
+ },
30
+ llm: {
31
+ type: "boolean",
32
+ description: "Also run the LLM prose verifier (needs ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN). Default false.",
33
+ },
34
+ failConfidence: {
35
+ type: "number",
36
+ description: "Minimum confidence (0..1) for a drift to be reported. Default 0.7.",
37
+ },
38
+ },
39
+ };
40
+ const MAX_FINDINGS = 50;
41
+ async function runCheck(args) {
42
+ const root = path.resolve(args.root ?? process.cwd());
43
+ const docFiles = args.docs?.length ? args.docs : discoverDocs(root);
44
+ const failConfidence = args.failConfidence ?? 0.7;
45
+ const wantLlm = Boolean(args.llm);
46
+ const useLlm = wantLlm && hasApiKey();
47
+ const verdicts = [];
48
+ for (const doc of docFiles) {
49
+ verdicts.push(...(await verifyReference(root, extractClaims(root, doc))));
50
+ if (useLlm) {
51
+ try {
52
+ verdicts.push(...(await verifyLlm(root, doc, "claude-opus-4-8")));
53
+ }
54
+ catch {
55
+ // Surface as a note rather than failing the whole call.
56
+ }
57
+ }
58
+ }
59
+ let ok = 0;
60
+ let drifted = 0;
61
+ let unverifiable = 0;
62
+ const findings = [];
63
+ for (const v of verdicts) {
64
+ if (v.status === "ok")
65
+ ok++;
66
+ else if (v.status === "drifted")
67
+ drifted++;
68
+ else
69
+ unverifiable++;
70
+ const include = (v.status === "drifted" && v.confidence >= failConfidence) ||
71
+ v.status === "unverifiable";
72
+ if (!include || v.status === "ok")
73
+ continue;
74
+ findings.push({
75
+ doc: v.claim.docFile,
76
+ line: v.claim.line,
77
+ kind: v.claim.kind,
78
+ text: v.claim.text,
79
+ status: v.status,
80
+ confidence: Number(v.confidence.toFixed(2)),
81
+ engine: v.engine,
82
+ explanation: v.explanation,
83
+ suggestedFix: v.suggestedFix,
84
+ evidence: v.evidence[0] ? `${v.evidence[0].file}:${v.evidence[0].line}` : undefined,
85
+ });
86
+ }
87
+ // Drifted first, then by confidence — the agent should act on these top-down.
88
+ findings.sort((a, b) => {
89
+ if (a.status !== b.status)
90
+ return a.status === "drifted" ? -1 : 1;
91
+ return b.confidence - a.confidence;
92
+ });
93
+ const truncated = findings.length > MAX_FINDINGS;
94
+ const shown = findings.slice(0, MAX_FINDINGS);
95
+ let note;
96
+ if (wantLlm && !hasApiKey()) {
97
+ note = "llm=true was requested but no ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN is set; ran deterministic checks only.";
98
+ }
99
+ if (truncated) {
100
+ note = `${note ? note + " " : ""}Showing ${MAX_FINDINGS} of ${findings.length} findings.`;
101
+ }
102
+ return {
103
+ summary: {
104
+ docsChecked: docFiles,
105
+ ok,
106
+ drifted,
107
+ unverifiable,
108
+ engine: useLlm ? "reference+llm" : "reference",
109
+ },
110
+ findings: shown,
111
+ note,
112
+ };
113
+ }
114
+ /** Start the stdio MCP server. Only protocol messages go to stdout. */
115
+ export async function runMcpServer() {
116
+ const server = new Server({ name: "docverity", version: "0.1.0" }, { capabilities: { tools: {} } });
117
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
118
+ tools: [
119
+ {
120
+ name: "check_docs",
121
+ description: CHECK_DESCRIPTION,
122
+ inputSchema: CHECK_INPUT_SCHEMA,
123
+ },
124
+ ],
125
+ }));
126
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
127
+ if (req.params.name !== "check_docs") {
128
+ return {
129
+ isError: true,
130
+ content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }],
131
+ };
132
+ }
133
+ const result = await runCheck((req.params.arguments ?? {}));
134
+ const headline = result.findings.length === 0
135
+ ? "No doc drift detected."
136
+ : `${result.findings.length} documentation claim(s) need attention.`;
137
+ return {
138
+ content: [
139
+ { type: "text", text: `${headline}\n\n${JSON.stringify(result, null, 2)}` },
140
+ ],
141
+ };
142
+ });
143
+ await server.connect(new StdioServerTransport());
144
+ }
package/dist/report.js ADDED
@@ -0,0 +1,84 @@
1
+ import kleur from "kleur";
2
+ export function summarize(verdicts, opts) {
3
+ let ok = 0;
4
+ let drifted = 0;
5
+ let unverifiable = 0;
6
+ const failures = [];
7
+ for (const v of verdicts) {
8
+ if (v.status === "ok")
9
+ ok++;
10
+ else if (v.status === "drifted") {
11
+ drifted++;
12
+ if (v.confidence >= opts.failConfidence)
13
+ failures.push(v);
14
+ }
15
+ else {
16
+ unverifiable++;
17
+ if (opts.strict)
18
+ failures.push(v);
19
+ }
20
+ }
21
+ return { ok, drifted, unverifiable, failures };
22
+ }
23
+ /** Pretty terminal report. Returns true if the check should fail the build. */
24
+ export function printReport(verdicts, opts) {
25
+ const summary = summarize(verdicts, opts);
26
+ const drifts = verdicts
27
+ .filter((v) => v.status === "drifted" && v.confidence >= opts.failConfidence)
28
+ .sort((a, b) => b.confidence - a.confidence);
29
+ if (drifts.length === 0) {
30
+ console.log(kleur.green(`\n✓ No doc drift detected.`) +
31
+ kleur.dim(` (${summary.ok} claims verified, ${summary.unverifiable} unverifiable)\n`));
32
+ return opts.strict && summary.failures.length > 0;
33
+ }
34
+ console.log(kleur.bold().red(`\n✗ ${drifts.length} doc claim(s) drifted from the code:\n`));
35
+ for (const v of drifts) {
36
+ const loc = kleur.cyan(`${v.claim.docFile}:${v.claim.line}`);
37
+ const tag = kleur.dim(`[${v.engine}]`);
38
+ const conf = kleur.dim(`${Math.round(v.confidence * 100)}%`);
39
+ console.log(` ${kleur.red("●")} ${loc} ${tag} ${conf}`);
40
+ console.log(` ${kleur.bold(v.claim.text)}`);
41
+ console.log(` ${v.explanation}`);
42
+ if (v.suggestedFix) {
43
+ console.log(` ${kleur.yellow("fix:")} ${kleur.dim(v.suggestedFix)}`);
44
+ }
45
+ if (v.evidence.length) {
46
+ const e = v.evidence[0];
47
+ console.log(kleur.dim(` seen: ${e.file}:${e.line} ${e.snippet}`));
48
+ }
49
+ console.log();
50
+ }
51
+ console.log(kleur.dim(`${summary.ok} ok · ${summary.drifted} drifted · ${summary.unverifiable} unverifiable\n`));
52
+ return true;
53
+ }
54
+ /** GitHub Actions workflow-command annotations. */
55
+ export function printGithubAnnotations(verdicts, opts) {
56
+ for (const v of verdicts) {
57
+ if (v.status !== "drifted" || v.confidence < opts.failConfidence)
58
+ continue;
59
+ const msg = `${v.claim.text} — ${v.explanation}`.replace(/\n/g, " ");
60
+ console.log(`::error file=${v.claim.docFile},line=${v.claim.line}::doc drift: ${msg}`);
61
+ }
62
+ }
63
+ export function toJson(verdicts, opts) {
64
+ const summary = summarize(verdicts, opts);
65
+ return JSON.stringify({
66
+ summary: {
67
+ ok: summary.ok,
68
+ drifted: summary.drifted,
69
+ unverifiable: summary.unverifiable,
70
+ failed: summary.failures.length,
71
+ },
72
+ verdicts: verdicts.map((v) => ({
73
+ doc: v.claim.docFile,
74
+ line: v.claim.line,
75
+ kind: v.claim.kind,
76
+ text: v.claim.text,
77
+ status: v.status,
78
+ confidence: v.confidence,
79
+ engine: v.engine,
80
+ explanation: v.explanation,
81
+ suggestedFix: v.suggestedFix,
82
+ })),
83
+ }, null, 2);
84
+ }
package/dist/search.js ADDED
@@ -0,0 +1,157 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ const execFileAsync = promisify(execFile);
6
+ // Directories never worth searching for evidence of documented behavior.
7
+ const IGNORE_DIRS = [
8
+ "node_modules",
9
+ ".git",
10
+ "dist",
11
+ "build",
12
+ "out",
13
+ "coverage",
14
+ ".next",
15
+ ".venv",
16
+ "vendor",
17
+ ];
18
+ // Documentation file types are excluded from evidence: a claim in the docs
19
+ // must be backed by the *code*, not by the docs restating it (otherwise a
20
+ // README that mentions a removed flag would cite itself as proof).
21
+ const DOC_EXTENSIONS = [".md", ".markdown", ".mdx", ".rst", ".txt", ".adoc"];
22
+ function isDocFile(file) {
23
+ const lower = file.toLowerCase();
24
+ return DOC_EXTENSIONS.some((ext) => lower.endsWith(ext));
25
+ }
26
+ let rgChecked = false;
27
+ let rgAvailable = false;
28
+ async function hasRipgrep() {
29
+ if (rgChecked)
30
+ return rgAvailable;
31
+ rgChecked = true;
32
+ try {
33
+ await execFileAsync("rg", ["--version"]);
34
+ rgAvailable = true;
35
+ }
36
+ catch {
37
+ rgAvailable = false;
38
+ }
39
+ return rgAvailable;
40
+ }
41
+ /**
42
+ * Search the repo for a literal string. Returns up to `limit` evidence hits.
43
+ * Uses ripgrep when available, falling back to a Node-based walk otherwise.
44
+ */
45
+ export async function searchLiteral(root, needle, limit = 8) {
46
+ if (!needle.trim())
47
+ return [];
48
+ if (await hasRipgrep()) {
49
+ const args = [
50
+ "--fixed-strings",
51
+ "--line-number",
52
+ "--no-heading",
53
+ "--color",
54
+ "never",
55
+ "--max-count",
56
+ String(limit),
57
+ ];
58
+ for (const dir of IGNORE_DIRS)
59
+ args.push("--glob", `!${dir}/`);
60
+ for (const ext of DOC_EXTENSIONS)
61
+ args.push("--glob", `!*${ext}`);
62
+ args.push("--", needle, ".");
63
+ try {
64
+ const { stdout } = await execFileAsync("rg", args, {
65
+ cwd: root,
66
+ maxBuffer: 8 * 1024 * 1024,
67
+ });
68
+ return parseRgOutput(stdout, limit);
69
+ }
70
+ catch (err) {
71
+ // rg exits 1 when there are no matches; that is not an error for us.
72
+ if (err?.code === 1)
73
+ return [];
74
+ throw err;
75
+ }
76
+ }
77
+ return fallbackSearch(root, needle, limit);
78
+ }
79
+ function parseRgOutput(stdout, limit) {
80
+ const out = [];
81
+ for (const raw of stdout.split("\n")) {
82
+ if (!raw)
83
+ continue;
84
+ // format: path:line:content
85
+ const first = raw.indexOf(":");
86
+ const second = raw.indexOf(":", first + 1);
87
+ if (first < 0 || second < 0)
88
+ continue;
89
+ const file = raw.slice(0, first);
90
+ const line = Number(raw.slice(first + 1, second));
91
+ const snippet = raw.slice(second + 1).trim();
92
+ out.push({ file, line, snippet: snippet.slice(0, 200) });
93
+ if (out.length >= limit)
94
+ break;
95
+ }
96
+ return out;
97
+ }
98
+ import { readdirSync, readFileSync, statSync } from "node:fs";
99
+ function fallbackSearch(root, needle, limit) {
100
+ const out = [];
101
+ const walk = (dir) => {
102
+ if (out.length >= limit)
103
+ return;
104
+ let entries;
105
+ try {
106
+ entries = readdirSync(dir);
107
+ }
108
+ catch {
109
+ return;
110
+ }
111
+ for (const entry of entries) {
112
+ if (out.length >= limit)
113
+ return;
114
+ if (IGNORE_DIRS.includes(entry))
115
+ continue;
116
+ const full = path.join(dir, entry);
117
+ let st;
118
+ try {
119
+ st = statSync(full);
120
+ }
121
+ catch {
122
+ continue;
123
+ }
124
+ if (st.isDirectory()) {
125
+ walk(full);
126
+ }
127
+ else if (st.isFile() && st.size < 2 * 1024 * 1024 && !isDocFile(full)) {
128
+ let content;
129
+ try {
130
+ content = readFileSync(full, "utf8");
131
+ }
132
+ catch {
133
+ continue;
134
+ }
135
+ const lines = content.split("\n");
136
+ for (let i = 0; i < lines.length; i++) {
137
+ if (lines[i].includes(needle)) {
138
+ out.push({
139
+ file: path.relative(root, full),
140
+ line: i + 1,
141
+ snippet: lines[i].trim().slice(0, 200),
142
+ });
143
+ if (out.length >= limit)
144
+ return;
145
+ }
146
+ }
147
+ }
148
+ }
149
+ };
150
+ walk(root);
151
+ return out;
152
+ }
153
+ /** Resolve a documented path claim against the filesystem. */
154
+ export function fileExists(root, relPath) {
155
+ const clean = relPath.replace(/^\.\//, "").replace(/[`*]/g, "");
156
+ return existsSync(path.join(root, clean));
157
+ }
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ // Core data model for DocDrift.
2
+ //
3
+ // A "claim" is one checkable assertion that a documentation file makes about
4
+ // the codebase. The pipeline is: extract claims -> locate evidence in the
5
+ // repo -> verify each claim against that evidence -> report.
6
+ export {};
@@ -0,0 +1,127 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { searchLiteral } from "./search.js";
4
+ import { structuredCall } from "./llm.js";
5
+ const EXTRACT_SYSTEM = `You extract verifiable factual claims that a documentation file makes about a software codebase.
6
+
7
+ A claim is a specific, checkable assertion: a default value, a return type, a parameter name, a config key, an install step, a behavior ("by default X happens"), an output shape. Ignore marketing copy, aspirational statements, and anything not checkable against source code.
8
+
9
+ For each claim, provide search terms (identifiers, strings, file names) that would help locate the relevant code. Be precise; prefer fewer high-quality claims over many vague ones.`;
10
+ const VERIFY_SYSTEM = `You verify whether documentation claims still match the codebase, given source-code evidence.
11
+
12
+ For each claim, decide:
13
+ - "ok": the evidence confirms the claim is still true.
14
+ - "drifted": the evidence contradicts the claim (the docs are now wrong).
15
+ - "unverifiable": the evidence is insufficient to decide.
16
+
17
+ Be conservative. Only mark "drifted" when the evidence clearly contradicts the claim. When unsure, choose "unverifiable". A false "drifted" verdict is worse than a missed one. Give the specific contradiction and, when drifted, a concrete suggested doc fix.`;
18
+ const EXTRACT_SCHEMA = {
19
+ type: "object",
20
+ additionalProperties: false,
21
+ properties: {
22
+ claims: {
23
+ type: "array",
24
+ items: {
25
+ type: "object",
26
+ additionalProperties: false,
27
+ properties: {
28
+ line: { type: "integer" },
29
+ text: { type: "string" },
30
+ assertion: { type: "string" },
31
+ searchHints: { type: "array", items: { type: "string" } },
32
+ },
33
+ required: ["line", "text", "assertion", "searchHints"],
34
+ },
35
+ },
36
+ },
37
+ required: ["claims"],
38
+ };
39
+ const VERIFY_SCHEMA = {
40
+ type: "object",
41
+ additionalProperties: false,
42
+ properties: {
43
+ verdicts: {
44
+ type: "array",
45
+ items: {
46
+ type: "object",
47
+ additionalProperties: false,
48
+ properties: {
49
+ id: { type: "string" },
50
+ status: { type: "string", enum: ["ok", "drifted", "unverifiable"] },
51
+ confidence: { type: "number" },
52
+ explanation: { type: "string" },
53
+ suggestedFix: { type: "string" },
54
+ },
55
+ required: ["id", "status", "confidence", "explanation"],
56
+ },
57
+ },
58
+ },
59
+ required: ["verdicts"],
60
+ };
61
+ /** LLM engine: extract prose claims from a doc and verify them against the code. */
62
+ export async function verifyLlm(root, docFile, model) {
63
+ const abs = path.isAbsolute(docFile) ? docFile : path.join(root, docFile);
64
+ const rel = path.relative(root, abs);
65
+ const docText = readFileSync(abs, "utf8");
66
+ const numbered = docText
67
+ .split("\n")
68
+ .map((l, i) => `${i + 1}: ${l}`)
69
+ .join("\n");
70
+ const { claims: raw } = await structuredCall(model, EXTRACT_SYSTEM, `Documentation file: ${rel}\n\n${numbered}`, EXTRACT_SCHEMA);
71
+ if (!raw.length)
72
+ return [];
73
+ const claims = raw.map((c, i) => ({
74
+ id: `${rel}~${i + 1}`,
75
+ docFile: rel,
76
+ line: c.line,
77
+ kind: "prose",
78
+ text: c.text,
79
+ assertion: c.assertion,
80
+ searchHints: c.searchHints,
81
+ }));
82
+ // Gather evidence for each claim from the source tree.
83
+ const evidenceByClaim = new Map();
84
+ for (const claim of claims) {
85
+ const found = [];
86
+ for (const hint of claim.searchHints.slice(0, 4)) {
87
+ found.push(...(await searchLiteral(root, hint, 4)));
88
+ }
89
+ evidenceByClaim.set(claim.id, dedupeEvidence(found).slice(0, 8));
90
+ }
91
+ const verifyPayload = claims.map((c) => ({
92
+ id: c.id,
93
+ claim: c.assertion,
94
+ docText: c.text,
95
+ evidence: evidenceByClaim.get(c.id) ?? [],
96
+ }));
97
+ const { verdicts: rawVerdicts } = await structuredCall(model, VERIFY_SYSTEM, `Verify these claims against the evidence:\n\n${JSON.stringify(verifyPayload, null, 2)}`, VERIFY_SCHEMA);
98
+ const byId = new Map(claims.map((c) => [c.id, c]));
99
+ const out = [];
100
+ for (const v of rawVerdicts) {
101
+ const claim = byId.get(v.id);
102
+ if (!claim)
103
+ continue;
104
+ out.push({
105
+ claim,
106
+ status: v.status,
107
+ confidence: v.confidence,
108
+ explanation: v.explanation,
109
+ suggestedFix: v.suggestedFix,
110
+ evidence: evidenceByClaim.get(v.id) ?? [],
111
+ engine: "llm",
112
+ });
113
+ }
114
+ return out;
115
+ }
116
+ function dedupeEvidence(items) {
117
+ const seen = new Set();
118
+ const out = [];
119
+ for (const e of items) {
120
+ const key = `${e.file}:${e.line}`;
121
+ if (seen.has(key))
122
+ continue;
123
+ seen.add(key);
124
+ out.push(e);
125
+ }
126
+ return out;
127
+ }
@@ -0,0 +1,68 @@
1
+ import { searchLiteral, fileExists } from "./search.js";
2
+ /**
3
+ * The deterministic engine: verify each claim by looking for hard evidence in
4
+ * the source tree. No model, no API key. High precision by design — when in
5
+ * doubt it returns "unverifiable" rather than "drifted".
6
+ */
7
+ export async function verifyReference(root, claims) {
8
+ const verdicts = [];
9
+ for (const claim of claims) {
10
+ verdicts.push(await verifyOne(root, claim));
11
+ }
12
+ return verdicts;
13
+ }
14
+ async function verifyOne(root, claim) {
15
+ const base = { claim, evidence: [], engine: "reference" };
16
+ switch (claim.kind) {
17
+ case "file": {
18
+ const exists = fileExists(root, claim.text);
19
+ return exists
20
+ ? {
21
+ ...base,
22
+ status: "ok",
23
+ confidence: 0.95,
24
+ explanation: `${claim.text} exists on disk.`,
25
+ }
26
+ : {
27
+ ...base,
28
+ status: "drifted",
29
+ confidence: 0.9,
30
+ explanation: `The docs reference ${claim.text}, but no such file or directory exists.`,
31
+ suggestedFix: `Update or remove the reference to ${claim.text}.`,
32
+ };
33
+ }
34
+ case "flag":
35
+ case "env":
36
+ case "symbol": {
37
+ const hits = await searchLiteral(root, claim.text);
38
+ if (hits.length > 0) {
39
+ return {
40
+ ...base,
41
+ status: "ok",
42
+ confidence: 0.8,
43
+ explanation: `Found ${hits.length} occurrence(s) of ${claim.text} in the source.`,
44
+ evidence: hits.slice(0, 3),
45
+ };
46
+ }
47
+ const noun = claim.kind === "flag"
48
+ ? "CLI flag"
49
+ : claim.kind === "env"
50
+ ? "environment variable"
51
+ : "symbol";
52
+ return {
53
+ ...base,
54
+ status: "drifted",
55
+ confidence: claim.kind === "symbol" ? 0.6 : 0.8,
56
+ explanation: `The docs mention the ${noun} ${claim.text}, but it does not appear anywhere in the source.`,
57
+ suggestedFix: `Verify ${claim.text} still exists; it may have been renamed or removed.`,
58
+ };
59
+ }
60
+ default:
61
+ return {
62
+ ...base,
63
+ status: "unverifiable",
64
+ confidence: 0.3,
65
+ explanation: `Prose claim — needs the LLM engine to verify.`,
66
+ };
67
+ }
68
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "docverity",
3
+ "version": "0.1.0",
4
+ "description": "Catch documentation that lies about your code. Verify that your docs' claims still match the source, in CI.",
5
+ "type": "module",
6
+ "bin": {
7
+ "docverity": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "dev": "tsc --watch",
18
+ "start": "node dist/cli.js",
19
+ "test": "npm run build && node --test test/*.test.mjs",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "keywords": [
23
+ "documentation",
24
+ "docs",
25
+ "drift",
26
+ "ci",
27
+ "readme",
28
+ "doc-rot",
29
+ "lint",
30
+ "ai",
31
+ "claude",
32
+ "mcp",
33
+ "model-context-protocol",
34
+ "agent"
35
+ ],
36
+ "author": "Devesh Agarwal",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/deveshagarwal/docverity.git"
41
+ },
42
+ "homepage": "https://github.com/deveshagarwal/docverity#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/deveshagarwal/docverity/issues"
45
+ },
46
+ "dependencies": {
47
+ "@anthropic-ai/sdk": "^0.70.0",
48
+ "@modelcontextprotocol/sdk": "^1.29.0",
49
+ "commander": "^12.1.0",
50
+ "kleur": "^4.1.5"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^22.0.0",
54
+ "typescript": "^5.6.0"
55
+ }
56
+ }