Files
hyperframes/scripts/cli-command-tree.ts
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

117 lines
4.6 KiB
TypeScript

// Prints the CLI's command tree as JSON: every command and subcommand, with the flags each one declares.
// It loads the real citty definitions rather than parsing --help, so docs checks see exactly what the CLI parses.
// Run with bun after `bun run --cwd packages/core build`: the command modules import core's generated sources.
//
// bun scripts/cli-command-tree.ts [--root <checkout>]
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
type Resolvable<T> = T | Promise<T> | (() => T | Promise<T>);
interface CittyArg {
type?: string;
alias?: string | string[];
hidden?: boolean;
description?: string;
}
export interface CittyCommand {
meta?: Resolvable<{ name?: string; description?: string; hidden?: boolean }>;
args?: Resolvable<Record<string, CittyArg>>;
subCommands?: Resolvable<Record<string, Resolvable<CittyCommand>>>;
}
export interface TreeFlag {
type: string;
aliases: string[];
hidden: boolean;
deprecated: boolean;
}
export interface TreeCommand {
hidden: boolean;
deprecated: boolean;
flags: Record<string, TreeFlag>;
subCommands: Record<string, TreeCommand>;
}
const DEPRECATED = /\bdeprecated\b/i;
async function settle<T>(value: Resolvable<T> | undefined): Promise<T | undefined> {
return typeof value === "function" ? (value as () => T | Promise<T>)() : value;
}
/** Each `name: () => import("./commands/<file>.js").then((m) => m.<export>)` entry of cli.ts's commandLoaders. */
export function commandLoaders(
cliSource: string,
): { name: string; file: string; exportName: string }[] {
const block = cliSource.match(/const commandLoaders = \{([\s\S]*?)\n\};/)?.[1];
if (!block) throw new Error("cli.ts no longer declares `const commandLoaders = { ... };`");
const entries = [
...block.matchAll(
/^ {2}"?([a-z][a-z-]*)"?: \(\) =>[\s\S]*?import\("\.\/commands\/([\w-]+)\.js"\)\.then\(\(m\) => m\.(\w+)\)/gm,
),
].map((m) => ({ name: m[1]!, file: m[2]!, exportName: m[3]! }));
const declared = block.match(/^ {2}"?[a-z][a-z-]*"?: \(\) =>/gm)?.length ?? 0;
if (entries.length !== declared)
throw new Error(
`Read ${entries.length} of cli.ts's ${declared} command loaders; update the pattern`,
);
return entries;
}
function describeFlag(def: CittyArg): TreeFlag {
return {
type: def.type ?? "string",
aliases: def.alias === undefined ? [] : [def.alias].flat(),
hidden: def.hidden === true,
deprecated: DEPRECATED.test(def.description ?? ""),
};
}
/** One citty command and its subcommands, with every lazy part resolved. Positionals are not flags. */
export async function describeCommand(command: CittyCommand): Promise<TreeCommand> {
const meta = (await settle(command.meta)) ?? {};
const args = Object.entries((await settle(command.args)) ?? {});
const subs = Object.entries((await settle(command.subCommands)) ?? {});
const subCommands: Record<string, TreeCommand> = {};
for (const [name, sub] of subs) subCommands[name] = await describeCommand((await settle(sub))!);
return {
hidden: meta.hidden === true,
deprecated: DEPRECATED.test(meta.description ?? ""),
flags: Object.fromEntries(
args
.filter(([, def]) => def.type !== "positional")
.map(([name, def]) => [name, describeFlag(def)]),
),
subCommands,
};
}
export async function cliCommandTree(root: string): Promise<TreeCommand> {
const cliDir = join(root, "packages", "cli", "src");
const subCommands: Record<string, TreeCommand> = {};
for (const loader of commandLoaders(readFileSync(join(cliDir, "cli.ts"), "utf8"))) {
const url = pathToFileURL(join(cliDir, "commands", `${loader.file}.ts`)).href;
const mod = (await import(url)) as Record<string, CittyCommand>;
const command = mod[loader.exportName];
if (!command) throw new Error(`commands/${loader.file}.ts has no export ${loader.exportName}`);
subCommands[loader.name] = await describeCommand(command);
}
// The root answers --version/-V before citty runs (cli.ts's fast path) and --help/-h through citty.
const version = { type: "boolean", aliases: ["V"], hidden: false, deprecated: false };
return { hidden: false, deprecated: false, flags: { version }, subCommands };
}
if (import.meta.main) {
const rootFlag = process.argv.indexOf("--root");
const root = resolve(
rootFlag < 0 ? join(import.meta.dirname, "..") : process.argv[rootFlag + 1]!,
);
process.env.HYPERFRAMES_NO_TELEMETRY = "1";
const json = `${JSON.stringify(await cliCommandTree(root), null, 2)}\n`;
// Some command modules start timers or handles at import, so exit once a piped stdout has drained.
process.stdout.write(json, () => process.exit(0));
}