ci: check command docs drift
Add a docs check that reads the published CLI command list and verifies each top-level command has a command page plus README index entry. Reorder the command index to match CLI help order and note the contributor credit in the changelog. Co-authored-by: stainlu <stainlu@newtype-ai.org>
This commit is contained in:
parent
5b18b7d53c
commit
94b15c8e2e
@ -4,6 +4,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Added a command-doc drift check to `npm run docs:check` so every top-level CLI command has a matching command page and index entry. Thanks @stainlu.
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -8,6 +8,7 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th
|
||||
- [login](login.md)
|
||||
- [logout](logout.md)
|
||||
- [whoami](whoami.md)
|
||||
- [doctor](doctor.md)
|
||||
- [warmup](warmup.md)
|
||||
- [run](run.md)
|
||||
- [sync-plan](sync-plan.md)
|
||||
@ -19,13 +20,12 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th
|
||||
- [cache](cache.md)
|
||||
- [status](status.md)
|
||||
- [list](list.md)
|
||||
- [usage](usage.md)
|
||||
- [image](image.md)
|
||||
- [usage](usage.md)
|
||||
- [admin](admin.md)
|
||||
- [actions](actions.md)
|
||||
- [ssh](ssh.md)
|
||||
- [inspect](inspect.md)
|
||||
- [stop](stop.md)
|
||||
- [actions](actions.md)
|
||||
- [cleanup](cleanup.md)
|
||||
- [doctor](doctor.md)
|
||||
- [config](config.md)
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"check": "node --check index.js && node --test index.test.js",
|
||||
"docs:check": "node scripts/check-docs-links.mjs && node scripts/build-docs-site.mjs",
|
||||
"docs:check": "node scripts/check-command-docs.mjs && node scripts/check-docs-links.mjs && node scripts/build-docs-site.mjs",
|
||||
"live:auth": "CRABBOX_LIVE=1 scripts/live-auth-smoke.sh",
|
||||
"test": "node --test index.test.js"
|
||||
},
|
||||
|
||||
116
scripts/check-command-docs.mjs
Normal file
116
scripts/check-command-docs.mjs
Normal file
@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const commandDocsDir = path.join(root, "docs", "commands");
|
||||
const failures = [];
|
||||
|
||||
const commands = readHelpCommands();
|
||||
const commandNames = commands.map((command) => command.name);
|
||||
const commandSet = new Set(commandNames);
|
||||
const indexEntries = readCommandIndex();
|
||||
|
||||
for (const command of commands) {
|
||||
const file = path.join(commandDocsDir, `${command.name}.md`);
|
||||
if (!fs.existsSync(file)) {
|
||||
failures.push(`docs/commands/README.md is missing ${command.name}.md for CLI command ${command.name}`);
|
||||
continue;
|
||||
}
|
||||
const firstLine = fs.readFileSync(file, "utf8").split(/\r?\n/, 1)[0]?.trim();
|
||||
if (firstLine !== `# ${command.name}`) {
|
||||
failures.push(`${rel(file)} should start with "# ${command.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const seenIndexEntries = new Set();
|
||||
for (const entry of indexEntries) {
|
||||
if (seenIndexEntries.has(entry.name)) {
|
||||
failures.push(`docs/commands/README.md lists ${entry.name} more than once`);
|
||||
}
|
||||
seenIndexEntries.add(entry.name);
|
||||
|
||||
if (!commandSet.has(entry.name)) {
|
||||
failures.push(`docs/commands/README.md lists ${entry.name}, but CLI help does not expose that command`);
|
||||
}
|
||||
if (entry.href !== `${entry.name}.md`) {
|
||||
failures.push(`docs/commands/README.md should link ${entry.name} to ${entry.name}.md, not ${entry.href}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const command of commandNames) {
|
||||
if (!seenIndexEntries.has(command)) {
|
||||
failures.push(`docs/commands/README.md does not link CLI command ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
const documentedFiles = fs
|
||||
.readdirSync(commandDocsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "README.md")
|
||||
.map((entry) => entry.name.replace(/\.md$/, ""))
|
||||
.sort();
|
||||
|
||||
for (const fileCommand of documentedFiles) {
|
||||
if (!commandSet.has(fileCommand)) {
|
||||
failures.push(`docs/commands/${fileCommand}.md exists, but CLI help does not expose ${fileCommand}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (indexEntries.map((entry) => entry.name).join("\n") !== commandNames.join("\n")) {
|
||||
failures.push(
|
||||
`docs/commands/README.md order should match internal/cli/app.go help order:\n${commandNames
|
||||
.map((name) => `- [${name}](${name}.md)`)
|
||||
.join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error(failures.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`checked ${commands.length} command docs: command surface ok`);
|
||||
|
||||
function readHelpCommands() {
|
||||
const appPath = path.join(root, "internal", "cli", "app.go");
|
||||
const app = fs.readFileSync(appPath, "utf8");
|
||||
const match = app.match(/Commands:\n([\s\S]*?)\n\nCommon Flows:/);
|
||||
if (!match) {
|
||||
fail(`could not find Commands block in ${rel(appPath)}`);
|
||||
}
|
||||
|
||||
const commands = [];
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
const command = line.match(/^ ([a-z][a-z0-9-]*)\s{2,}\S/);
|
||||
if (command) {
|
||||
commands.push({ name: command[1] });
|
||||
}
|
||||
}
|
||||
|
||||
if (commands.length === 0) {
|
||||
fail(`could not parse any commands from ${rel(appPath)}`);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
function readCommandIndex() {
|
||||
const readmePath = path.join(commandDocsDir, "README.md");
|
||||
const readme = fs.readFileSync(readmePath, "utf8");
|
||||
const entries = [];
|
||||
for (const match of readme.matchAll(/^- \[([a-z][a-z0-9-]*)\]\(([^)]+)\)$/gm)) {
|
||||
entries.push({ name: match[1], href: match[2] });
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
fail(`could not parse command links from ${rel(readmePath)}`);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function rel(file) {
|
||||
return path.relative(root, file).replaceAll(path.sep, "/");
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user