diff --git a/.github/workflows/docs-impact.yml b/.github/workflows/docs-impact.yml
new file mode 100644
index 00000000000..ec99799f37b
--- /dev/null
+++ b/.github/workflows/docs-impact.yml
@@ -0,0 +1,62 @@
+name: Docs impact
+
+permissions:
+ contents: read
+
+# Suppress hyperframes CLI telemetry from HeyGen's own CI runs.
+# External users' CI continues to emit telemetry unless they set this themselves.
+env:
+ HYPERFRAMES_NO_TELEMETRY: "1"
+
+# No path filter: a public export can change from any package source, and the script passes at once when
+# neither the CLI nor a package changed. `edited`, `labeled` and `unlabeled` start a fresh run when the
+# opt-out changes; re-running an old run would reuse the event's stale body and labels. Reading the label
+# and body from the event payload needs no extra permission, fork PRs included. Not a merge-queue check:
+# a merge group has no PR body (see merge-queue-workflows.test.mjs before adding merge_group).
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, edited, labeled, unlabeled]
+ branches: [main]
+
+concurrency:
+ group: docs-impact-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ docs-impact:
+ name: Docs impact
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ fetch-depth: 0
+
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: 22
+
+ # HEAD is the PR merge ref; its first parent is the current base tip, unlike the payload's
+ # base.sha, which goes stale and would count the base's newer commits as the PR's changes.
+ - name: Find the pull request's current base
+ id: base
+ run: |
+ git rev-parse --verify -q HEAD^2 > /dev/null || { echo "::error::HEAD is not the pull request merge commit"; exit 1; }
+ sha="$(git rev-parse --verify HEAD^1)"
+ echo "sha=$sha" >> "$GITHUB_OUTPUT"
+ git diff --quiet "$sha" HEAD -- packages/cli/src || echo "cli=true" >> "$GITHUB_OUTPUT"
+
+ - run: bash scripts/ci/install-workspace-dependencies.sh
+
+ # Only a CLI change needs the command tree, which imports core's generated sources.
+ - if: steps.base.outputs.cli == 'true'
+ run: bun run --cwd packages/core build
+
+ - name: Require docs for CLI and public export changes
+ env:
+ BASE_SHA: ${{ steps.base.outputs.sha }}
+ PR_BODY: ${{ github.event.pull_request.body }}
+ PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
+ run: node scripts/check-docs-impact.mjs --base "$BASE_SHA"
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 9417b77a0f9..88580a484c1 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -16,6 +16,11 @@ on:
- "DOCS_GUIDELINES.md"
- "packages/core/schemas/**"
- "scripts/sync-schemas.ts"
+ # A CLI change can strand a command or flag the docs still show.
+ - "packages/cli/src/**"
+ - "scripts/cli-command-tree.ts"
+ - "scripts/check-docs-cli-references.mjs"
+ - ".github/workflows/docs.yml"
push:
branches: [main]
paths:
@@ -23,6 +28,11 @@ on:
- "DOCS_GUIDELINES.md"
- "packages/core/schemas/**"
- "scripts/sync-schemas.ts"
+ # A CLI change can strand a command or flag the docs still show.
+ - "packages/cli/src/**"
+ - "scripts/cli-command-tree.ts"
+ - "scripts/check-docs-cli-references.mjs"
+ - ".github/workflows/docs.yml"
concurrency:
group: docs-${{ github.ref }}
@@ -52,3 +62,24 @@ jobs:
# --check-redirects also resolves every redirect destination, so a
# restructure cannot leave a redirect pointing at a page it removed.
run: npx mint broken-links --check-redirects
+
+ cli-references:
+ name: CLI references
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: 22
+
+ - run: bash scripts/ci/install-workspace-dependencies.sh
+
+ # The command tree loads the CLI's command modules, which import core's generated sources.
+ - run: bun run --cwd packages/core build
+
+ - name: Check every command and flag the docs show exists in the CLI
+ run: node scripts/check-docs-cli-references.mjs
diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx
index 684a0a85150..b984ae425d1 100644
--- a/docs/packages/cli.mdx
+++ b/docs/packages/cli.mdx
@@ -23,13 +23,13 @@ npm install -g hyperframes
| You want to | Use |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Start a project | [`init`](#init), [`add`](#add), [`catalog`](#catalog) |
-| Bring in source material | [`capture`](#capture), [`transcribe`](#transcribe), [`tts`](#tts), [`remove-background`](#remove-background), [`media-treatment`](#media-treatment), [`beats`](#beats), [`audio`](#audio), [`normalize-audio`](#normalize-audio) |
+| Bring in source material | [`capture`](#capture), [`transcribe`](#transcribe), [`tts`](#tts), [`remove-background`](#remove-background), [`media-treatment`](#media-treatment), [`beats`](#beats), [`audio`](#audio), [`normalize-audio`](#normalize-audio), [`models`](#models) |
| Look at it, or share it | [`preview`](#preview), [`present`](#present-and-play), [`play`](#present-and-play), [`publish`](#publish), [`open`](#open-and-catch-up), [`catch-up`](#open-and-catch-up) |
| Find problems | [`lint`](#lint), [`check`](#check), [`snapshot`](#snapshot), [`keyframes`](#keyframes), [`compare`](#compare-and-grade-compare), [`grade-compare`](#compare-and-grade-compare) |
| Edit from the terminal | [`timeline`](#timeline), [`history`](#history) |
| Make a file | [`render`](#render), [`benchmark`](#benchmark) |
| Render somewhere else | [`cloud`](#hyperframes-cloud), [`lambda`](#hyperframes-lambda), [`cloudrun`](#hyperframes-cloudrun) |
-| Fix your setup | [`doctor`](#doctor), [`browser`](#browser), [`upgrade`](#upgrade), [`docs`](#docs), [`info`](#info-and-compositions), [`compositions`](#info-and-compositions) |
+| Fix your setup | [`doctor`](#doctor), [`browser`](#browser), [`upgrade`](#upgrade), [`docs`](#docs), [`info`](#info-and-compositions), [`compositions`](#info-and-compositions), [`clean`](#clean) |
| Connect other tools | [`auth`](#hyperframes-auth), [`skills`](#skills), [`figma`](#figma-and-events), [`telemetry`](#telemetry), [`feedback`](#feedback) |
`validate`, `inspect`, and `layout` still work but are
@@ -73,11 +73,12 @@ prompts, apart from confirmations you can skip with `--yes` or `--no-confirm`.
**Version notices.** An owned install (npm, bun, pnpm, brew) updates itself
quietly in the background and prints one line on the next run:
-`hyperframes auto-updated to v0.1.5`. It never crosses a major version. Anywhere
+{/* cli-references: ignore */}
+`hyperframes auto-updated to v0.8.181`. It never crosses a major version. Anywhere
it cannot install — `npx`, an unknown installer — you get a notice instead:
```
- Update available: 0.1.4 → 0.1.5
+ Update available: 0.8.180 → 0.8.181
Run: npx hyperframes@latest
```
diff --git a/docs/studio-app/index.mdx b/docs/studio-app/index.mdx
index 7e3c8665b8e..a170e388b35 100644
--- a/docs/studio-app/index.mdx
+++ b/docs/studio-app/index.mdx
@@ -38,19 +38,29 @@ green guide, points you to the next step along the way.
- Go to [hyperframes.dev/studio](https://hyperframes.dev/studio) and choose **Download** next to Linux. You get
- one file, `HyperFrames-x86_64.AppImage`, for 64-bit Intel and AMD computers.
+ Go to [hyperframes.dev/studio](https://hyperframes.dev/studio) and choose **Download** next to Linux (.deb).
+ You get `HyperFrames-amd64-.deb` for 64-bit Intel and AMD computers on Debian, Ubuntu and their
+ relatives.
-
+
In a terminal, in the folder you downloaded it to:
```bash
- chmod +x HyperFrames-x86_64.AppImage
- ./HyperFrames-x86_64.AppImage
+ sudo apt install ./HyperFrames-amd64-*.deb
```
+
+ Then open **HyperFrames** from your app menu.
+ Prefer one portable file, or not on a Debian-based system? Choose **Linux (AppImage)** instead, then in the
+ folder you downloaded it to:
+
+ ```bash
+ chmod +x HyperFrames-x86_64-*.AppImage
+ ./HyperFrames-x86_64-*.AppImage
+ ```
+
The AppImage is the whole app. Keep it anywhere you like and open it the same way next time.
diff --git a/package.json b/package.json
index b7bf2373174..2d9e8e2faa3 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,8 @@
"check:tracked-artifacts": "node scripts/check-tracked-artifacts.mjs",
"check:docs-snippet-motion": "node scripts/check-docs-snippet-motion.mjs",
"check:docs-catalog": "node scripts/check-docs-catalog.mjs",
+ "check:docs-cli-references": "node scripts/check-docs-cli-references.mjs",
+ "check:docs-impact": "node scripts/check-docs-impact.mjs",
"generate:catalog": "bun scripts/generate-catalog.ts",
"check:catalog-drift": "tsx scripts/catalog-drift.ts",
"verify:catalog-payloads": "tsx scripts/verify-catalog-payloads.ts",
@@ -53,7 +55,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
- "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
+ "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/cli-command-tree.test.ts scripts/check-docs-cli-references.test.mjs scripts/check-docs-impact.test.mjs scripts/public-api-surface.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
"test:skills": "HYPERFRAMES_MEDIA_HOME_REQUIRED=1 node --import ./packages/cli/scripts/test-home.mjs --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
diff --git a/scripts/check-docs-cli-references.mjs b/scripts/check-docs-cli-references.mjs
new file mode 100644
index 00000000000..3efc7159a64
--- /dev/null
+++ b/scripts/check-docs-cli-references.mjs
@@ -0,0 +1,273 @@
+// Every `hyperframes [--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 ] [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_]*=/;
+// ``, `[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: .
+ ...[...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();
diff --git a/scripts/check-docs-cli-references.test.mjs b/scripts/check-docs-cli-references.test.mjs
new file mode 100644
index 00000000000..789c87e5425
--- /dev/null
+++ b/scripts/check-docs-cli-references.test.mjs
@@ -0,0 +1,187 @@
+import { strict as assert } from "node:assert";
+import { test } from "node:test";
+
+import {
+ cliArguments,
+ codeSpans,
+ flagSpellings,
+ invocationProblems,
+ pageProblems,
+ shellCommands,
+} from "./check-docs-cli-references.mjs";
+
+const flag = (type, aliases = [], extra = {}) => ({
+ type,
+ aliases,
+ hidden: false,
+ deprecated: false,
+ ...extra,
+});
+const command = (flags = {}, subCommands = {}, extra = {}) => ({
+ hidden: false,
+ deprecated: false,
+ flags,
+ subCommands,
+ ...extra,
+});
+
+const TREE = command(
+ { version: flag("boolean", ["V"]) },
+ {
+ render: command({
+ output: flag("string", ["o"]),
+ quality: flag("enum"),
+ browserGpu: flag("boolean"),
+ legacy: flag("boolean", [], { deprecated: true }),
+ }),
+ init: command({
+ example: flag("string", ["e"]),
+ template: flag("string", ["t"], { hidden: true }),
+ }),
+ validate: command({}, {}, { deprecated: true }),
+ cloud: command(
+ { region: flag("string") },
+ { render: command({ "dry-run": flag("boolean") }), list: command() },
+ ),
+ },
+);
+
+const messages = (args) => invocationProblems(TREE, args).map((p) => `${p.severity}: ${p.message}`);
+
+test("codeSpans joins backslash-continued fenced lines and reports the first line", () => {
+ const page = [
+ "intro",
+ "```bash",
+ "npx hyperframes render \\",
+ " --output out.mp4 \\",
+ " --quality high",
+ "```",
+ ].join("\n");
+ assert.deepEqual(codeSpans(page), [
+ { line: 3, text: "npx hyperframes render --output out.mp4 --quality high" },
+ ]);
+});
+
+test("codeSpans reads inline code, catalog command props, and fences nested in components", () => {
+ const page = [
+ "Run `hyperframes lint` then ``hyperframes check``.",
+ '',
+ "",
+ " ```bash",
+ " hyperframes preview",
+ " ```",
+ "",
+ ].join("\n");
+ assert.deepEqual(codeSpans(page), [
+ { line: 1, text: "hyperframes lint" },
+ { line: 1, text: "hyperframes check" },
+ { line: 2, text: "npx hyperframes add grain" },
+ { line: 5, text: " hyperframes preview" },
+ ]);
+});
+
+test("codeSpans skips code marked cli-references: ignore on its line or the line above", () => {
+ const page = [
+ "{/* cli-references: ignore */}",
+ "prints `hyperframes auto-updated to v1`",
+ "`hyperframes lint`",
+ ].join("\n");
+ assert.deepEqual(codeSpans(page), [{ line: 3, text: "hyperframes lint" }]);
+});
+
+test("shellCommands splits chained commands and drops comments, keeping quoted words whole", () => {
+ assert.deepEqual(
+ shellCommands('cd app && npx hyperframes render --output "my film.mp4" # then share'),
+ [
+ ["cd", "app"],
+ ["npx", "hyperframes", "render", "--output", '"my film.mp4"'],
+ ],
+ );
+ assert.deepEqual(shellCommands("hyperframes lint | tee log; hyperframes check"), [
+ ["hyperframes", "lint"],
+ ["tee", "log"],
+ ["hyperframes", "check"],
+ ]);
+});
+
+test("cliArguments recognises runners, pinned versions and env prefixes, and leaves other tools alone", () => {
+ const args = (line) => cliArguments(shellCommands(line)[0]);
+ assert.deepEqual(args("hyperframes render"), ["render"]);
+ assert.deepEqual(args("npx -y hyperframes@latest lint --json"), ["lint", "--json"]);
+ assert.deepEqual(args("$ bunx @hyperframes/cli@0.8.0 check"), ["check"]);
+ assert.deepEqual(args("HYPERFRAMES_NO_TELEMETRY=1 npx hyperframes doctor"), ["doctor"]);
+ assert.equal(args("npx skills add heygen-com/hyperframes --skill hyperframes-cli"), null);
+ assert.equal(args("npm install -g hyperframes"), null);
+ assert.equal(args("/hyperframes make me a video"), null);
+});
+
+test("flagSpellings accepts kebab and camel spellings, aliases and the global flags", () => {
+ const spellings = flagSpellings(TREE.subCommands.render);
+ for (const spelling of ["browser-gpu", "browserGpu", "o", "output", "help", "h", "json", "v"])
+ assert.ok(spellings.has(spelling), spelling);
+ assert.ok(!spellings.has("out"));
+});
+
+test("known commands, flags, negations, aliases and combined short flags pass", () => {
+ assert.deepEqual(
+ messages(["render", "./film", "-o", "out.mp4", "--no-browser-gpu", "--quality=high"]),
+ [],
+ );
+ assert.deepEqual(messages(["cloud", "render", "--dry-run"]), []);
+ assert.deepEqual(messages(["--version"]), []);
+ assert.deepEqual(messages(["render", "-oh", "x"]), []);
+});
+
+test("unknown commands, subcommands and flags are errors with a suggestion when one is close", () => {
+ assert.deepEqual(messages(["rendr"]), [
+ "error: unknown command `hyperframes rendr` (did you mean render?)",
+ ]);
+ assert.deepEqual(messages(["cloud", "lst"]), [
+ "error: unknown subcommand `hyperframes cloud lst` (did you mean list?)",
+ ]);
+ assert.deepEqual(messages(["render", "--out", "x.mp4"]), [
+ "error: `hyperframes render` has no --out (did you mean --output?)",
+ ]);
+ assert.deepEqual(messages(["render", "--fps", "30"]), [
+ "error: `hyperframes render` has no --fps",
+ ]);
+ assert.deepEqual(messages(["init", "-ez"]), ["error: `hyperframes init` has no -z"]);
+});
+
+test("placeholders, values, negative numbers and words after -- are not read as commands or flags", () => {
+ assert.deepEqual(messages(["render", "", "--output", "", "--", "..."]), []);
+ assert.deepEqual(messages(["render", "--output", "-1"]), []);
+ assert.deepEqual(messages(["render", "--", "--anything"]), []);
+ assert.deepEqual(messages(["cloud", ""]), []);
+ // A string flag's value is not where citty looks for the subcommand.
+ assert.deepEqual(messages(["cloud", "--region", "eu", "list"]), []);
+});
+
+test("deprecated commands and flags, and hidden flags, are warnings", () => {
+ assert.deepEqual(messages(["validate"]), ["warning: `hyperframes validate` is deprecated"]);
+ assert.deepEqual(messages(["render", "--legacy"]), [
+ "warning: `hyperframes render --legacy` is deprecated",
+ ]);
+ assert.deepEqual(messages(["init", "-t", "blank"]), [
+ "warning: `hyperframes init -t` is hidden from --help",
+ ]);
+});
+
+test("pageProblems reports the line a multi-line command starts on", () => {
+ const page = [
+ "# Render",
+ "",
+ "```bash",
+ "npx hyperframes render \\",
+ " --output out.mp4 \\",
+ " --qualty high",
+ "```",
+ ].join("\n");
+ assert.deepEqual(pageProblems(TREE, page), [
+ {
+ line: 4,
+ severity: "error",
+ message: "`hyperframes render` has no --qualty (did you mean --quality?)",
+ },
+ ]);
+});
diff --git a/scripts/check-docs-impact.mjs b/scripts/check-docs-impact.mjs
new file mode 100644
index 00000000000..795077e5f08
--- /dev/null
+++ b/scripts/check-docs-impact.mjs
@@ -0,0 +1,166 @@
+// A pull request that changes what users type or import must change the docs with it.
+//
+// Fails when, between the merge base and HEAD, either surface changed and nothing under docs/ did:
+// CLI every command, subcommand and flag the citty definitions declare (scripts/cli-command-tree.ts),
+// hidden ones excepted; flag value types and deprecation count as changes.
+// exports every public package's exports subpaths, bin names and root-entry exports
+// (scripts/public-api-surface.mjs).
+// The PR opts out with the `docs-not-needed` label or a `docs-impact: none` line in its body
+// (PR_LABELS: JSON array of label names or label objects; PR_BODY: the body).
+//
+// node scripts/check-docs-impact.mjs [--base [] [--dry-run] [--base-tree ] [--head-tree ]
+//
+// The base CLI tree is built from a temporary worktree (bun install + core build), only when the CLI's
+// sources changed. --dry-run prints the verdict and exits 0.
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import { publicApiSurface } from "./public-api-surface.mjs";
+
+const ROOT = resolve(import.meta.dirname, "..");
+export const OPT_OUT_LABEL = "docs-not-needed";
+const OPT_OUT_BODY = /^\s*docs-impact:\s*none\b/im;
+
+const CLI_SOURCE =
+ /^packages\/cli\/src\/(?!.*\.test\.tsx?$)(?!(?:.*\/)?(?:__fixtures__|fixtures)\/)/;
+const PACKAGE_SOURCE = /^packages\/[^/]+\/(?:package\.json$|src\/)/;
+
+/** One line per user-visible command, flag and alias, e.g. `hyperframes render --output `. */
+export function cliSurface(tree, path = ["hyperframes"]) {
+ const shown = path.join(" ");
+ const lines = [path.length > 1 ? `${shown}${tree.deprecated ? " (deprecated)" : ""}` : shown];
+ for (const [name, flag] of Object.entries(tree.flags)) {
+ if (flag.hidden) continue;
+ const value = flag.type === "boolean" ? "" : ` <${flag.type}>`;
+ lines.push(`${shown} --${name}${value}${flag.deprecated ? " (deprecated)" : ""}`);
+ for (const alias of flag.aliases) lines.push(`${shown} -${alias} = --${name}`);
+ }
+ for (const [name, sub] of Object.entries(tree.subCommands)) {
+ if (!sub.hidden) lines.push(...cliSurface(sub, [...path, name]));
+ }
+ return lines;
+}
+
+/** `-` lines the base had and HEAD lost, then `+` lines HEAD added. */
+export function surfaceDiff(base, head) {
+ const before = new Set(base);
+ const after = new Set(head);
+ return [
+ ...base.filter((line) => !after.has(line)).map((line) => `- ${line}`),
+ ...head.filter((line) => !before.has(line)).map((line) => `+ ${line}`),
+ ];
+}
+
+/** Why the PR may skip docs, or null. */
+export function optOutReason({ labels = [], body = "" }) {
+ const names = labels.map((label) => (typeof label === "string" ? label : label?.name));
+ if (names.includes(OPT_OUT_LABEL)) return `labelled ${OPT_OUT_LABEL}`;
+ if (OPT_OUT_BODY.test(body)) return "the body says `docs-impact: none`";
+ return null;
+}
+
+export const touchesDocs = (files) => files.some((file) => file.startsWith("docs/"));
+export const touchesCli = (files) => files.some((file) => CLI_SOURCE.test(file));
+export const touchesPackages = (files) => files.some((file) => PACKAGE_SOURCE.test(file));
+
+function git(args, cwd = ROOT) {
+ return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 26 }).trim();
+}
+
+// Output is kept for the error a failed step throws, not printed: installs and builds are noisy.
+function run(command, args, cwd) {
+ return execFileSync(command, args, {
+ cwd,
+ encoding: "utf8",
+ maxBuffer: 1 << 26,
+ env: { ...process.env, HYPERFRAMES_NO_TELEMETRY: "1", GIT_LFS_SKIP_SMUDGE: "1" },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+}
+
+const treeAt = (root) =>
+ JSON.parse(run("bun", [join(ROOT, "scripts", "cli-command-tree.ts"), "--root", root], root));
+
+// The base revision's command modules need their own dependencies and generated core sources.
+function baseTree(sha) {
+ const dir = mkdtempSync(join(tmpdir(), "hf-docs-impact-"));
+ try {
+ run("git", ["worktree", "add", "--detach", dir, sha], ROOT);
+ run("bun", ["install", "--frozen-lockfile", "--ignore-scripts"], dir);
+ run("bun", ["run", "--cwd", "packages/core", "build"], dir);
+ return treeAt(dir);
+ } finally {
+ git(["worktree", "remove", "--force", dir]);
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+function readJson(path) {
+ return JSON.parse(readFileSync(path, "utf8"));
+}
+
+function flag(args, name) {
+ const at = args.indexOf(name);
+ return at < 0 ? undefined : args[at + 1];
+}
+
+function parseLabels(raw) {
+ if (!raw) return [];
+ const labels = JSON.parse(raw);
+ return Array.isArray(labels) ? labels : [];
+}
+
+function report(heading, lines) {
+ if (lines.length === 0) return;
+ console.log(heading);
+ for (const line of lines) console.log(` ${line}`);
+}
+
+/** The outcome for a PR: `{ pass, message }`, given how many surface lines changed and any excuse. */
+export function verdict({ changed, excuse, dryRun = false }) {
+ if (changed === 0)
+ return { pass: true, message: "passes, no CLI command, flag or public export changed." };
+ if (excuse) return { pass: true, message: `passes, ${excuse}.` };
+ const optOut = `opt out with the \`${OPT_OUT_LABEL}\` label or a \`docs-impact: none\` line in the PR body (say why)`;
+ return { pass: dryRun, message: `update docs/ for the changes above, or ${optOut}.` };
+}
+
+function cliChanges(args, files, base) {
+ const baseTreePath = flag(args, "--base-tree");
+ const headTreePath = flag(args, "--head-tree");
+ if (!touchesCli(files) && !baseTreePath) return [];
+ const before = baseTreePath ? readJson(baseTreePath) : baseTree(base);
+ const after = headTreePath ? readJson(headTreePath) : treeAt(ROOT);
+ return surfaceDiff(cliSurface(before), cliSurface(after));
+}
+
+function exportChanges(files, base) {
+ if (!touchesPackages(files)) return [];
+ return surfaceDiff(publicApiSurface(base, ROOT), publicApiSurface("HEAD", ROOT));
+}
+
+function main() {
+ const args = process.argv.slice(2);
+ const dryRun = args.includes("--dry-run");
+ const base = git(["merge-base", flag(args, "--base") ?? "origin/main", "HEAD"]);
+ const files = git(["diff", "--name-only", base, "HEAD"]).split("\n").filter(Boolean);
+ const pr = { labels: parseLabels(process.env.PR_LABELS), body: process.env.PR_BODY ?? "" };
+ const excuse = touchesDocs(files) ? "it changes docs/" : optOutReason(pr);
+ // Past the excuse, the surfaces only matter to a dry run, which shows them anyway.
+ if (excuse && !dryRun) {
+ console.log(`Docs impact: passes, ${excuse}.`);
+ return;
+ }
+ const cli = cliChanges(args, files, base);
+ const exports = exportChanges(files, base);
+ report("CLI commands and flags changed:", cli);
+ report("Public package exports changed:", exports);
+ const result = verdict({ changed: cli.length + exports.length, excuse, dryRun });
+ console.log(`Docs impact${dryRun ? " (dry run)" : ""}: ${result.message}`);
+ if (!result.pass) process.exitCode = 1;
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href)
+ main();
diff --git a/scripts/check-docs-impact.test.mjs b/scripts/check-docs-impact.test.mjs
new file mode 100644
index 00000000000..ac0a83d33e2
--- /dev/null
+++ b/scripts/check-docs-impact.test.mjs
@@ -0,0 +1,104 @@
+import { strict as assert } from "node:assert";
+import { test } from "node:test";
+
+import {
+ OPT_OUT_LABEL,
+ cliSurface,
+ optOutReason,
+ surfaceDiff,
+ touchesCli,
+ touchesDocs,
+ touchesPackages,
+ verdict,
+} from "./check-docs-impact.mjs";
+
+const flag = (type, aliases = [], extra = {}) => ({
+ type,
+ aliases,
+ hidden: false,
+ deprecated: false,
+ ...extra,
+});
+const command = (flags = {}, subCommands = {}, extra = {}) => ({
+ hidden: false,
+ deprecated: false,
+ flags,
+ subCommands,
+ ...extra,
+});
+
+test("cliSurface lists commands, flags with their value type, and aliases, leaving hidden ones out", () => {
+ const tree = command(
+ {},
+ {
+ render: command({
+ output: flag("string", ["o"]),
+ quiet: flag("boolean"),
+ old: flag("boolean", [], { hidden: true }),
+ }),
+ validate: command({}, {}, { deprecated: true }),
+ events: command({}, {}, { hidden: true }),
+ auth: command({}, { login: command({ token: flag("string") }) }),
+ },
+ );
+ assert.deepEqual(cliSurface(tree), [
+ "hyperframes",
+ "hyperframes render",
+ "hyperframes render --output ",
+ "hyperframes render -o = --output",
+ "hyperframes render --quiet",
+ "hyperframes validate (deprecated)",
+ "hyperframes auth",
+ "hyperframes auth login",
+ "hyperframes auth login --token ",
+ ]);
+});
+
+test("surfaceDiff reports removals before additions and ignores reordering", () => {
+ assert.deepEqual(surfaceDiff(["a", "b", "c"], ["c", "a", "d"]), ["- b", "+ d"]);
+ assert.deepEqual(surfaceDiff(["a"], ["a"]), []);
+});
+
+test("a flag that changes from boolean to string-valued is a surface change", () => {
+ const before = cliSurface(command({}, { lint: command({ fix: flag("boolean") }) }));
+ const after = cliSurface(command({}, { lint: command({ fix: flag("string") }) }));
+ assert.deepEqual(surfaceDiff(before, after), [
+ "- hyperframes lint --fix",
+ "+ hyperframes lint --fix ",
+ ]);
+});
+
+test("optOutReason accepts the label as a name or a label object, or a docs-impact: none body line", () => {
+ assert.equal(optOutReason({ labels: [OPT_OUT_LABEL] }), `labelled ${OPT_OUT_LABEL}`);
+ assert.equal(optOutReason({ labels: [{ name: OPT_OUT_LABEL }] }), `labelled ${OPT_OUT_LABEL}`);
+ assert.ok(optOutReason({ body: "## What\n\nDocs-Impact: none, internal helper only\n" }));
+ assert.equal(
+ optOutReason({ labels: ["docs"], body: "Mentions docs-impact: none mid-sentence." }),
+ null,
+ );
+ assert.equal(optOutReason({}), null);
+});
+
+test("changed-file filters: CLI sources without tests or fixtures, package entries and manifests, docs", () => {
+ assert.ok(touchesCli(["packages/cli/src/commands/render.ts"]));
+ assert.ok(touchesCli(["packages/cli/src/cli.ts"]));
+ assert.ok(!touchesCli(["packages/cli/src/commands/render.test.ts"]));
+ assert.ok(!touchesCli(["packages/cli/src/commands/__fixtures__/clip.mp4"]));
+ assert.ok(!touchesCli(["packages/cli/scripts/build-copy.mjs"]));
+ assert.ok(touchesPackages(["packages/sdk/src/index.ts"]));
+ assert.ok(touchesPackages(["packages/core/package.json"]));
+ assert.ok(!touchesPackages(["packages/core/README.md"]));
+ assert.ok(touchesDocs(["docs/packages/cli.mdx"]));
+ assert.ok(!touchesDocs(["DOCS_GUIDELINES.md"]));
+});
+
+test("verdict passes with no surface change or with an excuse, and fails otherwise unless dry-run", () => {
+ assert.equal(verdict({ changed: 0, excuse: null }).pass, true);
+ assert.deepEqual(verdict({ changed: 2, excuse: "it changes docs/" }), {
+ pass: true,
+ message: "passes, it changes docs/.",
+ });
+ assert.equal(verdict({ changed: 2, excuse: null }).pass, false);
+ assert.match(verdict({ changed: 2, excuse: null }).message, /docs-not-needed/);
+ assert.equal(verdict({ changed: 2, excuse: null, dryRun: true }).pass, true);
+});
diff --git a/scripts/cli-command-tree.test.ts b/scripts/cli-command-tree.test.ts
new file mode 100644
index 00000000000..c84fe1442c8
--- /dev/null
+++ b/scripts/cli-command-tree.test.ts
@@ -0,0 +1,59 @@
+import { strict as assert } from "node:assert";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { test } from "node:test";
+
+import { commandLoaders, describeCommand, type CittyCommand } from "./cli-command-tree.ts";
+
+const cliSource = readFileSync(
+ join(import.meta.dirname, "..", "packages", "cli", "src", "cli.ts"),
+ "utf8",
+);
+
+test("commandLoaders reads every entry of cli.ts's commandLoaders, including multi-line ones", () => {
+ const loaders = commandLoaders(cliSource);
+ const byName = new Map(loaders.map((loader) => [loader.name, loader]));
+ assert.ok(loaders.length > 40);
+ assert.deepEqual(byName.get("render"), { name: "render", file: "render", exportName: "default" });
+ assert.deepEqual(byName.get("media-treatment"), {
+ name: "media-treatment",
+ file: "media-treatment",
+ exportName: "mediaTreatmentCommand",
+ });
+ assert.equal(byName.get("preview")?.file, "preview");
+});
+
+test("commandLoaders fails loudly when an entry stops matching the pattern", () => {
+ const changed = cliSource.replace(
+ 'render: () => import("./commands/render.js").then((m) => m.default),',
+ 'render: () => load("render"),',
+ );
+ assert.throws(() => commandLoaders(changed), /update the pattern/);
+ assert.throws(() => commandLoaders("export {};"), /commandLoaders/);
+});
+
+test("describeCommand resolves lazy parts and records flags, aliases, hidden and deprecated", async () => {
+ const command: CittyCommand = {
+ meta: () => ({ name: "cloud", description: "Cloud renders" }),
+ args: {
+ dir: { type: "positional" },
+ output: { type: "string", alias: "o" },
+ quiet: { type: "boolean", alias: ["q", "s"] },
+ legacy: { type: "boolean", hidden: true, description: "Deprecated alias for --quiet" },
+ },
+ subCommands: {
+ list: () =>
+ Promise.resolve({ meta: { description: "(deprecated, use ls)" }, args: () => ({}) }),
+ },
+ };
+ assert.deepEqual(await describeCommand(command), {
+ hidden: false,
+ deprecated: false,
+ flags: {
+ output: { type: "string", aliases: ["o"], hidden: false, deprecated: false },
+ quiet: { type: "boolean", aliases: ["q", "s"], hidden: false, deprecated: false },
+ legacy: { type: "boolean", aliases: [], hidden: true, deprecated: true },
+ },
+ subCommands: { list: { hidden: false, deprecated: true, flags: {}, subCommands: {} } },
+ });
+});
diff --git a/scripts/cli-command-tree.ts b/scripts/cli-command-tree.ts
new file mode 100644
index 00000000000..9bdac545aaf
--- /dev/null
+++ b/scripts/cli-command-tree.ts
@@ -0,0 +1,116 @@
+// 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 ]
+import { readFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+
+type Resolvable = T | Promise | (() => T | Promise);
+
+interface CittyArg {
+ type?: string;
+ alias?: string | string[];
+ hidden?: boolean;
+ description?: string;
+}
+
+export interface CittyCommand {
+ meta?: Resolvable<{ name?: string; description?: string; hidden?: boolean }>;
+ args?: Resolvable>;
+ subCommands?: Resolvable>>;
+}
+
+export interface TreeFlag {
+ type: string;
+ aliases: string[];
+ hidden: boolean;
+ deprecated: boolean;
+}
+
+export interface TreeCommand {
+ hidden: boolean;
+ deprecated: boolean;
+ flags: Record;
+ subCommands: Record;
+}
+
+const DEPRECATED = /\bdeprecated\b/i;
+
+async function settle(value: Resolvable | undefined): Promise {
+ return typeof value === "function" ? (value as () => T | Promise)() : value;
+}
+
+/** Each `name: () => import("./commands/.js").then((m) => m.)` 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 {
+ 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 = {};
+ 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 {
+ const cliDir = join(root, "packages", "cli", "src");
+ const subCommands: Record = {};
+ 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;
+ 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));
+}
diff --git a/scripts/pr-edit-concurrency.test.mjs b/scripts/pr-edit-concurrency.test.mjs
index 2b94b7dba6b..b6b59a2d7e6 100644
--- a/scripts/pr-edit-concurrency.test.mjs
+++ b/scripts/pr-edit-concurrency.test.mjs
@@ -13,8 +13,8 @@ const workflows = readdirSync(workflowsDir)
// fail-closed `Test` stays red on the PR. Only a push or a base change cancels.
const CANCEL_UNLESS_PLAIN_EDIT =
"${{ github.event.action != 'edited' || github.event.changes.base != null }}";
-// Reads the PR body itself, so the newest edit should replace an older run.
-const BODY_WORKFLOWS = new Set(["pr-captures.yml"]);
+// Read the PR body itself, so the newest edit should replace an older run.
+const BODY_WORKFLOWS = new Set(["docs-impact.yml", "pr-captures.yml"]);
const editedWorkflows = workflows.filter(({ config }) =>
config.on?.pull_request?.types?.includes("edited"),
@@ -23,6 +23,7 @@ const editedWorkflows = workflows.filter(({ config }) =>
test("the workflows that re-run on a PR edit are known", () => {
assert.deepEqual(editedWorkflows.map(({ file }) => file).sort(), [
"ci.yml",
+ "docs-impact.yml",
"pr-captures.yml",
"regression.yml",
"windows-render.yml",
diff --git a/scripts/public-api-surface.mjs b/scripts/public-api-surface.mjs
new file mode 100644
index 00000000000..8a5532a8505
--- /dev/null
+++ b/scripts/public-api-surface.mjs
@@ -0,0 +1,137 @@
+// The public API the workspace packages publish at a git revision, as sorted lines:
+// " exports " every key of `exports` / `publishConfig.exports`
+// " bin " every executable in `bin`
+// " export " every name the root entry (`exports["."]`) exports, through `export *` chains
+// A package is public when its package.json is not `private` and declares `exports`, the same rule as
+// scripts/package-subpaths.mjs. Reading from git (not the working tree) lets one checkout compare two revisions.
+import { execFileSync } from "node:child_process";
+import { posix } from "node:path";
+import ts from "typescript";
+
+const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".js", ".mjs"];
+
+/** A reader for files at `ref`: `read(path)` is the file's text, or undefined when the revision has no such file. */
+export function gitReader(ref, cwd) {
+ const files = new Set(
+ execFileSync("git", ["ls-tree", "-r", "--name-only", ref, "--", "packages"], {
+ cwd,
+ encoding: "utf8",
+ })
+ .split("\n")
+ .filter(Boolean),
+ );
+ const cache = new Map();
+ return (path) => {
+ if (!files.has(path)) return undefined;
+ if (!cache.has(path))
+ cache.set(
+ path,
+ execFileSync("git", ["show", `${ref}:${path}`], {
+ cwd,
+ encoding: "utf8",
+ maxBuffer: 1 << 26,
+ }),
+ );
+ return cache.get(path);
+ };
+}
+
+// Same precedence as package-subpaths.mjs's sourceTarget: the file a workspace consumer actually loads.
+const SOURCE_CONDITIONS = ["bun", "browser", "import", "node", "default"];
+function sourceTarget(target) {
+ if (typeof target === "string") return target;
+ return SOURCE_CONDITIONS.map((condition) => target?.[condition]).find(Boolean);
+}
+
+function resolveModule(from, specifier, read) {
+ const base = posix.join(posix.dirname(from), specifier);
+ const stem = base.replace(/\.(?:[mc]?js|tsx?)$/, "");
+ const candidates = [
+ base,
+ ...SOURCE_EXTENSIONS.map((ext) => stem + ext),
+ ...SOURCE_EXTENSIONS.map((ext) => `${base}/index${ext}`),
+ ];
+ return candidates.find((path) => read(path) !== undefined);
+}
+
+function bindingNames(name) {
+ if (ts.isIdentifier(name)) return [name.text];
+ return name.elements.flatMap((element) =>
+ ts.isOmittedExpression(element) ? [] : bindingNames(element.name),
+ );
+}
+
+function declaredExports(statement) {
+ const modifiers = ts.canHaveModifiers(statement) ? (ts.getModifiers(statement) ?? []) : [];
+ if (!modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) return [];
+ if (modifiers.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) return ["default"];
+ if (ts.isVariableStatement(statement))
+ return statement.declarationList.declarations.flatMap((d) => bindingNames(d.name));
+ return statement.name && ts.isIdentifier(statement.name) ? [statement.name.text] : [];
+}
+
+function starExports(path, specifier, read, seen) {
+ const target = specifier.startsWith(".") ? resolveModule(path, specifier, read) : undefined;
+ if (!target) return [`* from "${specifier}"`];
+ return moduleExports(target, read, seen).filter((name) => name !== "default");
+}
+
+function statementExports(statement, path, read, seen) {
+ if (ts.isExportAssignment(statement)) return [statement.isExportEquals ? "export=" : "default"];
+ if (!ts.isExportDeclaration(statement)) return declaredExports(statement);
+ const clause = statement.exportClause;
+ if (!clause) return starExports(path, statement.moduleSpecifier.text, read, seen);
+ return ts.isNamedExports(clause)
+ ? clause.elements.map((element) => element.name.text)
+ : [clause.name.text];
+}
+
+/** Every name the module at `path` exports, following relative `export *`; a package re-export stays `* from ""`. */
+export function moduleExports(path, read, seen = new Set()) {
+ if (seen.has(path)) return [];
+ seen.add(path);
+ const file = ts.createSourceFile(path, read(path) ?? "", ts.ScriptTarget.Latest, false);
+ return file.statements.flatMap((statement) => statementExports(statement, path, read, seen));
+}
+
+// A root entry built into dist/ (player) is read from the source file tsup builds it from.
+function rootSource(dir, target, read) {
+ if (typeof target !== "string") return undefined;
+ const path = posix.normalize(posix.join(dir, target));
+ if (read(path) !== undefined) return path;
+ const source = path.replace(`${dir}/dist/`, `${dir}/src/`).replace(/\.[mc]?js$/, ".ts");
+ return read(source) !== undefined ? source : undefined;
+}
+
+/** Surface lines for one package.json (its path and text) read through `read`. */
+export function packageSurface(manifestPath, read) {
+ const pkg = JSON.parse(read(manifestPath));
+ if (pkg.private === true || pkg.exports == null) return [];
+ const dir = posix.dirname(manifestPath);
+ const subpaths = new Set([
+ ...Object.keys(pkg.exports),
+ ...Object.keys(pkg.publishConfig?.exports ?? {}),
+ ]);
+ const bins = typeof pkg.bin === "string" ? [pkg.name] : Object.keys(pkg.bin ?? {});
+ const entry = rootSource(dir, sourceTarget(pkg.exports["."]), read);
+ const symbols = entry ? moduleExports(entry, read) : [];
+ return [
+ ...[...subpaths].map((subpath) => `${pkg.name} exports ${subpath}`),
+ ...bins.map((bin) => `${pkg.name} bin ${bin}`),
+ ...symbols.map((symbol) => `${pkg.name} export ${symbol}`),
+ ];
+}
+
+/** The public API of every workspace package at `ref`, sorted and deduplicated. */
+export function publicApiSurface(ref, cwd) {
+ const read = gitReader(ref, cwd);
+ const manifests = execFileSync("git", ["ls-tree", "--name-only", ref, "packages/"], {
+ cwd,
+ encoding: "utf8",
+ })
+ .split("\n")
+ .filter(Boolean)
+ .map((dir) => `${dir}/package.json`)
+ .filter((path) => read(path) !== undefined);
+ return [...new Set(manifests.flatMap((path) => packageSurface(path, read)))].sort();
+}
diff --git a/scripts/public-api-surface.test.mjs b/scripts/public-api-surface.test.mjs
new file mode 100644
index 00000000000..e63d29ea7e2
--- /dev/null
+++ b/scripts/public-api-surface.test.mjs
@@ -0,0 +1,83 @@
+import { strict as assert } from "node:assert";
+import { test } from "node:test";
+
+import { moduleExports, packageSurface } from "./public-api-surface.mjs";
+
+const reader = (files) => (path) => files[path];
+
+test("moduleExports follows relative export * chains and keeps package re-exports opaque", () => {
+ const read = reader({
+ "p/src/index.ts": [
+ 'export * from "./a.js";',
+ 'export * as helpers from "./helpers";',
+ 'export { b as renamed, type C } from "./b.js";',
+ 'export * from "@hyperframes/core";',
+ "export const one = 1, two = 2;",
+ "export function make() {}",
+ "export interface Options {}",
+ "export type Id = string;",
+ "export default class Thing {}",
+ "const hidden = 3;",
+ "export { hidden as visible };",
+ ].join("\n"),
+ "p/src/a.ts": 'export enum Mode { A }\nexport default 1;\nexport * from "./index.js";',
+ "p/src/helpers/index.ts": "export const helper = 1;",
+ "p/src/b.ts": "export const b = 1;\nexport type C = number;",
+ });
+ assert.deepEqual(moduleExports("p/src/index.ts", read).sort(), [
+ '* from "@hyperframes/core"',
+ "C",
+ "Id",
+ "Mode",
+ "Options",
+ "default",
+ "helpers",
+ "make",
+ "one",
+ "renamed",
+ "two",
+ "visible",
+ ]);
+});
+
+test("packageSurface lists subpaths, bins and root exports, and skips private packages", () => {
+ const read = reader({
+ "packages/a/package.json": JSON.stringify({
+ name: "@x/a",
+ bin: { a: "./bin/a.mjs" },
+ exports: {
+ ".": { bun: "./src/index.ts", node: "./dist/index.js" },
+ "./extra": "./src/extra.ts",
+ },
+ publishConfig: { exports: { "./published-only": "./dist/p.js" } },
+ }),
+ "packages/a/src/index.ts": "export const run = 1;",
+ "packages/b/package.json": JSON.stringify({
+ name: "@x/b",
+ private: true,
+ exports: { ".": "./src/index.ts" },
+ }),
+ });
+ assert.deepEqual(packageSurface("packages/a/package.json", read), [
+ "@x/a exports .",
+ "@x/a exports ./extra",
+ "@x/a exports ./published-only",
+ "@x/a bin a",
+ "@x/a export run",
+ ]);
+ assert.deepEqual(packageSurface("packages/b/package.json", read), []);
+});
+
+test("packageSurface reads a root entry built into dist/ from its src/ source", () => {
+ const read = reader({
+ "packages/player/package.json": JSON.stringify({
+ name: "@x/player",
+ exports: { ".": { types: "./dist/player.d.ts", import: "./dist/player.js" } },
+ }),
+ "packages/player/src/player.ts": "export class Player {}",
+ });
+ assert.deepEqual(packageSurface("packages/player/package.json", read), [
+ "@x/player exports .",
+ "@x/player export Player",
+ ]);
+});
]