Skip to content

Commit 8c2acab

Browse files
test(docs): check that doc links, commands and flags exist (#74)
## Value **V.A.L.U.E. tier:** micro — tests only; 2 new test files; no blast-radius path; the tests are the deterministic check. - **Problem:** the docs are about to get much more traffic (linked from Vapi's docs), and the last few PRs moved a lot of them. Nothing stopped a broken relative link, a stale anchor, or a command that no longer exists (`npm run <script>`, `--flag`) from shipping. - **Who it affects:** new users following the README and guides, and coding agents that run the commands the docs show. - **What changes:** - `tests/docs-links.test.ts`: every relative link and `#anchor` in the root `*.md`, `docs/**` and `examples/**` must resolve, ignoring code blocks. - `tests/docs-commands.test.ts`: every `npm run <x>` in the docs must be a `package.json` script, and every `--flag` shown with a command must exist in `src/`. `improvements.md` is excluded because it records historical behavior; `--allow-unrelated-histories` is allowed as a git flag. ## Evidence of value | Mutation | Result | | --- | --- | | Rename a heading that a link targets | links test fails, naming the file and anchor | | Rename a `package.json` script | commands test fails, naming the doc and script | | Rename a CLI flag in `src/` | commands test fails, naming the doc and flag | | Current docs | both pass (more than 20 files checked) | ## Testing plan - `npm test` (505 tests at this commit) and `npx tsc --noEmit` pass. - **Not tested:** external `https://` links, which would make the suite network-dependent. Refs TEST-141 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 7d143c8 + 30365d3 commit 8c2acab

2 files changed

Lines changed: 177 additions & 0 deletions

File tree

‎tests/docs-commands.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import assert from "node:assert/strict";
2+
import { readdirSync, readFileSync } from "node:fs";
3+
import { join } from "node:path";
4+
import test from "node:test";
5+
import { fileURLToPath } from "node:url";
6+
7+
// The commands the docs tell people to run must exist: every `npm run <x>`
8+
// is a package.json script, and every documented `--flag` is one the engine
9+
// still knows. Renaming or removing a script or a flag fails here until the
10+
// docs follow.
11+
12+
const REPO = fileURLToPath(new URL("..", import.meta.url));
13+
14+
// improvements.md is a historical log: its entries describe commands and
15+
// flags as they were when each problem was found, and are never rewritten.
16+
const HISTORICAL_DOCS = new Set(["improvements.md"]);
17+
18+
// Flags in the docs that belong to other tools (git, npm, node).
19+
const EXTERNAL_FLAGS = new Set(["--allow-unrelated-histories"]);
20+
21+
function docFiles(): string[] {
22+
const files = readdirSync(REPO).filter(
23+
(name) => name.endsWith(".md") && !HISTORICAL_DOCS.has(name),
24+
);
25+
const walk = (dir: string) => {
26+
for (const entry of readdirSync(join(REPO, dir), { withFileTypes: true })) {
27+
const path = `${dir}/${entry.name}`;
28+
if (entry.isDirectory()) walk(path);
29+
else if (entry.name.endsWith(".md")) files.push(path);
30+
}
31+
};
32+
walk("docs");
33+
walk("examples");
34+
return files.sort();
35+
}
36+
37+
interface Mention {
38+
file: string;
39+
value: string;
40+
}
41+
42+
function mentions(): { scripts: Mention[]; flags: Mention[] } {
43+
const scripts: Mention[] = [];
44+
const flags: Mention[] = [];
45+
for (const file of docFiles()) {
46+
const text = readFileSync(join(REPO, file), "utf8");
47+
for (const line of text.split("\n")) {
48+
for (const match of line.matchAll(/npm run ([a-z][a-z:-]*)/g))
49+
scripts.push({ file, value: match[1]! });
50+
// Flags written after `npm run …` on the same line.
51+
const start = line.indexOf("npm run ");
52+
if (start >= 0)
53+
for (const match of line
54+
.slice(start)
55+
.matchAll(/(?<![\w-])(--[a-z][a-z-]*[a-z])/g))
56+
flags.push({ file, value: match[1]! });
57+
}
58+
// Flags mentioned on their own in prose: `--overwrite`, `--resolve=ours`.
59+
for (const match of text.matchAll(/`(--[a-z][a-z-]*[a-z])(?:[= ][^`]*)?`/g))
60+
flags.push({ file, value: match[1]! });
61+
}
62+
return { scripts, flags };
63+
}
64+
65+
function sourceText(): string {
66+
return readdirSync(join(REPO, "src"))
67+
.filter((name) => name.endsWith(".ts"))
68+
.map((name) => readFileSync(join(REPO, "src", name), "utf8"))
69+
.join("\n");
70+
}
71+
72+
const unique = (list: Mention[]) =>
73+
[...new Map(list.map((m) => [`${m.value} (${m.file})`, m])).keys()].sort();
74+
75+
test("every `npm run <script>` in the docs is a package.json script", () => {
76+
const scripts = Object.keys(
77+
JSON.parse(readFileSync(join(REPO, "package.json"), "utf8")).scripts,
78+
);
79+
const { scripts: used } = mentions();
80+
assert.ok(
81+
used.length > 50,
82+
`expected many npm run mentions, found ${used.length}`,
83+
);
84+
assert.deepEqual(unique(used.filter((m) => !scripts.includes(m.value))), []);
85+
});
86+
87+
test("every documented --flag is one the engine knows", () => {
88+
const source = sourceText();
89+
const { flags } = mentions();
90+
assert.ok(
91+
flags.length > 20,
92+
`expected many flag mentions, found ${flags.length}`,
93+
);
94+
assert.deepEqual(
95+
unique(
96+
flags.filter(
97+
(m) => !EXTERNAL_FLAGS.has(m.value) && !source.includes(m.value),
98+
),
99+
),
100+
[],
101+
);
102+
});

‎tests/docs-links.test.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import assert from "node:assert/strict";
2+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3+
import { dirname, join, normalize, relative } from "node:path";
4+
import test from "node:test";
5+
import { fileURLToPath } from "node:url";
6+
7+
// Every relative link and #anchor in the docs must resolve. The README links
8+
// into a set of guides that link to each other, so renaming a file or a
9+
// heading would otherwise break links silently.
10+
11+
const REPO = fileURLToPath(new URL("..", import.meta.url));
12+
13+
function markdownFiles(): string[] {
14+
const files = readdirSync(REPO).filter((name) => name.endsWith(".md"));
15+
const walk = (dir: string) => {
16+
for (const entry of readdirSync(join(REPO, dir), { withFileTypes: true })) {
17+
const path = `${dir}/${entry.name}`;
18+
if (entry.isDirectory()) walk(path);
19+
else if (entry.name.endsWith(".md")) files.push(path);
20+
}
21+
};
22+
walk("docs");
23+
walk("examples");
24+
return files.sort();
25+
}
26+
27+
const withoutCode = (text: string) => text.replace(/```[\s\S]*?```/g, "");
28+
29+
// GitHub's heading anchor: lowercase, punctuation dropped, spaces to dashes.
30+
function anchorsOf(file: string): Set<string> {
31+
const text = withoutCode(readFileSync(file, "utf8"));
32+
return new Set(
33+
[...text.matchAll(/^#{1,6} (.*)$/gm)].map((match) =>
34+
match[1]!
35+
.toLowerCase()
36+
.trim()
37+
.replace(/[^\w\- ]+/g, "")
38+
.replace(/ /g, "-"),
39+
),
40+
);
41+
}
42+
43+
test("every relative link and anchor in the docs resolves", () => {
44+
const files = markdownFiles();
45+
const broken: string[] = [];
46+
for (const file of files) {
47+
const text = withoutCode(readFileSync(join(REPO, file), "utf8"));
48+
for (const match of text.matchAll(/\]\(([^)\s]+)\)/g)) {
49+
const link = match[1]!;
50+
if (/^(https?:|mailto:)/.test(link)) continue;
51+
const [path, anchor] = link.split("#");
52+
const target = path
53+
? normalize(join(REPO, dirname(file), decodeURIComponent(path)))
54+
: join(REPO, file);
55+
if (!existsSync(target)) {
56+
broken.push(`${file}: ${link} (no such file)`);
57+
continue;
58+
}
59+
if (
60+
anchor &&
61+
statSync(target).isFile() &&
62+
target.endsWith(".md") &&
63+
!anchorsOf(target).has(anchor)
64+
)
65+
broken.push(
66+
`${file}: ${link} (no heading #${anchor} in ${relative(REPO, target)})`,
67+
);
68+
}
69+
}
70+
assert.ok(
71+
files.length > 20,
72+
`expected the docs tree, found ${files.length} files`,
73+
);
74+
assert.deepEqual(broken, []);
75+
});

0 commit comments

Comments
 (0)