Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit bf30586f88acfc421419cc23e6925e331acfc77f
parent 9cbed498b846120abc958645fef892d22b30f332
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 11:04:26 -0400

common: the archilyzer CLI's parser and command table machinery

parseArgv beside parseFlags keeps positionals and never feeds a value to a
declared boolean flag; _cli.ts resolves the longest command path, refuses an
unknown or misshapen flag before a command runs, and prints the usage table.
common's test glob gains bin/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Acommon/bin/_cli.test.ts | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/bin/_cli.ts | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/bin/_parseFlags.ts | 52++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcommon/package.json | 2+-
4 files changed, 307 insertions(+), 3 deletions(-)

diff --git a/common/bin/_cli.test.ts b/common/bin/_cli.test.ts @@ -0,0 +1,120 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseArgv } from "./_parseFlags"; +import { + argumentProblem, + booleanFlags, + resolveCommand, + runCli, + usage, + type Command, +} from "./_cli"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common test +// +// The archilyzer CLI's parser and lookup. The command table itself +// (archilyzer.ts) is not imported: these are the rules every row obeys. + +function cmd(path: string[], extra: Partial<Command> = {}): Command { + return { + path, + usage: `the ${path.join(" ")} command`, + run: async () => 0, + ...extra, + }; +} + +test("parseArgv keeps positionals in order and reads both flag spellings", () => { + assert.deepEqual( + parseArgv(["deploy", "site", "anilyzer", "--preview", "tags", "--x=1"]), + { positionals: ["deploy", "site", "anilyzer"], flags: { preview: "tags", x: "1" } }, + ); +}); + +test("a boolean flag never swallows the positional after it", () => { + assert.deepEqual(parseArgv(["build", "site", "--nodata", "jeralyzer"], ["nodata"]), { + positionals: ["build", "site", "jeralyzer"], + flags: { nodata: true }, + }); + // Without the declaration it would — which is why runCli declares them. + assert.deepEqual(parseArgv(["--nodata", "jeralyzer"]).flags, { nodata: "jeralyzer" }); +}); + +test("a lone -- is skipped, so `pnpm run build -- --nodata` still reads the flag", () => { + assert.deepEqual(parseArgv(["build", "site", "--", "--nodata"], ["nodata"]), { + positionals: ["build", "site"], + flags: { nodata: true }, + }); +}); + +test("a bare trailing flag and -h are true", () => { + assert.deepEqual(parseArgv(["deploy", "--preview"]).flags, { preview: true }); + assert.deepEqual(parseArgv(["-h"]).flags, { help: true }); +}); + +test("resolveCommand picks the LONGEST matching path and returns the rest", () => { + const table = [cmd(["build"]), cmd(["build", "site"]), cmd(["build", "hub"])]; + const hit = resolveCommand(table, ["build", "site", "anilyzer"]); + assert.deepEqual(hit?.command.path, ["build", "site"]); + assert.deepEqual(hit?.rest, ["anilyzer"]); + assert.deepEqual(resolveCommand(table, ["build", "all"])?.command.path, ["build"]); + assert.equal(resolveCommand(table, ["deploy"]), null); + // Table order does not matter. + assert.deepEqual( + resolveCommand([...table].reverse(), ["build", "hub"])?.command.path, + ["build", "hub"], + ); +}); + +test("argumentProblem refuses an unknown flag, a valued boolean, a bare string flag, an extra positional", () => { + const c = cmd(["deploy", "site"], { + flags: { preview: "string", nodata: "boolean" }, + maxPositionals: 1, + }); + assert.equal(argumentProblem(c, { preview: "x" }, ["a"]), null); + assert.equal(argumentProblem(c, { help: true }, []), null); + assert.match(argumentProblem(c, { preveiw: "x" }, [])!, /unknown flag --preveiw \(accepts --preview, --nodata\)/); + assert.match(argumentProblem(c, { nodata: "yes" }, [])!, /--nodata takes no value/); + assert.match(argumentProblem(c, { preview: true }, [])!, /--preview needs a value/); + assert.match(argumentProblem(c, {}, ["a", "b"])!, /unexpected argument "b"/); + assert.match(argumentProblem(cmd(["index"]), { x: true }, [])!, /it takes none/); +}); + +test("booleanFlags is the union of every boolean flag, plus help", () => { + const table = [ + cmd(["a"], { flags: { nodata: "boolean", preview: "string" } }), + cmd(["b"], { flags: { check: "boolean" } }), + ]; + assert.deepEqual(booleanFlags(table).sort(), ["check", "help", "nodata"]); +}); + +test("usage lists every command on its own line", () => { + const u = usage([cmd(["index"]), cmd(["build", "site"])]); + assert.match(u, /^Usage:/); + assert.match(u, /archilyzer index\s+the index command/); + assert.match(u, /archilyzer build site\s+the build site command/); +}); + +test("runCli runs the command with the rest, and refuses with 2 before running", async () => { + const seen: unknown[] = []; + const table = [ + cmd(["build", "site"], { + flags: { nodata: "boolean" }, + maxPositionals: 1, + run: async (ctx) => { + seen.push(ctx.positionals, ctx.flags); + return 7; + }, + }), + ]; + const quiet = { log: () => {}, error: () => {} }; + assert.equal(await runCli(table, ["build", "site", "x", "--nodata"], {}, quiet), 7); + assert.deepEqual(seen, [["x"], { nodata: true }]); + assert.equal(await runCli(table, ["build", "site", "--bogus"], {}, quiet), 2); + assert.equal(await runCli(table, ["nope"], {}, quiet), 2); + assert.equal(await runCli(table, [], {}, quiet), 2); + assert.equal(await runCli(table, ["--help"], {}, quiet), 0); + assert.equal(await runCli(table, ["build", "site", "--help"], {}, quiet), 0); + assert.equal(seen.length, 2, "no refused call reached run"); +}); diff --git a/common/bin/_cli.ts b/common/bin/_cli.ts @@ -0,0 +1,136 @@ +// The `archilyzer` CLI's machinery: a command table, the longest-path lookup +// over it, and the usage text. Hand-rolled on purpose — there is no +// commander/yargs in the workspace, and a table of a dozen rows does not earn +// one. The table itself is `archilyzer.ts`; this file holds nothing that knows +// what any command does, so it is unit-tested without importing one. + +import { parseArgv, type FlagValue } from "./_parseFlags"; + +// A flag a command accepts. "boolean" never takes a value (`--nodata`), +// "string" always needs one (`--preview <branch>`). +export type FlagKind = "boolean" | "string"; + +export type CommandContext = { + positionals: string[]; + flags: Record<string, FlagValue>; + env: NodeJS.ProcessEnv; +}; + +export type Command = { + // The words that select it: ["build", "site"]. + path: string[]; + // One line: the arguments after the path, then a short description. + usage: string; + // Every flag it accepts. Anything else is refused BEFORE `run`, because a + // typo'd flag on a deploy must not quietly mean "production". + flags?: Record<string, FlagKind>; + // At most this many positionals after the path (default 0). + maxPositionals?: number; + // The exit code. + run: (ctx: CommandContext) => Promise<number>; +}; + +/** + * The command whose `path` is the LONGEST prefix of `positionals`, and the + * positionals left after it — or null when no command matches. + */ +export function resolveCommand( + table: readonly Command[], + positionals: readonly string[], +): { command: Command; rest: string[] } | null { + let best: Command | null = null; + for (const c of table) { + if (c.path.length > positionals.length) continue; + if (!c.path.every((w, i) => positionals[i] === w)) continue; + if (!best || c.path.length > best.path.length) best = c; + } + return best ? { command: best, rest: positionals.slice(best.path.length) } : null; +} + +/** Every boolean flag any command declares — what parseArgv must not feed a value. */ +export function booleanFlags(table: readonly Command[]): string[] { + const out = new Set<string>(["help"]); + for (const c of table) { + for (const [name, kind] of Object.entries(c.flags ?? {})) { + if (kind === "boolean") out.add(name); + } + } + return [...out]; +} + +/** The usage text: one line per command, in table order. */ +export function usage(table: readonly Command[], bin = "archilyzer"): string { + const rows = table.map((c) => [`${bin} ${c.path.join(" ")}`, c.usage] as const); + const width = Math.max(...rows.map(([head]) => head.length)); + return [ + `Usage:`, + ...rows.map(([head, text]) => ` ${head.padEnd(width)} ${text}`), + ].join("\n"); +} + +/** + * Why `flags` / `rest` do not fit `command`, as one sentence — or null. + * `help` is always allowed (the caller prints usage for it). + */ +export function argumentProblem( + command: Command, + flags: Record<string, FlagValue>, + rest: readonly string[], +): string | null { + const name = command.path.join(" "); + const allowed = command.flags ?? {}; + for (const [key, value] of Object.entries(flags)) { + if (key === "help") continue; + const kind = allowed[key]; + if (!kind) { + const known = Object.keys(allowed); + return `${name}: unknown flag --${key}${ + known.length ? ` (accepts ${known.map((k) => `--${k}`).join(", ")})` : " (it takes none)" + }`; + } + if (kind === "boolean" && value !== true) { + return `${name}: --${key} takes no value`; + } + if (kind === "string" && value === true) { + return `${name}: --${key} needs a value`; + } + } + const max = command.maxPositionals ?? 0; + if (rest.length > max) { + return `${name}: unexpected argument "${rest[max]}"`; + } + return null; +} + +/** + * Parse argv, resolve the command, check its arguments and run it. Returns the + * exit code; every refusal prints to stderr and returns 2 (usage), which is + * what a shell script tests for. + */ +export async function runCli( + table: readonly Command[], + argv: readonly string[], + env: NodeJS.ProcessEnv = process.env, + out: { log: (s: string) => void; error: (s: string) => void } = console, +): Promise<number> { + const { flags, positionals } = parseArgv(argv, booleanFlags(table)); + if (positionals.length === 0) { + (flags.help ? out.log : out.error)(usage(table)); + return flags.help ? 0 : 2; + } + const hit = resolveCommand(table, positionals); + if (!hit) { + out.error(`archilyzer: unknown command "${positionals.join(" ")}"\n\n${usage(table)}`); + return 2; + } + if (flags.help) { + out.log(usage([hit.command])); + return 0; + } + const problem = argumentProblem(hit.command, flags, hit.rest); + if (problem) { + out.error(`${problem}\n\n${usage([hit.command])}`); + return 2; + } + return hit.command.run({ positionals: hit.rest, flags, env }); +} diff --git a/common/bin/_parseFlags.ts b/common/bin/_parseFlags.ts @@ -1,6 +1,6 @@ // Tiny argv parser: supports `--key value` and `--key=value`. Unknown -// positional args are dropped. Good enough for the bin shims; reach for -// commander/yargs if we ever need subcommands. +// positional args are dropped. Good enough for the flag-driven bin shims; the +// `archilyzer` CLI's subcommands use parseArgv below, which keeps positionals. export function parseFlags(argv: string[]): Record<string, string> { const out: Record<string, string> = {}; for (let i = 0; i < argv.length; i++) { @@ -21,3 +21,51 @@ export function parseFlags(argv: string[]): Record<string, string> { } return out; } + +// A flag's value: the string it was given, or `true` for a bare `--flag`. +export type FlagValue = string | true; + +/** + * The `archilyzer` CLI's argv parser: positionals in order, plus flags. + * + * `--key=value` always carries its value. `--key value` takes the next word as + * the value UNLESS `key` is one of `booleans` (so `build site --nodata jeralyzer` + * keeps `jeralyzer` a positional) or the next word is itself a flag; a bare + * `--key` is `true`. A lone `--` is skipped rather than treated as the end of + * the flags, because `pnpm run build -- --nodata` hands the script its `--` + * verbatim and the flag after it must still be read as a flag. `-h` is `--help`. + */ +export function parseArgv( + argv: readonly string[], + booleans: Iterable<string> = [], +): { flags: Record<string, FlagValue>; positionals: string[] } { + const bool = new Set(booleans); + const flags: Record<string, FlagValue> = {}; + const positionals: string[] = []; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--") continue; + if (a === "-h") { + flags.help = true; + continue; + } + if (!a.startsWith("--")) { + positionals.push(a); + continue; + } + const eq = a.indexOf("="); + if (eq > 2) { + flags[a.slice(2, eq)] = a.slice(eq + 1); + continue; + } + const key = a.slice(2); + const next = argv[i + 1]; + if (!bool.has(key) && next !== undefined && !next.startsWith("-")) { + flags[key] = next; + i++; + } else { + flags[key] = true; + } + } + return { flags, positionals }; +} diff --git a/common/package.json b/common/package.json @@ -45,7 +45,7 @@ "./styles/*": "./styles/*.ts" }, "scripts": { - "test": "tsx --test \"*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components,views,publish}/*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components,views,publish}/*/*.test.ts\"" + "test": "tsx --test \"*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components,views,publish,bin}/*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components,views,publish,bin}/*/*.test.ts\"" }, "dependencies": { "@aws-sdk/client-s3": "^3.1080.0",