Files
hyperframes/scripts/check-docs-cli-references.mjs
ukimsanovandClaude Opus 5.5 b849f70834 ci(docs): keep the docs checks under the complexity budget and known to the workflow tests
Splits codeSpans, the flag and subcommand walk, the export walker and the
docs-impact entry point into small functions so fallow's audit passes, adds
scripts/cli-command-tree.test.ts (cli.ts's loader block and a citty command
described end to end), and lists docs-impact.yml among the workflows that read
the PR body on an edit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:09:32 -04:00

274 lines
11 KiB
JavaScript

// Every `hyperframes <command> [--flag]` the docs show in code must exist in the CLI the repo builds.
// The command tree comes from scripts/cli-command-tree.ts, which loads the real citty definitions, so a
// renamed or removed command or flag fails here instead of in a reader's terminal.
//
// Code that only looks like a command (CLI output quoted inline) opts out with
// `{/* cli-references: ignore */}` on its line or the line above.
//
// node scripts/check-docs-cli-references.mjs [--tree <tree.json>] [docs-dir]
import { execFileSync } from "node:child_process";
import { readFileSync, readdirSync, realpathSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = resolve(import.meta.dirname, "..");
// Release logs record what the CLI accepted at the time; old flags there are history, not drift.
const HISTORY_PAGES = new Set(["changelog.mdx", "weekly-updates.mdx"]);
const IGNORE = "{/* cli-references: ignore */}";
// Flags citty and the CLI accept on every command (packages/cli/src/utils/reject-unknown-flags.ts).
const ALWAYS_KNOWN = ["help", "h", "version", "v", "json"];
// Words that may come before `hyperframes` when a line runs it: package runners and shell prompts.
const RUNNERS = new Set(["$", ">", "npx", "bunx", "pnpx", "pnpm", "yarn", "npm", "dlx", "exec"]);
const RUNNER_FLAGS = new Set(["-y", "--yes"]);
const INVOCATION = /^(?:hyperframes|@hyperframes\/cli)(?:@[\w.-]+)?$/;
const ENV_ASSIGNMENT = /^[A-Z_][A-Z0-9_]*=/;
// `<name>`, `[options]`, `{id}`, `$VAR`, `...`: the docs' stand-ins for a value the reader supplies.
const PLACEHOLDER = /[<>[\]{}$|]|\.\.\.|…/;
// Labels each line as a fence marker, a line inside a fence, or prose.
function lineKinds(lines) {
let fence = null;
return lines.map((raw) => {
const marker = raw.match(/^\s*(`{3,}|~{3,})/);
if (!marker || (fence && !raw.trim().startsWith(fence))) return fence ? "code" : "prose";
fence = fence ? null : marker[1];
return "fence";
});
}
function proseSpans(raw, line) {
return [
...[...raw.matchAll(/(`+)([^`]+?)\1(?!`)/g)].map((m) => ({ line, text: m[2] })),
// Catalog pages hand the install line to a component: <InstallCommand command="npx hyperframes add x" />.
...[...raw.matchAll(/\bcommand=(?:"([^"]*)"|\{"([^"]*)"\})/g)].map((m) => ({
line,
text: m[1] ?? m[2],
})),
];
}
const CONTINUED = /\\\s*$/;
// Joins a fenced line onto a command a `\` continued; a command that ends here comes back as `span`.
function fencedStep(pending, raw, index) {
const line = pending?.line ?? index + 1;
const text = pending ? `${pending.text} ${raw.trim()}` : raw;
if (CONTINUED.test(text)) return { pending: { line, text: text.replace(CONTINUED, "") } };
return { pending: null, span: { line, text } };
}
/** Each piece of code on an MDX page as `{ line, text }`: fenced lines (joined across `\` continuations), inline code, command props. */
export function codeSpans(source) {
const lines = source.split("\n");
const kinds = lineKinds(lines);
const spans = [];
let pending = null;
for (const [index, raw] of lines.entries()) {
const ignored = raw.includes(IGNORE) || (lines[index - 1] ?? "").includes(IGNORE);
const step = kinds[index] === "code" ? fencedStep(pending, raw, index) : { pending: null };
pending = step.pending;
if (ignored) continue;
if (step.span) spans.push(step.span);
if (kinds[index] === "prose") spans.push(...proseSpans(raw, index + 1));
}
return spans;
}
/** Shell-ish words per command: quotes group, an unquoted `#` starts a comment, `&&`/`||`/`|`/`;`/`&` end a command. */
export function shellCommands(text) {
const commands = [[]];
const pattern = /"(?:\\.|[^"\\])*"|'[^']*'|&&|\|\||[|;&]|#.*$|[^\s"'|;&#]+(?:#\S*)?/g;
for (const [token] of text.matchAll(pattern)) {
if (token.startsWith("#")) break;
if (["&&", "||", "|", ";", "&"].includes(token)) commands.push([]);
else commands.at(-1).push(token);
}
return commands.filter((words) => words.length > 0);
}
/** The words after `hyperframes` when the command runs our CLI, else null (`npm i -g hyperframes`, `skills add …/hyperframes`). */
export function cliArguments(words) {
const at = words.findIndex((word) => INVOCATION.test(word));
if (at < 0) return null;
const lead = words.slice(0, at);
const runsCli = lead.every(
(word) => RUNNERS.has(word) || RUNNER_FLAGS.has(word) || ENV_ASSIGNMENT.test(word),
);
return runsCli ? words.slice(at + 1) : null;
}
const camel = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
const unquote = (word) => word.replace(/^(["'])(.*)\1$/, "$2");
const isFlag = (word) =>
word.startsWith("-") && word !== "-" && word !== "--" && !/^-\d/.test(word);
/** Every spelling (`x` of `--x` / `-x`) the command accepts, mapped to its flag. Mirrors reject-unknown-flags.ts. */
export function flagSpellings(command) {
const spellings = new Map(ALWAYS_KNOWN.map((name) => [name, { type: "boolean", global: true }]));
for (const [name, flag] of Object.entries(command.flags)) {
for (const spelling of [name, kebab(name), camel(name), ...flag.aliases])
spellings.set(spelling, flag);
}
return spellings;
}
// citty's findSubCommandIndex: the first word that is neither a flag nor a string/enum flag's value; `--` ends the search.
function subCommandIndex(words, spellings) {
for (let i = 0; i < words.length; i++) {
if (words[i] === "--") return -1;
if (!isFlag(words[i])) return i;
const flag = spellings.get(words[i].replace(/^-{1,2}/, ""));
if (!words[i].includes("=") && (flag?.type === "string" || flag?.type === "enum")) i++;
}
return -1;
}
function editDistance(a, b) {
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
let diagonal = row[0];
row[0] = i;
for (let j = 1; j <= b.length; j++) {
const above = row[j];
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
diagonal = above;
}
}
return row[b.length];
}
/** ` (did you mean x?)` for the nearest candidate a typo or a shortened name could have meant. */
function suggestion(word, candidates, prefix = "") {
const near = candidates
.map((candidate) => ({ candidate, distance: editDistance(word, candidate) }))
.filter(
({ candidate, distance }) =>
distance <= Math.max(1, word.length >> 2) || candidate.startsWith(word),
)
.sort((a, b) => a.distance - b.distance)[0];
return near ? ` (did you mean ${prefix}${near.candidate}?)` : "";
}
// `--name`, `--name=value`, `--no-name` -> the names citty could read it as; `-abc` -> one name per letter.
function flagReadings(word) {
if (word.startsWith("--")) {
const name = word.slice(2).split("=")[0];
return [name.startsWith("no-") ? [name, name.slice(3)] : [name]];
}
return [...word.slice(1).split("=")[0]].map((letter) => [letter]);
}
function flagProblem(spellings, shown, dashes, readings) {
const flag = readings.map((name) => spellings.get(name)).find(Boolean);
const name = `${dashes}${readings[0]}`;
if (!flag) {
const hint = dashes === "--" ? suggestion(readings[0], [...spellings.keys()], "--") : "";
return { severity: "error", message: `\`${shown}\` has no ${name}${hint}` };
}
if (flag.deprecated)
return { severity: "warning", message: `\`${shown} ${name}\` is deprecated` };
if (flag.hidden)
return { severity: "warning", message: `\`${shown} ${name}\` is hidden from --help` };
return null;
}
function flagProblems(command, shown, words) {
const spellings = flagSpellings(command);
const end = words.indexOf("--");
return (end < 0 ? words : words.slice(0, end))
.filter((word) => isFlag(word) && !PLACEHOLDER.test(word.split("=")[0]))
.flatMap((word) => {
const dashes = word.startsWith("--") ? "--" : "-";
return flagReadings(word).map((readings) => flagProblem(spellings, shown, dashes, readings));
})
.filter(Boolean);
}
function unknownSubcommand(command, path, name) {
if (PLACEHOLDER.test(name)) return [];
const kind = path.length === 1 ? "command" : "subcommand";
const hint = suggestion(name, Object.keys(command.subCommands));
return [{ severity: "error", message: `unknown ${kind} \`${path.join(" ")} ${name}\`${hint}` }];
}
function commandProblems(command, path, words) {
const hasSubs = Object.keys(command.subCommands).length > 0;
const at = hasSubs ? subCommandIndex(words, flagSpellings(command)) : -1;
const own = flagProblems(command, path.join(" "), at < 0 ? words : words.slice(0, at));
if (at < 0) return own;
const name = unquote(words[at]);
const next = command.subCommands[name];
if (!next) return [...own, ...unknownSubcommand(command, path, name)];
const nextPath = [...path, name];
const deprecated = next.deprecated
? [{ severity: "warning", message: `\`${nextPath.join(" ")}\` is deprecated` }]
: [];
return [...own, ...deprecated, ...commandProblems(next, nextPath, words.slice(at + 1))];
}
/** Problems with one invocation (the words after `hyperframes`), as `{ severity: "error" | "warning", message }`. */
export function invocationProblems(tree, args) {
return commandProblems(tree, ["hyperframes"], args);
}
/** Problems on one page, each with the line its command starts on. */
export function pageProblems(tree, source) {
return codeSpans(source).flatMap(({ line, text }) =>
shellCommands(text)
.map(cliArguments)
.filter(Boolean)
.flatMap((args) => invocationProblems(tree, args).map((problem) => ({ line, ...problem }))),
);
}
function mdxFiles(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) return mdxFiles(path);
return entry.name.endsWith(".mdx") ? [path] : [];
});
}
export function checkDocs(tree, docsDir) {
return mdxFiles(docsDir)
.filter((file) => !HISTORY_PAGES.has(relative(docsDir, file)))
.sort()
.flatMap((file) =>
pageProblems(tree, readFileSync(file, "utf8")).map((problem) => ({
file: relative(ROOT, file),
...problem,
})),
);
}
function loadTree(treePath) {
if (treePath) return JSON.parse(readFileSync(treePath, "utf8"));
const json = execFileSync("bun", [join(ROOT, "scripts", "cli-command-tree.ts")], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
return JSON.parse(json);
}
function main() {
const args = process.argv.slice(2);
const treeAt = args.indexOf("--tree");
const treePath = treeAt < 0 ? undefined : args[treeAt + 1];
const docsArg = args.find((arg, i) => !arg.startsWith("--") && (treeAt < 0 || i !== treeAt + 1));
const docsDir = resolve(docsArg ?? join(ROOT, "docs"));
const problems = checkDocs(loadTree(treePath), docsDir);
const errors = problems.filter((p) => p.severity === "error").length;
for (const p of problems) console.log(`${p.file}:${p.line}: ${p.severity}: ${p.message}`);
console.log(
`${errors} error(s), ${problems.length - errors} warning(s) in the docs' CLI references.`,
);
if (errors > 0) process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href)
main();