Skip to content

Commit 4d95467

Browse files
test(docs): check that doc links, commands and flags exist
The README now links into eight guides that link to each other, and the docs name many commands and flags. Nothing checked either: renaming a heading, a script or a flag would leave the docs quietly wrong. - tests/docs-links.test.ts: every relative link and #anchor in the root markdown files, docs/ and examples/ resolves (GitHub's heading anchor rules, code blocks ignored). - tests/docs-commands.test.ts: every `npm run <x>` in the docs is a package.json script, and every documented --flag (after `npm run` on the same line, or on its own in inline code) appears in src/. Flags of other tools are allow-listed, and improvements.md is skipped as a historical log. Each test fails when a heading, a script or a flag is renamed (checked by breaking one of each). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 30f8b1f commit 4d95467

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)