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", + ]); +});