From 0c914dd0d0d35e216ff3acbb152e775ac34a289d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:31:43 +0000 Subject: [PATCH 1/7] docs(cli): Generate and check docs that mirror CLI behavior Lists that copied code (destructive commands, detected agents, target syntax, skill file layout) are now generated from the code. New checks fail CI when a `sentry ...` example names a missing command or flag, passes a bare org slug as a project-first target, or when an installer option is missing from its help text or the install guide. `pnpm run cli` and `generate:docs` now create missing build-time files, and check:stale-refs flags `pnpm run cli -- `, which pnpm forwards as a literal `--`. Co-Authored-By: Claude Opus Co-Authored-By: BYK --- .oxfmtrc.json | 1 + .../src/content/docs/agent-guidance.md | 6 +- .../src/content/docs/agentic-usage.md | 10 +- .../cli-docs/src/content/docs/contributing.md | 2 +- .../src/content/docs/getting-started.mdx | 3 + apps/cli-docs/src/fragments/commands/alert.md | 4 +- .../src/fragments/commands/dashboard.md | 4 +- apps/cli-docs/src/fragments/commands/index.md | 14 + apps/cli-docs/src/fragments/commands/init.md | 2 +- packages/cli/AGENTS.md | 2 +- packages/cli/CONTRIBUTING.md | 18 +- packages/cli/DEVELOPMENT.md | 4 +- packages/cli/README.md | 10 +- packages/cli/install | 1 + packages/cli/package.json | 8 +- packages/cli/plugins/README.md | 33 +- .../sentry-cli/skills/sentry-cli/SKILL.md | 4 +- .../skills/sentry-cli/references/alert.md | 4 +- .../skills/sentry-cli/references/cli.md | 2 +- .../skills/sentry-cli/references/dashboard.md | 4 +- packages/cli/script/check-doc-examples.ts | 347 ++++++++++++++++++ packages/cli/script/check-installer-docs.ts | 114 ++++++ packages/cli/script/check-stale-references.ts | 52 ++- packages/cli/script/ensure-generated.mjs | 37 ++ packages/cli/script/generate-docs-sections.ts | 127 ++++++- packages/cli/script/generate-skill.ts | 7 +- packages/cli/src/commands/cli/uninstall.ts | 10 +- packages/cli/src/commands/dashboard/list.ts | 1 - .../cli/src/commands/dashboard/restore.ts | 2 +- .../cli/src/commands/dashboard/revisions.ts | 6 +- packages/cli/src/commands/dashboard/view.ts | 4 +- packages/cli/src/commands/issue/merge.ts | 1 + packages/cli/src/commands/trial/start.ts | 1 + packages/cli/src/lib/command.ts | 12 + packages/cli/src/lib/db/index.ts | 3 + packages/cli/src/lib/detect-agent.ts | 28 ++ packages/cli/src/lib/introspect.ts | 5 + packages/cli/src/lib/mutate-command.ts | 3 + packages/cli/src/lib/target-syntax.ts | 65 ++++ packages/cli/test/lib/detect-agent.test.ts | 27 ++ packages/cli/test/lib/target-syntax.test.ts | 29 ++ 41 files changed, 949 insertions(+), 68 deletions(-) create mode 100644 packages/cli/script/check-doc-examples.ts create mode 100644 packages/cli/script/check-installer-docs.ts create mode 100644 packages/cli/script/ensure-generated.mjs create mode 100644 packages/cli/src/lib/target-syntax.ts create mode 100644 packages/cli/test/lib/target-syntax.test.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 7bf3ff175..4e98fd9c6 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -12,6 +12,7 @@ "packages/cli/plugins/**", "packages/cli/README.md", "packages/cli/DEVELOPMENT.md", + "packages/cli/CONTRIBUTING.md", "packages/cli/CHANGELOG.md", "packages/cli/AGENTS.md", "packages/mcp-server-mocks/src/fixtures/**", diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md index 7dc9d82e9..2c7b2c504 100644 --- a/apps/cli-docs/src/content/docs/agent-guidance.md +++ b/apps/cli-docs/src/content/docs/agent-guidance.md @@ -34,7 +34,9 @@ The `sentry` CLI follows conventions from well-known tools — if you're familia ## Safety Rules -- Always confirm with the user before running destructive commands: `project delete`, `trial start` + +- Always confirm with the user before running destructive commands: `alert issues delete`, `alert metrics delete`, `cli uninstall`, `dashboard widget delete`, `integration delete`, `issue merge`, `issue unlink`, `project delete`, `release delete`, `trial start` + - For mutations, verify the org/project context looks correct in the command output before proceeding with further changes - Never store or log authentication tokens — the CLI manages credentials automatically - If the CLI reports the wrong org/project, override with explicit `/` arguments @@ -210,7 +212,7 @@ sentry release deploy my-org/1.0.0 production sentry agent-conversation list # Explicit org, last 24 hours -sentry agent-conversation list my-org --period 24h +sentry agent-conversation list my-org/ --period 24h # View a conversation transcript sentry agent-conversation view my-org/conv-123 diff --git a/apps/cli-docs/src/content/docs/agentic-usage.md b/apps/cli-docs/src/content/docs/agentic-usage.md index 2f6e6ff27..b688e81ad 100644 --- a/apps/cli-docs/src/content/docs/agentic-usage.md +++ b/apps/cli-docs/src/content/docs/agentic-usage.md @@ -3,7 +3,11 @@ title: Agentic Usage description: Enable AI coding agents to use the Sentry CLI --- -AI coding agents can use the Sentry CLI through the skill system. The CLI detects and supports Claude Code (including Cowork), Cursor, Windsurf, GitHub Copilot, Gemini CLI, OpenAI Codex, Goose, Amp, Augment, OpenCode, Cline, Grok, Kimi, Junie, OpenClaw, and any agent that reads skills from `~/.agents`. This allows agents to interact with Sentry directly from your development environment. +AI coding agents can use the Sentry CLI through the skill system, which lets them interact with Sentry directly from your development environment. Any agent that reads skills from `~/.agents` can use the skill. + + +The CLI detects Claude Code, Claude Cowork, Cursor, Windsurf, GitHub Copilot, Gemini CLI, OpenAI Codex, Antigravity, Goose, Amp, Augment, OpenCode, Cline, Grok, Kimi, Junie, and OpenClaw. + ## Automatic Installation @@ -66,7 +70,7 @@ When you ask your agent about Sentry errors or want to investigate an issue, the - "Show me the latest issues in my project" → `sentry issue list` - "What's the stack trace for ISSUE-123?" → `sentry issue view ISSUE-123` -- "List all projects in my organization" → `sentry project list my-org` +- "List all projects in my organization" → `sentry project list my-org/` - "What API endpoints exist for releases?" → `sentry schema releases` - "How do I set up source maps for Next.js?" → `sentry docs "source maps Next.js"` - "What is Sentry's status right now?" → `sentry status` @@ -79,4 +83,4 @@ The skill uses your existing CLI authentication, so you'll need to run `sentry a ## Requirements - An authenticated Sentry CLI installation (`sentry auth login`) -- An AI coding agent that supports the skills system (e.g., Claude Code, Cursor, Windsurf, GitHub Copilot, Gemini CLI, Codex, Goose, Amp, Augment, OpenCode, Cline, Grok, Kimi, Junie, OpenClaw, or any agent that reads from `~/.agents`) +- An AI coding agent that supports the skills system, such as one of the agents listed at the top of this page or any agent that reads from `~/.agents` diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 86c950e94..473d573e7 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -27,7 +27,7 @@ pnpm install cd packages/cli # Run CLI in development mode -pnpm run cli -- --help +pnpm run cli --help # Run tests pnpm run test diff --git a/apps/cli-docs/src/content/docs/getting-started.mdx b/apps/cli-docs/src/content/docs/getting-started.mdx index ee2b01822..02efb7ae4 100644 --- a/apps/cli-docs/src/content/docs/getting-started.mdx +++ b/apps/cli-docs/src/content/docs/getting-started.mdx @@ -61,6 +61,9 @@ Set `SENTRY_INIT=1` to run [`sentry init`](../commands/init/) right after instal curl https://cli.sentry.dev/install -fsS | SENTRY_INIT=1 bash ``` +The installer reports its own errors to Sentry. Set `SENTRY_CLI_NO_TELEMETRY=1` +to turn this off; the CLI honors the same variable. + ### Windows (PowerShell) On Windows, install the latest stable release from PowerShell: diff --git a/apps/cli-docs/src/fragments/commands/alert.md b/apps/cli-docs/src/fragments/commands/alert.md index 9b6d80c1d..fb881d799 100644 --- a/apps/cli-docs/src/fragments/commands/alert.md +++ b/apps/cli-docs/src/fragments/commands/alert.md @@ -50,7 +50,7 @@ sentry alert issues delete my-org/my-project/12345 --dry-run ```bash # Alert when P95 span duration goes above 500ms -sentry alert metrics create my-org \ +sentry alert metrics create my-org/ \ --name "P95 Latency" \ --project my-app \ --query "environment:prod" \ @@ -60,7 +60,7 @@ sentry alert metrics create my-org \ --trigger '{"alertThreshold":500}' # Alert on any matching log line and notify through an existing alert -sentry alert metrics create my-org \ +sentry alert metrics create my-org/ \ --name "AI chat failures" \ --project my-app \ --query "action:ai.chat result:error" \ diff --git a/apps/cli-docs/src/fragments/commands/dashboard.md b/apps/cli-docs/src/fragments/commands/dashboard.md index 9d8a1f2e1..b0d141065 100644 --- a/apps/cli-docs/src/fragments/commands/dashboard.md +++ b/apps/cli-docs/src/fragments/commands/dashboard.md @@ -136,7 +136,7 @@ sentry dashboard revisions 'Frontend Performance' sentry dashboard revisions 12345 # With explicit org -sentry dashboard revisions my-org 12345 +sentry dashboard revisions my-org/ 12345 ``` ### Restore a previous revision @@ -149,7 +149,7 @@ sentry dashboard restore 'Frontend Performance' --revision 3 sentry dashboard restore 12345 --revision 1 # With explicit org -sentry dashboard restore my-org 12345 --revision 1 +sentry dashboard restore my-org/ 12345 --revision 1 ``` :::tip diff --git a/apps/cli-docs/src/fragments/commands/index.md b/apps/cli-docs/src/fragments/commands/index.md index d1ab7faf7..9748a71f0 100644 --- a/apps/cli-docs/src/fragments/commands/index.md +++ b/apps/cli-docs/src/fragments/commands/index.md @@ -9,6 +9,20 @@ All commands support the following global options: - `--log-level ` - Set log verbosity (`error`, `warn`, `log`, `info`, `debug`, `trace`). Overrides `SENTRY_LOG_LEVEL` - `--verbose` - Shorthand for `--log-level debug` +## Targeting Organizations and Projects + +Many commands take an optional `/` target as their first positional argument, or as a prefix of an ID (e.g. `my-org/my-project/`). + + +| Target | Meaning | +|--------|---------| +| _(omitted)_ | Auto-detect the org and project from config, DSNs, or the directory name | +| `/` | That project in that organization | +| `/,` | Several projects in the same organization (commands that accept several projects) | +| `/` | The whole organization | +| `` | A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization | + + ## JSON Output Most list and view commands support `--json` flag for JSON output, making it easy to integrate with other tools: diff --git a/apps/cli-docs/src/fragments/commands/init.md b/apps/cli-docs/src/fragments/commands/init.md index 0cc4aa942..cef0a5cc2 100644 --- a/apps/cli-docs/src/fragments/commands/init.md +++ b/apps/cli-docs/src/fragments/commands/init.md @@ -41,7 +41,7 @@ sentry init --features profiling,replay | _(omitted)_ | Auto-detect org and project | | `acme/` | Use org `acme`, auto-detect or create project | | `acme/my-app` | Use org `acme` and project `my-app` | -| `my-app` | Search for project `my-app` across all accessible orgs | +| `my-app` | Use the existing project `my-app` from any accessible org. If there is none, use org `my-app` when one exists; otherwise create a new project named `my-app` | Path-like arguments (starting with `.`, `/`, or `~`) are always treated as the directory. The order of target and directory can be swapped — the CLI will auto-correct with a warning. diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 2e8d0c163..4d5d489e5 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -39,7 +39,7 @@ Before working on this codebase, read the Cursor rules: # Development pnpm install # Install dependencies (from repo root) pnpm run dev # Run CLI in dev mode -pnpm run cli -- # Run the CLI with arguments +pnpm run cli # Run the CLI with arguments # Build pnpm run build # Build for current platform diff --git a/packages/cli/CONTRIBUTING.md b/packages/cli/CONTRIBUTING.md index 4fb3df99a..524dca951 100644 --- a/packages/cli/CONTRIBUTING.md +++ b/packages/cli/CONTRIBUTING.md @@ -16,13 +16,17 @@ sentry project list [org] [--limit N] [--json] sentry issue list [/] [--json] ``` -**Target syntax**: - -- `/` - Explicit organization and project (e.g., `my-org/frontend`) -- `/,` - Several projects in the same org (e.g., `my-org/web,api`) -- `/` - All projects in the specified organization -- `` - Search for project by name across all accessible organizations -- _(omit)_ - Auto-detect from DSN or config +**Target syntax** (generated from `src/lib/target-syntax.ts`): + + +| Target | Meaning | +|--------|---------| +| _(omitted)_ | Auto-detect the org and project from config, DSNs, or the directory name | +| `/` | That project in that organization | +| `/,` | Several projects in the same organization (commands that accept several projects) | +| `/` | The whole organization | +| `` | A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization | + **Rationale**: Positional arguments follow `gh` CLI conventions and are more concise than flags. diff --git a/packages/cli/DEVELOPMENT.md b/packages/cli/DEVELOPMENT.md index 0287298ce..54102fc0e 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -31,7 +31,7 @@ Dev mode (`pnpm run cli`) falls back to the CLI's public sentry.io client ID whe `pnpm run cli` loads `.env.local` automatically when it exists: ```bash -pnpm run cli -- auth login +pnpm run cli auth login ``` ## Testing the Device Flow @@ -39,7 +39,7 @@ pnpm run cli -- auth login 1. Run the CLI login command: ```bash -pnpm run cli -- auth login +pnpm run cli auth login ``` 2. You'll see output like: diff --git a/packages/cli/README.md b/packages/cli/README.md index 030e115b2..f7c97e2a2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -45,6 +45,8 @@ bun add -g sentry > The npm/pnpm/yarn packages require Node.js 20+. On Node.js 22.15+ the CLI uses the built-in `node:sqlite`; on Node.js 20–22.14 it transparently falls back to a bundled WASM SQLite driver. +Homebrew and package manager installs don't add agent skills (package managers also skip shell completions). Run `sentry cli setup` once afterwards; see the [installation guide](https://cli.sentry.dev/getting-started/). + ### Run Without Installing ```bash @@ -149,11 +151,11 @@ cd packages/cli ### Running Locally ```bash -# Run CLI in development mode -pnpm run cli -- --help +# Run CLI in development mode (generates missing build-time files on first run) +pnpm run cli --help -# With environment variables (create .env.local first, see DEVELOPMENT.md) -pnpm run cli -- --help +# Loads packages/cli/.env.local automatically when present (see DEVELOPMENT.md) +pnpm run cli auth status ``` ### Scripts diff --git a/packages/cli/install b/packages/cli/install index 4a140d673..186231448 100755 --- a/packages/cli/install +++ b/packages/cli/install @@ -90,6 +90,7 @@ Environment Variables: SENTRY_INSTALL_DIR Override the installation directory SENTRY_VERSION Install a specific version (e.g., 0.42.2, nightly) SENTRY_INIT Set to 1 to run \`sentry init\` after installing + SENTRY_CLI_NO_TELEMETRY Set to 1 to disable installer error reporting Examples: curl -fsSL https://cli.sentry.dev/install | bash diff --git a/packages/cli/package.json b/packages/cli/package.json index 48e245c05..e86de3061 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -56,7 +56,7 @@ }, "scripts": { "tsx": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs", - "cli": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs src/bin.ts", + "cli": "node script/ensure-generated.mjs && tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs src/bin.ts", "dev": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && tsx --import ./script/require-shim.mjs src/bin.ts", "build": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single", "build:all": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts", @@ -72,7 +72,7 @@ "generate:sdk": "pnpm tsx script/generate-sdk.ts", "generate:skill": "pnpm tsx script/generate-skill.ts", "generate:banner": "pnpm tsx script/generate-banner-sixel.ts", - "generate:docs": "pnpm run generate:banner && pnpm run generate:completions && pnpm run generate:command-docs && pnpm run generate:skill && pnpm run generate:docs-sections", + "generate:docs": "node script/ensure-generated.mjs schema && pnpm run generate:banner && pnpm run generate:completions && pnpm run generate:command-docs && pnpm run generate:docs-sections && pnpm run generate:skill", "generate:completions": "pnpm tsx script/generate-completion-targets.ts", "generate:docs-sections": "pnpm tsx script/generate-docs-sections.ts", "generate:schema": "pnpm tsx script/generate-api-schema.ts", @@ -89,7 +89,9 @@ "check:docs-sections": "pnpm tsx script/generate-docs-sections.ts --check", "check:env-coverage": "pnpm tsx script/check-env-coverage.ts", "check:stale-refs": "pnpm tsx script/check-stale-references.ts", - "ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs" + "check:doc-examples": "pnpm tsx script/check-doc-examples.ts", + "check:installer-docs": "pnpm tsx script/check-installer-docs.ts", + "ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs && pnpm run check:doc-examples && pnpm run check:installer-docs" }, "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/packages/cli/plugins/README.md b/packages/cli/plugins/README.md index f7848b78f..4acc7e69b 100644 --- a/packages/cli/plugins/README.md +++ b/packages/cli/plugins/README.md @@ -22,7 +22,8 @@ npx @sentry/dotagents add https://cli.sentry.dev sentry-cli ### Cursor -Skills are automatically available in `.cursor/skills/` for Cursor users. +`packages/cli/.cursor/skills/sentry-cli/` symlinks to the skill files, so +Cursor picks them up when you open this repository. ### Other Agents @@ -48,25 +49,21 @@ The skill will guide the assistant to provide accurate CLI commands. ## Repository Structure + ``` -cli/ # Repository root -├── .claude-plugin/ -│ └── marketplace.json # Marketplace manifest -├── .cursor/ -│ └── skills/ -│ └── sentry-cli/ -│ └── SKILL.md # Symlink to plugins location -├── plugins/ -│ ├── README.md # This file -│ └── sentry-cli/ -│ ├── .claude-plugin/ -│ │ └── plugin.json # Plugin manifest -│ └── skills/ -│ └── sentry-cli/ -│ └── SKILL.md # CLI usage skill (auto-generated) -└── script/ - └── generate-skill.ts # Generates SKILL.md from CLI commands +packages/cli/.claude-plugin/marketplace.json # Marketplace manifest +packages/cli/.cursor/skills/sentry-cli/SKILL.md # Symlink to the plugin skill +packages/cli/.cursor/skills/sentry-cli/references # Symlink to the plugin references +packages/cli/plugins/README.md # This file +packages/cli/plugins/sentry-cli/.claude-plugin/plugin.json # Plugin manifest +packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md # CLI usage skill (generated) +packages/cli/plugins/sentry-cli/skills/sentry-cli/references # One file per command group (generated) +packages/cli/script/generate-skill.ts # Generates SKILL.md and references/ ``` + + +The docs site also serves these files, plus a generated `index.json` discovery +manifest, from `apps/cli-docs/public/.well-known/skills/`. ## Updating SKILL.md diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 933993124..64b1e7d1f 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -46,7 +46,7 @@ The `sentry` CLI follows conventions from well-known tools — if you're familia ### Safety Rules -- Always confirm with the user before running destructive commands: `project delete`, `trial start` +- Always confirm with the user before running destructive commands: `alert issues delete`, `alert metrics delete`, `cli uninstall`, `dashboard widget delete`, `integration delete`, `issue merge`, `issue unlink`, `project delete`, `release delete`, `trial start` - For mutations, verify the org/project context looks correct in the command output before proceeding with further changes - Never store or log authentication tokens — the CLI manages credentials automatically - If the CLI reports the wrong org/project, override with explicit `/` arguments @@ -222,7 +222,7 @@ sentry release deploy my-org/1.0.0 production sentry agent-conversation list # Explicit org, last 24 hours -sentry agent-conversation list my-org --period 24h +sentry agent-conversation list my-org/ --period 24h # View a conversation transcript sentry agent-conversation view my-org/conv-123 diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md index b7b150e57..dc3b17d39 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md @@ -168,7 +168,7 @@ Create a metric alert rule ```bash # Alert when P95 span duration goes above 500ms -sentry alert metrics create my-org \ +sentry alert metrics create my-org/ \ --name "P95 Latency" \ --project my-app \ --query "environment:prod" \ @@ -178,7 +178,7 @@ sentry alert metrics create my-org \ --trigger '{"alertThreshold":500}' # Alert on any matching log line and notify through an existing alert -sentry alert metrics create my-org \ +sentry alert metrics create my-org/ \ --name "AI chat failures" \ --project my-app \ --query "action:ai.chat result:error" \ diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md index 15a3e3767..86be3ee98 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md @@ -157,7 +157,7 @@ sentry cli setup --no-modify-path --no-completions Uninstall Sentry CLI **Flags:** -- `--keep-config - Keep the config directory (~/.sentry) and auth tokens` +- `--keep-config - Keep the config directory (default ~/.config/sentry) and auth tokens` - `-y, --yes - Skip confirmation prompt` - `-f, --force - Force the operation without confirmation` - `-n, --dry-run - Show what would happen without making changes` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index 60ee3c5ae..38e9c543e 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -197,7 +197,7 @@ sentry dashboard revisions 'Frontend Performance' sentry dashboard revisions 12345 # With explicit org -sentry dashboard revisions my-org 12345 +sentry dashboard revisions my-org/ 12345 ``` ### `sentry dashboard restore ` @@ -217,7 +217,7 @@ sentry dashboard restore 'Frontend Performance' --revision 3 sentry dashboard restore 12345 --revision 1 # With explicit org -sentry dashboard restore my-org 12345 --revision 1 +sentry dashboard restore my-org/ 12345 --revision 1 ``` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts new file mode 100644 index 000000000..4bd88315f --- /dev/null +++ b/packages/cli/script/check-doc-examples.ts @@ -0,0 +1,347 @@ +#!/usr/bin/env tsx +/** + * Check `sentry …` examples in hand-written docs against the live route tree. + * + * Every `sentry` invocation in a fenced shell block or inline code span, and + * every example embedded in a command (`docs.examples` and `fullDescription`, + * which feed `--help` and the generated command pages), must: + * 1. Name a command that exists (e.g. `sentry upgrade` → `sentry cli upgrade`) + * 2. Use only flags (and single-letter aliases) that command defines + * 3. Not pass a bare org-looking slug (`my-org`) as a target. A bare name + * matches a project first, so examples that mean the org must use the + * trailing-slash form (`my-org/`) + * + * Placeholders (`/`, `[args]`, `...`) are skipped. Generated + * command pages are not scanned: their examples are checked at the source. + * + * Usage: + * tsx script/check-doc-examples.ts + * + * Exit codes: + * 0 - All examples are valid + * 1 - At least one example names a missing command, flag, or ambiguous target + */ + +import { mkdirSync, readdirSync } from "node:fs"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { + Command, + PositionalParam, + RouteMap, + RouteTarget, +} from "../src/lib/introspect.js"; +import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; + +// Bootstrap: skill-content stub (same pattern as generate-docs-sections.ts) +const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; +const skillContentExists = await access(SKILL_CONTENT_PATH).then( + () => true, + () => false, +); +if (!skillContentExists) { + mkdirSync("src/generated", { recursive: true }); + await writeFile( + SKILL_CONTENT_PATH, + "export const SKILL_FILES: [string, string][] = [];\n", + ); +} + +const { routes } = await import("../src/app.js"); +const { isCommand, isRouteMap } = await import("../src/lib/introspect.js"); +const { parseOrgProjectTarget } = + await import("../src/lib/completion-target.js"); + +/** Generated from the route tree, so not worth re-checking. */ +const GENERATED_DOCS_DIR = `${DOCS_CONTENT}/commands`; + +/** Hand-written Markdown outside the docs site that shows CLI usage. */ +const EXTRA_DOC_FILES = [ + "README.md", + "CONTRIBUTING.md", + "DEVELOPMENT.md", + "plugins/README.md", +]; + +/** Flags Stricli handles before command dispatch. */ +const BUILTIN_FLAGS = new Set(["help", "help-all", "version"]); +const BUILTIN_ALIASES = new Set(["h", "H"]); + +/** + * Slugs that docs use for an organization. As a bare target they resolve to + * a project first, so an example meaning "the org" needs `my-org/`. + */ +const ORG_PLACEHOLDER_RE = /^(?:my-org|acme)$/; + +/** Placeholders of positionals parsed with `parseOrgProjectArg()`. */ +const TARGET_PLACEHOLDER_RE = /^(?:target|org\/.+)$/; + +const FENCE_RE = /^(`{3,}|~{3,})\s*([\w-]*)[^\n]*\n([\s\S]*?)\n\1\s*$/gm; +const SHELL_LANGS = new Set(["", "bash", "sh", "shell", "zsh", "console"]); +const INLINE_CODE_RE = /(?[\]{}]|\.\.\.|…|^\$/; +const SHORT_ALIAS_RE = /^-([a-zA-Z])$/; + +type Finding = { file: string; example: string; problem: string }; + +/** Stricli's alias-aware lookup, not part of the simplified RouteMap type. */ +type RoutingMap = RouteMap & { + getRoutingTargetForInput(input: string): RouteTarget | undefined; +}; + +/** Split a shell command into words, honoring single and double quotes. */ +function tokenize(command: string): string[] { + const tokens: string[] = []; + let current = ""; + let quote: string | null = null; + let hasToken = false; + for (const ch of command) { + if (quote) { + if (ch === quote) { + quote = null; + } else { + current += ch; + } + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + hasToken = true; + continue; + } + if (ch === "#" && !hasToken) { + break; // comment + } + if (/\s/.test(ch)) { + if (hasToken) { + tokens.push(current); + current = ""; + hasToken = false; + } + continue; + } + current += ch; + hasToken = true; + } + if (hasToken) { + tokens.push(current); + } + return tokens; +} + +/** Collect `sentry …` invocations from one Markdown file. */ +function extractInvocations(markdown: string): string[] { + const invocations: string[] = []; + for (const [, , lang = "", body = ""] of markdown.matchAll(FENCE_RE)) { + if (!SHELL_LANGS.has(lang)) { + continue; + } + for (const rawLine of body.replace(CONTINUATION_RE, " ").split("\n")) { + const line = rawLine.trim().replace(PROMPT_RE, ""); + if (line.startsWith("#")) { + continue; + } + for (const segment of line.split(COMMAND_SPLIT_RE)) { + if (segment.startsWith("sentry ")) { + invocations.push(segment); + } + } + } + } + const prose = markdown.replace(FENCE_RE, ""); + for (const [, code = ""] of prose.matchAll(INLINE_CODE_RE)) { + invocations.push(code); + } + return invocations; +} + +/** Whether `--name` (or the boolean negation `--no-name`) is accepted. */ +function hasFlag(cmd: Command, name: string): boolean { + const flags = cmd.parameters.flags ?? {}; + const negated = name.startsWith("no-") ? name.slice(3) : undefined; + return ( + BUILTIN_FLAGS.has(name) || + Object.hasOwn(flags, name) || + (negated !== undefined && Object.hasOwn(flags, negated)) + ); +} + +/** Walk the route tree; returns the command and the remaining tokens. */ +function resolve( + tokens: string[], +): { cmd: Command; rest: string[] } | { error: string } | null { + let target: RouteTarget = routes as unknown as RouteMap; + let i = 0; + while (isRouteMap(target)) { + const word = tokens[i]; + if (word === undefined || word.startsWith("-")) { + const fallback = target.getDefaultCommand?.(); + if (fallback && isCommand(fallback)) { + return { cmd: fallback, rest: tokens.slice(i) }; + } + // `sentry issue` alone names a group; `--help` on a group is fine. + return word === undefined || word === "--help" || word === "-h" + ? null + : { error: `\`${tokens.slice(0, i).join(" ")}\` is a group` }; + } + if (PLACEHOLDER_RE.test(word)) { + return null; + } + // Resolves aliases too (`issue ignore` → `issue archive`, `new` → `create`) + const next = (target as RoutingMap).getRoutingTargetForInput(word); + if (!next) { + const fallback = target.getDefaultCommand?.(); + if (i > 0 && fallback && isCommand(fallback)) { + return { cmd: fallback, rest: tokens.slice(i) }; + } + const path = ["sentry", ...tokens.slice(0, i + 1)].join(" "); + return { error: `unknown command \`${path}\`` }; + } + target = next; + i += 1; + } + return isCommand(target) ? { cmd: target, rest: tokens.slice(i) } : null; +} + +/** + * Whether a positional takes an `/` target, where a bare name + * is a project search. Raw org slugs (placeholder `org`) are not targets. + */ +function isTargetParam(param: PositionalParam | undefined): boolean { + if (!param) { + return false; + } + return ( + param.parse === parseOrgProjectTarget || + TARGET_PLACEHOLDER_RE.test(param.placeholder ?? "") + ); +} + +/** Problems with one `sentry …` invocation. */ +function checkInvocation(invocation: string): string[] { + const tokens = tokenize(invocation).slice(1); + const resolved = resolve(tokens); + if (!resolved) { + return []; + } + if ("error" in resolved) { + return [resolved.error]; + } + const { cmd, rest } = resolved; + const problems: string[] = []; + const aliases = cmd.parameters.aliases ?? {}; + const positionals: string[] = []; + for (const token of rest) { + if (token === "--") { + break; + } + if (token.startsWith("--")) { + const name = token.slice(2).split("=")[0] ?? ""; + if (!(PLACEHOLDER_RE.test(name) || hasFlag(cmd, name))) { + problems.push(`unknown flag \`--${name}\``); + } + continue; + } + const alias = token.match(SHORT_ALIAS_RE)?.[1]; + if (alias) { + if (!(BUILTIN_ALIASES.has(alias) || Object.hasOwn(aliases, alias))) { + problems.push(`unknown flag \`-${alias}\``); + } + continue; + } + positionals.push(token); + } + + const firstParam = + cmd.parameters.positional?.kind === "tuple" + ? cmd.parameters.positional.parameters[0] + : cmd.parameters.positional?.parameter; + const first = positionals[0]; + if (first && ORG_PLACEHOLDER_RE.test(first) && isTargetParam(firstParam)) { + problems.push( + `bare \`${first}\` resolves to a project first; use \`${first}/\` for the organization`, + ); + } + return problems; +} + +function listMarkdown(dir: string): string[] { + return readdirSync(dir, { recursive: true, encoding: "utf-8" }) + .filter((f) => f.endsWith(".md") || f.endsWith(".mdx")) + .map((f) => join(dir, f)) + .filter((f) => !f.startsWith(GENERATED_DOCS_DIR)); +} + +const files = [ + ...listMarkdown(DOCS_CONTENT), + ...listMarkdown(DOCS_FRAGMENTS), + ...EXTRA_DOC_FILES, +]; + +/** + * Examples embedded in commands: `docs.examples` and `sentry …` lines in + * `fullDescription`. These feed `--help` and the generated command pages. + */ +function commandExamples( + target: RouteTarget, + path: string[], +): { source: string; invocation: string }[] { + if (isRouteMap(target)) { + return target + .getAllEntries() + .filter((e) => !e.hidden) // hidden entries are plural aliases + .flatMap((e) => commandExamples(e.target, [...path, e.name.original])); + } + if (!isCommand(target)) { + return []; + } + const source = `\`${["sentry", ...path].join(" ")}\` help`; + const fromDocs = (target.__examples ?? []).map((e) => e.command); + const fromDescription = (target.fullDescription ?? "") + .replace(CONTINUATION_RE, " ") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("sentry ")); + return [...fromDocs, ...fromDescription].map((invocation) => ({ + source, + invocation, + })); +} + +const findings: Finding[] = []; +let checked = 0; +for (const { source, invocation } of commandExamples( + routes as unknown as RouteMap, + [], +)) { + checked += 1; + for (const problem of checkInvocation(invocation)) { + findings.push({ file: source, example: invocation, problem }); + } +} +for (const file of files) { + const content = await readFile(file, "utf-8"); + for (const invocation of extractInvocations(content)) { + checked += 1; + for (const problem of checkInvocation(invocation)) { + findings.push({ file, example: invocation, problem }); + } + } +} + +if (findings.length === 0) { + console.log( + `✓ ${checked} sentry examples in ${files.length} files match the CLI`, + ); + process.exit(0); +} + +console.error(`✗ ${findings.length} doc example problem(s):\n`); +for (const f of findings) { + console.error(` ${f.file}: ${f.example}`); + console.error(` → ${f.problem}\n`); +} +process.exit(1); diff --git a/packages/cli/script/check-installer-docs.ts b/packages/cli/script/check-installer-docs.ts new file mode 100644 index 000000000..d01defadf --- /dev/null +++ b/packages/cli/script/check-installer-docs.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env tsx +/** + * Installer documentation coverage. + * + * The install scripts (`install` for bash, `install.ps1` for PowerShell) read + * environment variables and flags that the CLI's ENV_VAR_REGISTRY never sees, + * so check-env-coverage.ts cannot catch them. This check closes that gap: + * + * 1. Every `SENTRY_*` variable an installer reads, and every flag the bash + * installer parses, appears in that installer's own help text. + * 2. Every variable and flag in either installer's help text appears in the + * installation guide (getting-started.mdx). + * + * Usage: + * tsx script/check-installer-docs.ts + * + * Exit codes: + * 0 - Installer options are documented + * 1 - An option is missing from installer help or the installation guide + */ + +import { readFile } from "node:fs/promises"; +import { DOCS_CONTENT } from "./paths.js"; + +const GUIDE = `${DOCS_CONTENT}/getting-started.mdx`; + +/** `${SENTRY_FOO:-}` / `$SENTRY_FOO` reads in bash. */ +const BASH_ENV_READ_RE = /\$\{?(SENTRY_[A-Z0-9_]+)/g; +/** `SENTRY_FOO="..."` script-local assignments (constants, not inputs). */ +const BASH_ASSIGN_RE = /^\s*(?:local\s+)?(SENTRY_[A-Z0-9_]+)=/gm; +/** `--flag)` or `-f|--flag)` case arms in the bash argument parser. */ +const BASH_FLAG_ARM_RE = /^\s*(?:-[a-z]\|)?(--[a-z][a-z-]*)\)/gm; +/** The `usage()` heredoc body in the bash installer. */ +const BASH_USAGE_RE = /usage\(\) \{\n\s*cat <` comment-based help block in PowerShell. */ +const PS_HELP_RE = /^<#([\s\S]*?)#>/; +/** Variable names listed in a help block. */ +const HELP_VAR_RE = /\b(SENTRY_[A-Z0-9_]+)\b/g; + +/** Flags handled by every CLI; not worth documenting in the guide. */ +const UNDOCUMENTED_FLAGS = new Set(["--help"]); + +type Installer = { + file: string; + reads: Set; + flags: Set; + help: string; +}; + +function matchAll(re: RegExp, text: string): Set { + return new Set([...text.matchAll(re)].map((m) => m[1] ?? "")); +} + +async function loadBash(): Promise { + const file = "install"; + const source = await readFile(file, "utf-8"); + const help = source.match(BASH_USAGE_RE)?.[1]; + if (!help) { + throw new Error(`${file}: usage() heredoc not found`); + } + const assigned = matchAll(BASH_ASSIGN_RE, source); + const reads = new Set( + [...matchAll(BASH_ENV_READ_RE, source)].filter((v) => !assigned.has(v)), + ); + return { file, reads, flags: matchAll(BASH_FLAG_ARM_RE, source), help }; +} + +async function loadPowerShell(): Promise { + const file = "install.ps1"; + const source = await readFile(file, "utf-8"); + const help = source.match(PS_HELP_RE)?.[1]; + if (!help) { + throw new Error(`${file}: <# ... #> help block not found`); + } + return { + file, + reads: matchAll(PS_ENV_READ_RE, source), + flags: new Set(), + help, + }; +} + +const installers = [await loadBash(), await loadPowerShell()]; +const guide = await readFile(GUIDE, "utf-8"); +const problems: string[] = []; + +for (const { file, reads, flags, help } of installers) { + for (const name of [...reads, ...flags]) { + if (!(UNDOCUMENTED_FLAGS.has(name) || help.includes(name))) { + problems.push(`${file} reads ${name} but its help text omits it`); + } + } + const documented = [...matchAll(HELP_VAR_RE, help), ...flags]; + for (const name of documented) { + if (!(UNDOCUMENTED_FLAGS.has(name) || guide.includes(name))) { + problems.push(`${file} supports ${name} but ${GUIDE} omits it`); + } + } +} + +if (problems.length === 0) { + console.log( + `✓ Installer options are documented (${installers.map((i) => i.file).join(", ")})`, + ); + process.exit(0); +} + +console.error(`✗ ${problems.length} undocumented installer option(s):\n`); +for (const problem of problems) { + console.error(` ${problem}`); +} +process.exit(1); diff --git a/packages/cli/script/check-stale-references.ts b/packages/cli/script/check-stale-references.ts index 4fda955b2..cfb77ba17 100644 --- a/packages/cli/script/check-stale-references.ts +++ b/packages/cli/script/check-stale-references.ts @@ -13,6 +13,9 @@ * `packageManager` field in package.json will automatically make this * check flag every `pnpm run` / `pnpm add` reference in dev docs. * + * It also flags `pnpm run cli -- ` in dev docs: pnpm passes the `--` + * through, so the CLI sees it as the end of options and fails. + * * Usage: * tsx script/check-stale-references.ts * @@ -175,15 +178,60 @@ function findOriginalLineNumber( return 0; } +// --------------------------------------------------------------------------- +// CLI scripts: `pnpm run cli -- --help` passes a literal `--` to the CLI, +// which then treats `--help` as a command name. Checked inside code blocks +// too, since that is where these commands live. +// --------------------------------------------------------------------------- + +/** package.json scripts that run the CLI entry point directly. */ +const cliScripts = Object.entries(pkg.scripts as Record) + .filter(([, command]) => command.includes("src/bin.ts")) + .map(([name]) => name); + +const CLI_SEPARATOR_RE = new RegExp( + `\\bpnpm(?:\\s+--filter\\s+\\S+)?\\s+(?:run\\s+)?(?:${cliScripts.join("|")})\\s+--(?=\\s|$)`, +); + +/** Docs that show how to run the CLI from a checkout. */ +const CLI_USAGE_FILES = [ + ...DOC_FILES, + "README.md", + "AGENTS.md", + "CONTRIBUTING.md", +]; + +for (const filePath of CLI_USAGE_FILES) { + let content: string; + try { + content = await readFile(filePath, "utf-8"); + } catch { + continue; + } + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + if (CLI_SEPARATOR_RE.test(line)) { + findings.push({ + file: filePath, + line: i + 1, + text: line.trim(), + reason: + "pnpm forwards a literal `--` to the CLI; pass arguments directly (`pnpm run cli --help`)", + }); + } + } +} + if (findings.length === 0) { console.log( - `✓ No stale package manager references found (current: ${currentPM}, checked: ${FILES_TO_SCAN.length} files)`, + `✓ No stale references found (current PM: ${currentPM}, checked: ${FILES_TO_SCAN.length + CLI_USAGE_FILES.length} files)`, ); process.exit(0); } console.error( - `✗ Found ${findings.length} stale package manager reference(s) (current PM: ${currentPM}):\n`, + `✗ Found ${findings.length} stale reference(s) (current PM: ${currentPM}):\n`, ); for (const f of findings) { console.error(` ${f.file}:${f.line}: ${f.text}`); diff --git a/packages/cli/script/ensure-generated.mjs b/packages/cli/script/ensure-generated.mjs new file mode 100644 index 000000000..92370db0e --- /dev/null +++ b/packages/cli/script/ensure-generated.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * Generate the gitignored `src/generated/` files when they are missing. + * + * A fresh checkout has no API schema, skill content, or completion targets, so + * `pnpm run cli`, `typecheck`, and the tests used to fail until contributors + * ran `generate:schema` and `generate:docs` by hand. Scripts that need those + * files call this first, so there is no bootstrap step to document or forget. + * + * Usage: + * node script/ensure-generated.mjs # everything the CLI imports + * node script/ensure-generated.mjs schema # only the API schema + * + * Plain Node (no tsx) so the check adds no noticeable startup time. + */ + +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; + +const SCHEMA = "src/generated/api-schema.json"; +/** Produced by `generate:docs`; imported at runtime by `src/`. */ +const DOCS_OUTPUTS = [ + "src/generated/skill-content.ts", + "src/generated/completion-targets.json", +]; + +function run(script) { + console.error(`Missing generated files; running \`pnpm run ${script}\`...`); + execSync(`pnpm run ${script}`, { stdio: "inherit" }); +} + +if (!existsSync(SCHEMA)) { + run("generate:schema"); +} +if (process.argv[2] !== "schema" && !DOCS_OUTPUTS.every(existsSync)) { + run("generate:docs"); +} diff --git a/packages/cli/script/generate-docs-sections.ts b/packages/cli/script/generate-docs-sections.ts index dabba3ad5..ad2abcd13 100644 --- a/packages/cli/script/generate-docs-sections.ts +++ b/packages/cli/script/generate-docs-sections.ts @@ -11,6 +11,10 @@ * - self-hosted.md: OAuth scopes, env var table * - README.md: dev prerequisites, library prerequisites, dev scripts * - getting-started.mdx: platform support table + * - agent-guidance.md: destructive commands (from `destructive` commands) + * - agentic-usage.md: detected agents (from AGENT_DISPLAY_NAMES) + * - commands/index.md fragment, CONTRIBUTING.md: target syntax (TARGET_FORMS) + * - plugins/README.md: skill file layout (paths checked on disk) * * Unlike generate-command-docs.ts (which produces gitignored files from scratch), * this script edits committed files in-place between marker pairs. @@ -20,9 +24,9 @@ * tsx script/generate-docs-sections.ts --check # Dry-run, exit 1 if stale */ -import { mkdirSync } from "node:fs"; +import { existsSync, mkdirSync } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; -import { DOCS_CONTENT } from "./paths.js"; +import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; // Bootstrap: skill-content stub (same pattern as generate-command-docs.ts) const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; @@ -44,6 +48,8 @@ const { routes } = await import("../src/app.js"); const { extractAllRoutes } = await import("../src/lib/introspect.js"); const { OAUTH_SCOPES } = await import("../src/lib/oauth.js"); const { ENV_VAR_REGISTRY } = await import("../src/lib/env-registry.js"); +const { AGENT_DISPLAY_NAMES } = await import("../src/lib/detect-agent.js"); +const { TARGET_FORMS } = await import("../src/lib/target-syntax.js"); const pkg = JSON.parse(await readFile("package.json", "utf-8")); const workspace = JSON.parse(await readFile("../../package.json", "utf-8")); @@ -556,6 +562,97 @@ function generatePlatformSupport(): string { return lines.join("\n"); } +// --------------------------------------------------------------------------- +// Section: Destructive Commands (agent-guidance.md) +// --------------------------------------------------------------------------- + +const SENTRY_PREFIX_RE = /^sentry /; + +/** + * Generate the Safety Rules bullet that lists destructive commands. + * + * Commands opt in with `buildCommand({ destructive: true })`; + * `buildDeleteCommand` sets it automatically, so new delete commands are + * listed without touching the docs. + */ +function generateDestructiveCommands(allRoutes: RouteInfo[]): string { + const names = allRoutes + .flatMap((route) => route.commands) + .filter((cmd) => cmd.destructive) + .map((cmd) => cmd.path.replace(SENTRY_PREFIX_RE, "")) + .sort((a, b) => a.localeCompare(b)); + if (names.length === 0) { + throw new Error("No destructive commands found in the route tree"); + } + const list = names.map((name) => `\`${name}\``).join(", "); + return `- Always confirm with the user before running destructive commands: ${list}`; +} + +// --------------------------------------------------------------------------- +// Section: Detected Agents (agentic-usage.md) +// --------------------------------------------------------------------------- + +/** Generate the list of agents the CLI detects, from AGENT_DISPLAY_NAMES. */ +function generateDetectedAgents(): string { + const names = [...AGENT_DISPLAY_NAMES.values()]; + const last = names.pop(); + return `The CLI detects ${names.join(", ")}, and ${last}.`; +} + +// --------------------------------------------------------------------------- +// Section: Target Syntax (commands/index.md fragment, CONTRIBUTING.md) +// --------------------------------------------------------------------------- + +/** Generate the target syntax table from TARGET_FORMS. */ +function generateTargetSyntax(): string { + const lines = ["| Target | Meaning |", "|--------|---------|"]; + for (const form of TARGET_FORMS) { + lines.push(`| ${form.syntax} | ${form.meaning} |`); + } + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Section: Skill File Layout (plugins/README.md) +// --------------------------------------------------------------------------- + +/** + * Skill-related paths under `packages/cli/`, in tree order. Generation fails + * when one no longer exists, so the README cannot describe a stale layout. + */ +const SKILL_LAYOUT: readonly [path: string, note: string][] = [ + [".claude-plugin/marketplace.json", "Marketplace manifest"], + [".cursor/skills/sentry-cli/SKILL.md", "Symlink to the plugin skill"], + [".cursor/skills/sentry-cli/references", "Symlink to the plugin references"], + ["plugins/README.md", "This file"], + ["plugins/sentry-cli/.claude-plugin/plugin.json", "Plugin manifest"], + [ + "plugins/sentry-cli/skills/sentry-cli/SKILL.md", + "CLI usage skill (generated)", + ], + [ + "plugins/sentry-cli/skills/sentry-cli/references", + "One file per command group (generated)", + ], + ["script/generate-skill.ts", "Generates SKILL.md and references/"], +]; + +/** Render {@link SKILL_LAYOUT} as an annotated path list. */ +function generateSkillLayout(): string { + const missing = SKILL_LAYOUT.filter(([path]) => !existsSync(path)); + if (missing.length > 0) { + throw new Error( + `plugins/README.md layout lists missing paths: ${missing.map(([p]) => p).join(", ")}`, + ); + } + const paths = SKILL_LAYOUT.map(([path]) => `packages/cli/${path}`); + const width = Math.max(...paths.map((p) => p.length)); + const rows = SKILL_LAYOUT.map( + ([, note], i) => `${paths[i]?.padEnd(width)} # ${note}`, + ); + return ["```", ...rows, "```"].join("\n"); +} + // --------------------------------------------------------------------------- // Section Definitions // --------------------------------------------------------------------------- @@ -647,6 +744,32 @@ const sections: SectionDef[] = [ generate: generatePlatformSupport, markerStyle: "mdx", }, + // -- Lists that mirror code -- + { + filePath: `${DOCS_CONTENT}/agent-guidance.md`, + sectionName: "destructive-commands", + generate: () => generateDestructiveCommands(routeInfos), + }, + { + filePath: `${DOCS_CONTENT}/agentic-usage.md`, + sectionName: "detected-agents", + generate: generateDetectedAgents, + }, + { + filePath: `${DOCS_FRAGMENTS}/commands/index.md`, + sectionName: "target-syntax", + generate: generateTargetSyntax, + }, + { + filePath: "CONTRIBUTING.md", + sectionName: "target-syntax", + generate: generateTargetSyntax, + }, + { + filePath: "plugins/README.md", + sectionName: "skill-layout", + generate: generateSkillLayout, + }, ]; let staleCount = 0; diff --git a/packages/cli/script/generate-skill.ts b/packages/cli/script/generate-skill.ts index 2ebe69b2b..1265973f6 100644 --- a/packages/cli/script/generate-skill.ts +++ b/packages/cli/script/generate-skill.ts @@ -209,6 +209,9 @@ function escapeRegex(str: string): string { // Documentation Loading // --------------------------------------------------------------------------- +/** `` / `END` lines from generate-docs-sections.ts */ +const GENERATED_MARKER_LINE_RE = /^\n/gm; + /** Load and parse a documentation file */ async function loadDoc(relativePath: string): Promise { const fullPath = `${DOCS_PATH}/${relativePath}`; @@ -220,7 +223,9 @@ async function loadDoc(relativePath: string): Promise { return null; } const content = await readFile(fullPath, "utf-8"); - return stripMdxComponents(stripFrontmatter(content)); + return stripMdxComponents( + stripFrontmatter(content).replace(GENERATED_MARKER_LINE_RE, ""), + ); } /** Extract npm command from PackageManagerCode Astro component */ diff --git a/packages/cli/src/commands/cli/uninstall.ts b/packages/cli/src/commands/cli/uninstall.ts index ed6bfe27a..91635084c 100644 --- a/packages/cli/src/commands/cli/uninstall.ts +++ b/packages/cli/src/commands/cli/uninstall.ts @@ -30,7 +30,11 @@ import type { SentryContext } from "../../context.js"; import { getSkillInstallPath } from "../../lib/agent-skills.js"; import { isHomebrewInstall } from "../../lib/binary.js"; import { getCompletionPath } from "../../lib/completions.js"; -import { closeDatabase, getConfigDir } from "../../lib/db/index.js"; +import { + closeDatabase, + DEFAULT_CONFIG_DIR_DISPLAY, + getConfigDir, +} from "../../lib/db/index.js"; import { getInstallInfo } from "../../lib/db/install-info.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; @@ -328,7 +332,7 @@ function gatherArtifacts(home: string): UninstallArtifact[] { } } - // 5. Config directory (~/.sentry/) + // 5. Config directory (SENTRY_CONFIG_DIR, XDG, or legacy ~/.sentry) const configDir = getConfigDir(); artifacts.push({ label: "config directory", @@ -438,7 +442,7 @@ export const uninstallCommand = buildDeleteCommand({ flags: { "keep-config": { kind: "boolean", - brief: "Keep the config directory (~/.sentry) and auth tokens", + brief: `Keep the config directory (default ${DEFAULT_CONFIG_DIR_DISPLAY}) and auth tokens`, default: false, optional: true, }, diff --git a/packages/cli/src/commands/dashboard/list.ts b/packages/cli/src/commands/dashboard/list.ts index 6dfd52213..d37bf817f 100644 --- a/packages/cli/src/commands/dashboard/list.ts +++ b/packages/cli/src/commands/dashboard/list.ts @@ -376,7 +376,6 @@ export const listCommand = buildListCommand("dashboard", { " sentry dashboard list my-org/ # explicit org\n" + " sentry dashboard list my-org/my-project # org from explicit project\n" + " sentry dashboard list 'Error*' # filter by title glob\n" + - " sentry dashboard list my-org '*API*' # bare org + filter\n" + " sentry dashboard list my-org/ '*API*' # org/ + filter\n" + " sentry dashboard list -c next # next page\n" + " sentry dashboard list -c prev # previous page\n" + diff --git a/packages/cli/src/commands/dashboard/restore.ts b/packages/cli/src/commands/dashboard/restore.ts index 8bd9d5345..78f43874d 100644 --- a/packages/cli/src/commands/dashboard/restore.ts +++ b/packages/cli/src/commands/dashboard/restore.ts @@ -61,7 +61,7 @@ export const restoreCommand = buildCommand({ "Use `sentry dashboard revisions` to list available revisions first.\n\n" + "Examples:\n" + " sentry dashboard restore 12345 --revision 42\n" + - " sentry dashboard restore my-org 12345 --revision 42\n" + + " sentry dashboard restore my-org/ 12345 --revision 42\n" + " sentry dashboard restore 'My Dashboard' --revision 42\n" + " sentry dashboard restore 12345 --revision 42 --json", }, diff --git a/packages/cli/src/commands/dashboard/revisions.ts b/packages/cli/src/commands/dashboard/revisions.ts index 7cc551bac..bf4176bcd 100644 --- a/packages/cli/src/commands/dashboard/revisions.ts +++ b/packages/cli/src/commands/dashboard/revisions.ts @@ -123,9 +123,9 @@ export const revisionsCommand = buildCommand({ "Examples:\n" + " sentry dashboard revisions 12345\n" + " sentry dashboard revisions 'My Dashboard'\n" + - " sentry dashboard revisions my-org 12345\n" + - " sentry dashboard revisions my-org 12345 --json\n" + - " sentry dashboard revisions my-org 12345 -c next", + " sentry dashboard revisions my-org/ 12345\n" + + " sentry dashboard revisions my-org/ 12345 --json\n" + + " sentry dashboard revisions my-org/ 12345 -c next", }, output: { human: formatRevisionsHuman, diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index 36b53b633..778bfac79 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -172,8 +172,8 @@ export const viewCommand = buildCommand({ "Examples:\n" + " sentry dashboard view 12345\n" + " sentry dashboard view 'My Dashboard'\n" + - " sentry dashboard view my-org 12345\n" + - " sentry dashboard view my-org 'My Dashboard'\n" + + " sentry dashboard view my-org/ 12345\n" + + " sentry dashboard view my-org/ 'My Dashboard'\n" + " sentry dashboard view my-org/my-project 12345\n" + " sentry dashboard view 12345 --json\n" + " sentry dashboard view 12345 --period 7d\n" + diff --git a/packages/cli/src/commands/issue/merge.ts b/packages/cli/src/commands/issue/merge.ts index d8ced7e9d..d8fc8bf46 100644 --- a/packages/cli/src/commands/issue/merge.ts +++ b/packages/cli/src/commands/issue/merge.ts @@ -251,6 +251,7 @@ async function orderForMerge( } export const mergeCommand = buildCommand({ + destructive: true, docs: { brief: "Merge 2+ issues into a single canonical group", fullDescription: diff --git a/packages/cli/src/commands/trial/start.ts b/packages/cli/src/commands/trial/start.ts index 05a0506d7..072119e04 100644 --- a/packages/cli/src/commands/trial/start.ts +++ b/packages/cli/src/commands/trial/start.ts @@ -77,6 +77,7 @@ function parseTrialStartArgs( } export const startCommand = buildCommand({ + destructive: true, docs: { brief: "Start a product trial", fullDescription: diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 33d6de8f7..fd040f0ba 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -192,6 +192,15 @@ type LocalCommandBuilderArguments< * repo-local `.sentryclirc` URL mismatches the current token. */ readonly skipRcUrlCheck?: boolean; + /** + * Mark the command as destructive or irreversible. Defaults to `false`. + * + * Destructive commands are listed in the agent Safety Rules (generated from + * this flag), so agents ask the user before running them. + * `buildDeleteCommand` sets this automatically; set it explicitly for other + * irreversible operations (e.g. `issue merge`, `trial start`). + */ + readonly destructive?: boolean; }; // --------------------------------------------------------------------------- @@ -882,6 +891,9 @@ export function buildCommand< (cmd as unknown as Record).__examples = builderArgs.docs.examples; } + if (builderArgs.destructive) { + (cmd as unknown as Record).__destructive = true; + } // Attach the JSON schema to the built command as a non-standard property. // introspect.ts reads this to populate CommandInfo.jsonFields for help diff --git a/packages/cli/src/lib/db/index.ts b/packages/cli/src/lib/db/index.ts index 8fbfad802..185209854 100644 --- a/packages/cli/src/lib/db/index.ts +++ b/packages/cli/src/lib/db/index.ts @@ -27,6 +27,9 @@ const LEGACY_CONFIG_DIR_NAME = ".sentry"; /** Sub-directory used under the XDG config base directory. */ const XDG_CONFIG_SUBDIR = "sentry"; +/** Default config directory as shown to users (help text, docs). */ +export const DEFAULT_CONFIG_DIR_DISPLAY = `~/.config/${XDG_CONFIG_SUBDIR}`; + const DB_FILENAME = "cli.db"; /** 7-day TTL for cache entries (milliseconds) */ diff --git a/packages/cli/src/lib/detect-agent.ts b/packages/cli/src/lib/detect-agent.ts index 80b34863f..89c83fa47 100644 --- a/packages/cli/src/lib/detect-agent.ts +++ b/packages/cli/src/lib/detect-agent.ts @@ -182,6 +182,34 @@ export const PROCESS_NAME_AGENTS = new Map([ ["gemini", "gemini"], ]); +/** + * Canonical agent name → display name, in documentation order. + * + * The agentic-usage docs list is generated from this map, and a unit test + * fails when a detected agent (from {@link ENV_VAR_AGENTS}, + * {@link PROCESS_NAME_AGENTS}, {@link AGENT_ALIASES}, or the Claude/Cowork + * branch in {@link detectAgent}) has no entry here. + */ +export const AGENT_DISPLAY_NAMES = new Map([ + ["claude", "Claude Code"], + ["cowork", "Claude Cowork"], + ["cursor", "Cursor"], + ["windsurf", "Windsurf"], + ["github-copilot", "GitHub Copilot"], + ["gemini", "Gemini CLI"], + ["codex", "OpenAI Codex"], + ["antigravity", "Antigravity"], + ["goose", "Goose"], + ["amp", "Amp"], + ["augment", "Augment"], + ["opencode", "OpenCode"], + ["cline", "Cline"], + ["grok", "Grok"], + ["kimi", "Kimi"], + ["junie", "Junie"], + ["openclaw", "OpenClaw"], +]); + /** Max levels to walk up the process tree before giving up. */ const MAX_ANCESTOR_DEPTH = 5; diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index fb144c72a..23f791a93 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -68,6 +68,8 @@ export type Command = { */ __primaryUsage?: string; __examples?: readonly CommandExample[]; + /** Set by `buildCommand({ destructive: true })` and `buildDeleteCommand`. */ + __destructive?: boolean; }; /** Positional parameter definitions — either fixed-length tuple or variadic array */ @@ -124,6 +126,8 @@ export type CommandInfo = { examples: string[]; /** JSON output field metadata extracted from `OutputConfig.schema` */ jsonFields?: SchemaFieldInfo[]; + /** Present (and `true`) when the command is destructive or irreversible */ + destructive?: true; }; /** Extracted metadata for a single flag */ @@ -327,6 +331,7 @@ export function buildCommandInfo( ) : examples, jsonFields: jsonFields?.length ? jsonFields : undefined, + ...(cmd.__destructive ? { destructive: true as const } : {}), }; } diff --git a/packages/cli/src/lib/mutate-command.ts b/packages/cli/src/lib/mutate-command.ts index f79a24ba6..ea57cf874 100644 --- a/packages/cli/src/lib/mutate-command.ts +++ b/packages/cli/src/lib/mutate-command.ts @@ -294,6 +294,8 @@ export type DeleteCommandOptions = { * 3. Runs a non-interactive guard before `func()` — refuses to proceed if * stdin is not a TTY and neither `--yes`/`--force` was passed (dry-run * bypasses the guard since it makes no changes) + * 4. Marks the command `destructive`, which lists it in the generated agent + * Safety Rules * * Commands still handle their own confirmation prompts using * {@link confirmByTyping} + {@link isConfirmationBypassed} inside `func()`, @@ -407,5 +409,6 @@ export function buildDeleteCommand< parameters: mergedParams, func: wrappedFunc, output: builderArgs.output, + destructive: true, }); } diff --git a/packages/cli/src/lib/target-syntax.ts b/packages/cli/src/lib/target-syntax.ts new file mode 100644 index 000000000..ec61f4180 --- /dev/null +++ b/packages/cli/src/lib/target-syntax.ts @@ -0,0 +1,65 @@ +/** + * Documented forms of the `/` target positional. + * + * The target syntax tables in the commands overview and CONTRIBUTING.md are + * generated from this list (`script/generate-docs-sections.ts`), and + * `test/lib/target-syntax.test.ts` parses each example with + * `parseOrgProjectArg()` to check it yields the documented type. Change the + * parser and this list together. + */ + +import type { ProjectSpecificationType } from "./arg-parsing.js"; + +type SpecificationType = + (typeof ProjectSpecificationType)[keyof typeof ProjectSpecificationType]; + +/** One documented target form. */ +export type TargetForm = { + /** Syntax shown in the docs table (Markdown). */ + readonly syntax: string; + /** Example value; `undefined` means the argument is omitted. */ + readonly example: string | undefined; + /** Whether the example needs `parseOrgProjectArg(..., { multi: true })`. */ + readonly multi?: boolean; + /** Parse result type the example must produce. */ + readonly type: SpecificationType; + /** What the form selects (Markdown). */ + readonly meaning: string; +}; + +export const TARGET_FORMS: readonly TargetForm[] = [ + { + syntax: "_(omitted)_", + example: undefined, + type: "auto-detect", + meaning: + "Auto-detect the org and project from config, DSNs, or the directory name", + }, + { + syntax: "`/`", + example: "my-org/my-project", + type: "explicit", + meaning: "That project in that organization", + }, + { + syntax: "`/,`", + example: "my-org/web,api", + multi: true, + type: "explicit", + meaning: + "Several projects in the same organization (commands that accept several projects)", + }, + { + syntax: "`/`", + example: "my-org/", + type: "org-all", + meaning: "The whole organization", + }, + { + syntax: "``", + example: "my-project", + type: "project-search", + meaning: + "A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization", + }, +]; diff --git a/packages/cli/test/lib/detect-agent.test.ts b/packages/cli/test/lib/detect-agent.test.ts index 7e80e79c3..c6f010a00 100644 --- a/packages/cli/test/lib/detect-agent.test.ts +++ b/packages/cli/test/lib/detect-agent.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "vitest"; import { AGENT_ALIASES, + AGENT_DISPLAY_NAMES, type AgentInfo, detectAgent, detectAgentFromProcessTree, @@ -645,6 +646,32 @@ describe("AGENT_ALIASES map structure", () => { }); }); +describe("AGENT_DISPLAY_NAMES", () => { + // The agentic-usage docs list is generated from AGENT_DISPLAY_NAMES, so it + // must name exactly the agents detectAgent() can return. + const detectable = new Set([ + ...ENV_VAR_AGENTS.values(), + ...PROCESS_NAME_AGENTS.values(), + ...AGENT_ALIASES.values(), + // Branches in detectAgent() that are not table-driven + "claude", + "cowork", + "cursor", + ]); + + test("names every detectable agent", () => { + const missing = [...detectable].filter((a) => !AGENT_DISPLAY_NAMES.has(a)); + expect(missing).toEqual([]); + }); + + test("names only detectable agents", () => { + const extra = [...AGENT_DISPLAY_NAMES.keys()].filter( + (a) => !detectable.has(a), + ); + expect(extra).toEqual([]); + }); +}); + describe("getProcessInfoFromOS", () => { test("returns info for own process", async () => { const info = await getProcessInfoFromOS(process.pid); diff --git a/packages/cli/test/lib/target-syntax.test.ts b/packages/cli/test/lib/target-syntax.test.ts new file mode 100644 index 000000000..195e610b4 --- /dev/null +++ b/packages/cli/test/lib/target-syntax.test.ts @@ -0,0 +1,29 @@ +/** + * The generated target syntax docs come from TARGET_FORMS. These tests keep + * that table honest against the real parser. + */ + +import { describe, expect, test } from "vitest"; +import { + ProjectSpecificationType, + parseOrgProjectArg, +} from "../../src/lib/arg-parsing.js"; +import { TARGET_FORMS } from "../../src/lib/target-syntax.js"; + +describe("TARGET_FORMS", () => { + test.each(TARGET_FORMS.map((form) => [form.syntax, form] as const))( + "%s example parses to the documented type", + (_syntax, form) => { + const parsed = parseOrgProjectArg(form.example, { multi: form.multi }); + expect(parsed.type).toBe(form.type); + }, + ); + + test("documents every parse result type", () => { + const documented = new Set(TARGET_FORMS.map((form) => form.type)); + const missing = Object.values(ProjectSpecificationType).filter( + (type) => !documented.has(type), + ); + expect(missing).toEqual([]); + }); +}); From 880bad04bc49d64f275da7b986e5e0906f4e41e7 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:01:24 +0000 Subject: [PATCH 2/7] ref(cli): Derive doc guards from code instead of tests Address review feedback on the docs drift guards: - Type agent tables by `KnownAgent` (keys of AGENT_DISPLAY_NAMES), so a detectable agent without a display name is a compile error. Drop the map-structure test. - Key TARGET_FORMS by parse result type so TypeScript enforces that every type is documented, and have the generator parse each example and fail on a mismatch. Drop target-syntax.test.ts. - Check only structured `docs.examples`, not examples hand-written into `fullDescription`. - Scan the install scripts in check:env-coverage so installer variables must be in ENV_VAR_REGISTRY (and therefore configuration.md). Add the missing SENTRY_NO_MODIFY_PATH. check:installer-docs now covers only installer help text and flags. - Replace the pnpm-specific `pnpm run cli --` lint with a dev-only preload that drops a leading `--`, so the dev scripts work with any package manager. - Add check:destructive: derive SDK DELETE operations from @sentry/api, follow calls, imports, and barrel re-exports through src/, and fail when a command that sends a DELETE is not marked destructive. Co-Authored-By: BYK --- packages/cli/package.json | 7 +- .../cli/script/check-destructive-commands.ts | 324 ++++++++++++++++++ packages/cli/script/check-doc-examples.ts | 19 +- packages/cli/script/check-env-coverage.ts | 57 ++- packages/cli/script/check-installer-docs.ts | 17 +- packages/cli/script/check-stale-references.ts | 52 +-- packages/cli/script/generate-docs-sections.ts | 22 +- packages/cli/script/strip-arg-separator.mjs | 14 + packages/cli/src/lib/command.ts | 6 +- packages/cli/src/lib/detect-agent.ts | 74 ++-- packages/cli/src/lib/env-registry.ts | 7 + packages/cli/src/lib/target-syntax.ts | 90 ++--- packages/cli/test/lib/detect-agent.test.ts | 27 -- packages/cli/test/lib/target-syntax.test.ts | 29 -- 14 files changed, 525 insertions(+), 220 deletions(-) create mode 100644 packages/cli/script/check-destructive-commands.ts create mode 100644 packages/cli/script/strip-arg-separator.mjs delete mode 100644 packages/cli/test/lib/target-syntax.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index e86de3061..a63b8a3b3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -56,8 +56,8 @@ }, "scripts": { "tsx": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs", - "cli": "node script/ensure-generated.mjs && tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs src/bin.ts", - "dev": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && tsx --import ./script/require-shim.mjs src/bin.ts", + "cli": "node script/ensure-generated.mjs && tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs --import ./script/strip-arg-separator.mjs src/bin.ts", + "dev": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && tsx --import ./script/require-shim.mjs --import ./script/strip-arg-separator.mjs src/bin.ts", "build": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single", "build:all": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts", "bundle": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle.ts", @@ -91,7 +91,8 @@ "check:stale-refs": "pnpm tsx script/check-stale-references.ts", "check:doc-examples": "pnpm tsx script/check-doc-examples.ts", "check:installer-docs": "pnpm tsx script/check-installer-docs.ts", - "ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs && pnpm run check:doc-examples && pnpm run check:installer-docs" + "check:destructive": "pnpm tsx script/check-destructive-commands.ts", + "ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs && pnpm run check:doc-examples && pnpm run check:installer-docs && pnpm run check:destructive" }, "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/packages/cli/script/check-destructive-commands.ts b/packages/cli/script/check-destructive-commands.ts new file mode 100644 index 000000000..12d61a5a4 --- /dev/null +++ b/packages/cli/script/check-destructive-commands.ts @@ -0,0 +1,324 @@ +#!/usr/bin/env tsx +/** + * Commands that send an HTTP DELETE must be marked destructive. + * + * The agent Safety Rules list destructive commands from command metadata + * (`buildDeleteCommand`, or `buildCommand({ destructive: true })`). This check + * derives the commands that can delete something from the code instead of + * trusting that metadata: + * + * 1. DELETE operations come from the `@sentry/api` SDK itself (operations + * built on `client.delete(...)`), plus raw requests with + * `method: "DELETE"`. + * 2. A function in `src/` that calls one, directly or through other + * functions, imports, and barrel re-exports, deletes too. + * 3. A command module that uses a deleting function must be built with + * `buildDeleteCommand` or pass `destructive: true`. + * + * Only DELETE is inferred. POST and PUT also create, update, and restore, so + * irreversible non-DELETE operations (`issue merge`, `trial start`) keep an + * explicit `destructive: true`. + * + * Usage: + * tsx script/check-destructive-commands.ts + * + * Exit codes: + * 0 - Every command that can send a DELETE is marked destructive + * 1 - A command sends a DELETE but is not marked destructive + */ + +import { readFile } from "node:fs/promises"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { glob } from "tinyglobby"; +import ts from "typescript"; + +/** `var deleteProject = (options) => (options.client ?? client).delete({` */ +const SDK_DELETE_OP_RE = + /\bvar (\w+) = \(options\) => \(options\.client \?\? client\)\.delete\(/g; + +const SDK_MODULE = "@sentry/api"; + +/** Where a local name comes from: a module path (or the SDK) and its export. */ +type Binding = { module: string; name: string }; + +type Module = { + /** Import bindings: local name → source. */ + imports: Map; + /** `export { a as b } from "./x.js"`: exported name → source. */ + reExports: Map; + /** `export * from "./x.js"` targets. */ + starExports: string[]; + /** Top-level functions: name → identifiers they reference. */ + functions: Map>; + /** Top-level functions that issue a raw `method: "DELETE"` request. */ + rawDelete: Set; + /** Every identifier in the file. */ + identifiers: Set; + /** Built with `buildDeleteCommand` or `destructive: true`. */ + markedDestructive: boolean; +}; + +async function loadSdkDeleteOps(): Promise> { + const entry = fileURLToPath(import.meta.resolve(SDK_MODULE)); + const source = await readFile(entry, "utf-8"); + const ops = new Set([...source.matchAll(SDK_DELETE_OP_RE)].map((m) => m[1])); + if (ops.size === 0) { + throw new Error( + `No DELETE operations found in ${entry}; the SDK build format changed. Update SDK_DELETE_OP_RE.`, + ); + } + return ops as Set; +} + +function resolveSpecifier(file: string, specifier: string): string | null { + if (specifier === SDK_MODULE) { + return SDK_MODULE; + } + if (!specifier.startsWith(".")) { + return null; + } + return relative( + process.cwd(), + resolve(dirname(file), specifier.replace(/\.js$/, ".ts")), + ); +} + +function collectIdentifiers(node: ts.Node, into: Set): void { + if (ts.isIdentifier(node)) { + into.add(node.text); + } + node.forEachChild((child) => collectIdentifiers(child, into)); +} + +function hasRawDelete(node: ts.Node): boolean { + if ( + ts.isPropertyAssignment(node) && + node.name.getText() === "method" && + ts.isStringLiteral(node.initializer) && + node.initializer.text === "DELETE" + ) { + return true; + } + return node.forEachChild(hasRawDelete) ?? false; +} + +function isMarkedDestructive(node: ts.Node): boolean { + if ( + ts.isCallExpression(node) && + node.expression.getText() === "buildDeleteCommand" + ) { + return true; + } + if ( + ts.isPropertyAssignment(node) && + node.name.getText() === "destructive" && + node.initializer.kind === ts.SyntaxKind.TrueKeyword + ) { + return true; + } + return node.forEachChild(isMarkedDestructive) ?? false; +} + +/** Name and body of a top-level function declaration or `const f = () => …`. */ +function topLevelFunctions(statement: ts.Statement): [string, ts.Node][] { + if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) { + return [[statement.name.text, statement.body]]; + } + if (!ts.isVariableStatement(statement)) { + return []; + } + return statement.declarationList.declarations.flatMap( + (d): [string, ts.Node][] => + ts.isIdentifier(d.name) && + d.initializer && + (ts.isArrowFunction(d.initializer) || + ts.isFunctionExpression(d.initializer)) + ? [[d.name.text, d.initializer]] + : [], + ); +} + +function parseModule(file: string, source: string): Module { + const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); + const mod: Module = { + imports: new Map(), + reExports: new Map(), + starExports: [], + functions: new Map(), + rawDelete: new Set(), + identifiers: new Set(), + markedDestructive: isMarkedDestructive(sf), + }; + collectIdentifiers(sf, mod.identifiers); + + for (const statement of sf.statements) { + if ( + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + const module = resolveSpecifier(file, statement.moduleSpecifier.text); + const bindings = statement.importClause?.namedBindings; + if (module && bindings && ts.isNamedImports(bindings)) { + for (const el of bindings.elements) { + const name = (el.propertyName ?? el.name).text; + mod.imports.set(el.name.text, { module, name }); + } + } + continue; + } + if ( + ts.isExportDeclaration(statement) && + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + const module = resolveSpecifier(file, statement.moduleSpecifier.text); + if (!module) { + continue; + } + const clause = statement.exportClause; + if (!clause) { + mod.starExports.push(module); + } else if (ts.isNamedExports(clause)) { + for (const el of clause.elements) { + const name = (el.propertyName ?? el.name).text; + mod.reExports.set(el.name.text, { module, name }); + } + } + continue; + } + for (const [name, body] of topLevelFunctions(statement)) { + const refs = new Set(); + collectIdentifiers(body, refs); + mod.functions.set(name, refs); + if (hasRawDelete(body)) { + mod.rawDelete.add(name); + } + } + } + return mod; +} + +const sdkDeleteOps = await loadSdkDeleteOps(); +const files = await glob("src/**/*.ts", { + ignore: ["src/generated/**", "**/*.test.ts"], +}); +const modules = new Map(); +for (const file of files) { + modules.set(file, parseModule(file, await readFile(file, "utf-8"))); +} + +/** `file#name` → a human-readable chain ending at the DELETE. */ +const deleting = new Map(); + +/** Whether `name` exported from `module` deletes; returns its chain. */ +function exportDeletes( + module: string, + name: string, + seen = new Set(), +): string | undefined { + if (module === SDK_MODULE) { + return sdkDeleteOps.has(name) ? `${SDK_MODULE} ${name}` : undefined; + } + const key = `${module}#${name}`; + if (deleting.has(key)) { + return deleting.get(key); + } + const mod = modules.get(module); + if (!mod || seen.has(key)) { + return; + } + seen.add(key); + const reExport = mod.reExports.get(name); + if (reExport) { + return exportDeletes(reExport.module, reExport.name, seen); + } + for (const target of mod.starExports) { + const chain = exportDeletes(target, name, seen); + if (chain) { + return chain; + } + } + return; +} + +/** Whether identifier `ref` used in `file` refers to something that deletes. */ +function refDeletes( + file: string, + mod: Module, + ref: string, +): string | undefined { + const local = deleting.get(`${file}#${ref}`); + if (local) { + return local; + } + const binding = mod.imports.get(ref); + return binding ? exportDeletes(binding.module, binding.name) : undefined; +} + +// Fixed point: propagate "deletes" through calls until nothing changes. +let changed = true; +while (changed) { + changed = false; + for (const [file, mod] of modules) { + for (const [name, refs] of mod.functions) { + const key = `${file}#${name}`; + if (deleting.has(key)) { + continue; + } + if (mod.rawDelete.has(name)) { + deleting.set(key, `${key} (method: "DELETE")`); + changed = true; + continue; + } + for (const ref of refs) { + const chain = ref === name ? undefined : refDeletes(file, mod, ref); + if (chain) { + deleting.set(key, `${key} → ${chain}`); + changed = true; + break; + } + } + } + } +} + +const problems: string[] = []; +let deletingCommands = 0; +for (const [file, mod] of modules) { + if (!file.startsWith("src/commands/")) { + continue; + } + let chain: string | undefined; + for (const ref of mod.identifiers) { + chain = refDeletes(file, mod, ref); + if (chain) { + break; + } + } + if (!chain) { + continue; + } + deletingCommands += 1; + if (!mod.markedDestructive) { + problems.push(`${file} sends a DELETE via ${chain}`); + } +} + +if (problems.length === 0) { + console.log( + `✓ All ${deletingCommands} command module(s) that send a DELETE are marked destructive`, + ); + process.exit(0); +} + +console.error( + `✗ ${problems.length} command(s) send a DELETE but are not marked destructive:\n`, +); +for (const problem of problems) { + console.error(` ${problem}`); +} +console.error( + "\n Build them with buildDeleteCommand, or pass `destructive: true` to buildCommand.", +); +process.exit(1); diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts index 4bd88315f..a09b48a69 100644 --- a/packages/cli/script/check-doc-examples.ts +++ b/packages/cli/script/check-doc-examples.ts @@ -3,8 +3,8 @@ * Check `sentry …` examples in hand-written docs against the live route tree. * * Every `sentry` invocation in a fenced shell block or inline code span, and - * every example embedded in a command (`docs.examples` and `fullDescription`, - * which feed `--help` and the generated command pages), must: + * every structured command example (`docs.examples`, which feeds `--help` and + * the generated command pages), must: * 1. Name a command that exists (e.g. `sentry upgrade` → `sentry cli upgrade`) * 2. Use only flags (and single-letter aliases) that command defines * 3. Not pass a bare org-looking slug (`my-org`) as a target. A bare name @@ -282,8 +282,9 @@ const files = [ ]; /** - * Examples embedded in commands: `docs.examples` and `sentry …` lines in - * `fullDescription`. These feed `--help` and the generated command pages. + * Structured command examples (`docs.examples`). These feed `--help` and the + * generated command pages. Examples hand-written into `fullDescription` are + * not checked; they belong in `docs.examples`. */ function commandExamples( target: RouteTarget, @@ -299,15 +300,9 @@ function commandExamples( return []; } const source = `\`${["sentry", ...path].join(" ")}\` help`; - const fromDocs = (target.__examples ?? []).map((e) => e.command); - const fromDescription = (target.fullDescription ?? "") - .replace(CONTINUATION_RE, " ") - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("sentry ")); - return [...fromDocs, ...fromDescription].map((invocation) => ({ + return (target.__examples ?? []).map((e) => ({ source, - invocation, + invocation: e.command, })); } diff --git a/packages/cli/script/check-env-coverage.ts b/packages/cli/script/check-env-coverage.ts index 01234b6e8..dfef7e506 100644 --- a/packages/cli/script/check-env-coverage.ts +++ b/packages/cli/script/check-env-coverage.ts @@ -2,10 +2,10 @@ /** * Environment variable documentation coverage. * - * Scans the source tree for environment variables the CLI reads at runtime and - * fails if any user-facing variable is missing from `ENV_VAR_REGISTRY` - * (src/lib/env-registry.ts), which is the single source of truth for the - * generated `configuration.md` docs. + * Scans the source tree and the install scripts (`install`, `install.ps1`) for + * environment variables they read and fails if any user-facing variable is + * missing from `ENV_VAR_REGISTRY` (src/lib/env-registry.ts), which is the + * single source of truth for the generated `configuration.md` docs. * * This closes the class of "code reads an env var but the docs never mention it" * bugs: a new `getEnv().SENTRY_FOO` read forces a deliberate choice — either @@ -20,7 +20,8 @@ * tsx script/check-env-coverage.ts * * Exit codes: - * 0 - Every referenced user-facing env var is documented or explicitly internal + * 0 - Every user-facing env var read by the CLI or an installer is documented + * or explicitly internal * 1 - Coverage gap, redundant allowlist entry, or stale allowlist entry */ @@ -141,8 +142,51 @@ for (const [file, content] of fileContents) { } } +// --------------------------------------------------------------------------- +// Install scripts: they run before the CLI exists, so src/ never reads their +// variables. Script-local assignments (`SENTRY_DSN_KEY="..."`) are constants, +// not inputs. +// --------------------------------------------------------------------------- + +const INSTALLERS: readonly { file: string; read: RegExp; assign?: RegExp }[] = [ + { + file: "install", + read: /\$\{?(SENTRY_[A-Z0-9_]+)/g, + assign: /^\s*(?:local\s+)?(SENTRY_[A-Z0-9_]+)=/gm, + }, + { file: "install.ps1", read: /\$env:(SENTRY_[A-Z0-9_]+)/g }, +]; + +/** name -> first "file:line" where an installer reads it. */ +const installerReads = new Map(); + +for (const { file, read, assign } of INSTALLERS) { + const content = await readFile(file, "utf-8"); + const assigned = new Set( + assign ? [...content.matchAll(assign)].map((m) => m[1]) : [], + ); + for (const match of content.matchAll(read)) { + const name = match[1]; + if (!name || assigned.has(name) || installerReads.has(name)) { + continue; + } + const line = content.slice(0, match.index).split("\n").length; + installerReads.set(name, `${file}:${line}`); + } +} + const errors: string[] = []; +for (const [name, location] of installerReads) { + if (!registryNames.has(name)) { + errors.push( + `Undocumented installer env var: ${name} (read at ${location})\n` + + " Add it to ENV_VAR_REGISTRY in src/lib/env-registry.ts (with `installOnly: true`\n" + + " if the CLI itself never reads it) so it appears in configuration.md.", + ); + } +} + // A user-facing var read in code must be documented or explicitly internal. for (const [name, location] of referenced) { if (registryNames.has(name) || INTERNAL_ENV_VARS.has(name)) { @@ -183,6 +227,7 @@ if (errors.length > 0) { } console.log( - `✓ All ${referenced.size} user-facing env var(s) read in src/ are documented ` + + `✓ All ${referenced.size} user-facing env var(s) read in src/ and ` + + `${installerReads.size} read by the installers are documented ` + `(${registryNames.size} in registry, ${INTERNAL_ENV_VARS.size} internal).`, ); diff --git a/packages/cli/script/check-installer-docs.ts b/packages/cli/script/check-installer-docs.ts index d01defadf..2e59897cd 100644 --- a/packages/cli/script/check-installer-docs.ts +++ b/packages/cli/script/check-installer-docs.ts @@ -2,14 +2,16 @@ /** * Installer documentation coverage. * - * The install scripts (`install` for bash, `install.ps1` for PowerShell) read - * environment variables and flags that the CLI's ENV_VAR_REGISTRY never sees, - * so check-env-coverage.ts cannot catch them. This check closes that gap: + * The install scripts (`install` for bash, `install.ps1` for PowerShell) have + * their own help text and flags. This check keeps those in sync: * * 1. Every `SENTRY_*` variable an installer reads, and every flag the bash * installer parses, appears in that installer's own help text. - * 2. Every variable and flag in either installer's help text appears in the - * installation guide (getting-started.mdx). + * 2. Every flag the bash installer parses appears in the installation guide + * (getting-started.mdx). + * + * Installer environment variables are documented in configuration.md, which + * is generated from ENV_VAR_REGISTRY; check-env-coverage.ts enforces that. * * Usage: * tsx script/check-installer-docs.ts @@ -36,8 +38,6 @@ const BASH_USAGE_RE = /usage\(\) \{\n\s*cat <` comment-based help block in PowerShell. */ const PS_HELP_RE = /^<#([\s\S]*?)#>/; -/** Variable names listed in a help block. */ -const HELP_VAR_RE = /\b(SENTRY_[A-Z0-9_]+)\b/g; /** Flags handled by every CLI; not worth documenting in the guide. */ const UNDOCUMENTED_FLAGS = new Set(["--help"]); @@ -92,8 +92,7 @@ for (const { file, reads, flags, help } of installers) { problems.push(`${file} reads ${name} but its help text omits it`); } } - const documented = [...matchAll(HELP_VAR_RE, help), ...flags]; - for (const name of documented) { + for (const name of flags) { if (!(UNDOCUMENTED_FLAGS.has(name) || guide.includes(name))) { problems.push(`${file} supports ${name} but ${GUIDE} omits it`); } diff --git a/packages/cli/script/check-stale-references.ts b/packages/cli/script/check-stale-references.ts index cfb77ba17..4fda955b2 100644 --- a/packages/cli/script/check-stale-references.ts +++ b/packages/cli/script/check-stale-references.ts @@ -13,9 +13,6 @@ * `packageManager` field in package.json will automatically make this * check flag every `pnpm run` / `pnpm add` reference in dev docs. * - * It also flags `pnpm run cli -- ` in dev docs: pnpm passes the `--` - * through, so the CLI sees it as the end of options and fails. - * * Usage: * tsx script/check-stale-references.ts * @@ -178,60 +175,15 @@ function findOriginalLineNumber( return 0; } -// --------------------------------------------------------------------------- -// CLI scripts: `pnpm run cli -- --help` passes a literal `--` to the CLI, -// which then treats `--help` as a command name. Checked inside code blocks -// too, since that is where these commands live. -// --------------------------------------------------------------------------- - -/** package.json scripts that run the CLI entry point directly. */ -const cliScripts = Object.entries(pkg.scripts as Record) - .filter(([, command]) => command.includes("src/bin.ts")) - .map(([name]) => name); - -const CLI_SEPARATOR_RE = new RegExp( - `\\bpnpm(?:\\s+--filter\\s+\\S+)?\\s+(?:run\\s+)?(?:${cliScripts.join("|")})\\s+--(?=\\s|$)`, -); - -/** Docs that show how to run the CLI from a checkout. */ -const CLI_USAGE_FILES = [ - ...DOC_FILES, - "README.md", - "AGENTS.md", - "CONTRIBUTING.md", -]; - -for (const filePath of CLI_USAGE_FILES) { - let content: string; - try { - content = await readFile(filePath, "utf-8"); - } catch { - continue; - } - const lines = content.split("\n"); - for (let i = 0; i < lines.length; i++) { - const line = lines[i] ?? ""; - if (CLI_SEPARATOR_RE.test(line)) { - findings.push({ - file: filePath, - line: i + 1, - text: line.trim(), - reason: - "pnpm forwards a literal `--` to the CLI; pass arguments directly (`pnpm run cli --help`)", - }); - } - } -} - if (findings.length === 0) { console.log( - `✓ No stale references found (current PM: ${currentPM}, checked: ${FILES_TO_SCAN.length + CLI_USAGE_FILES.length} files)`, + `✓ No stale package manager references found (current: ${currentPM}, checked: ${FILES_TO_SCAN.length} files)`, ); process.exit(0); } console.error( - `✗ Found ${findings.length} stale reference(s) (current PM: ${currentPM}):\n`, + `✗ Found ${findings.length} stale package manager reference(s) (current PM: ${currentPM}):\n`, ); for (const f of findings) { console.error(` ${f.file}:${f.line}: ${f.text}`); diff --git a/packages/cli/script/generate-docs-sections.ts b/packages/cli/script/generate-docs-sections.ts index ad2abcd13..edfa991fa 100644 --- a/packages/cli/script/generate-docs-sections.ts +++ b/packages/cli/script/generate-docs-sections.ts @@ -42,6 +42,7 @@ if (!skillContentExists) { } import type { EnvVarEntry } from "../src/lib/env-registry.js"; +import type { TargetForm } from "../src/lib/target-syntax.js"; import type { RouteInfo, RouteMap } from "../src/lib/introspect.js"; const { routes } = await import("../src/app.js"); @@ -50,6 +51,7 @@ const { OAUTH_SCOPES } = await import("../src/lib/oauth.js"); const { ENV_VAR_REGISTRY } = await import("../src/lib/env-registry.js"); const { AGENT_DISPLAY_NAMES } = await import("../src/lib/detect-agent.js"); const { TARGET_FORMS } = await import("../src/lib/target-syntax.js"); +const { parseOrgProjectArg } = await import("../src/lib/arg-parsing.js"); const pkg = JSON.parse(await readFile("package.json", "utf-8")); const workspace = JSON.parse(await readFile("../../package.json", "utf-8")); @@ -594,7 +596,7 @@ function generateDestructiveCommands(allRoutes: RouteInfo[]): string { /** Generate the list of agents the CLI detects, from AGENT_DISPLAY_NAMES. */ function generateDetectedAgents(): string { - const names = [...AGENT_DISPLAY_NAMES.values()]; + const names: string[] = Object.values(AGENT_DISPLAY_NAMES); const last = names.pop(); return `The CLI detects ${names.join(", ")}, and ${last}.`; } @@ -603,11 +605,23 @@ function generateDetectedAgents(): string { // Section: Target Syntax (commands/index.md fragment, CONTRIBUTING.md) // --------------------------------------------------------------------------- -/** Generate the target syntax table from TARGET_FORMS. */ +/** + * Generate the target syntax table from TARGET_FORMS. Each example goes + * through the real parser, so generation fails when the docs and the parser + * disagree about what a form means. + */ function generateTargetSyntax(): string { const lines = ["| Target | Meaning |", "|--------|---------|"]; - for (const form of TARGET_FORMS) { - lines.push(`| ${form.syntax} | ${form.meaning} |`); + for (const [type, forms] of Object.entries(TARGET_FORMS)) { + for (const form of forms as readonly TargetForm[]) { + const parsed = parseOrgProjectArg(form.example, { multi: form.multi }); + if (parsed.type !== type) { + throw new Error( + `TARGET_FORMS: ${form.syntax} example ${JSON.stringify(form.example)} parses as "${parsed.type}", not "${type}"`, + ); + } + lines.push(`| ${form.syntax} | ${form.meaning} |`); + } } return lines.join("\n"); } diff --git a/packages/cli/script/strip-arg-separator.mjs b/packages/cli/script/strip-arg-separator.mjs new file mode 100644 index 000000000..210fac230 --- /dev/null +++ b/packages/cli/script/strip-arg-separator.mjs @@ -0,0 +1,14 @@ +/** + * Preload for the dev CLI scripts (`pnpm run cli`, `pnpm run dev`). + * + * npm needs `npm run cli -- --help` to forward arguments, but pnpm and Yarn + * forward the `--` too, so the CLI would see `-- --help` and treat `--help` as + * a positional. A leading `--` is never meaningful to the CLI itself, so drop + * it and let either habit work with any package manager. + * + * Dev scripts only; the built binary keeps its arguments unchanged. + */ + +if (process.argv[2] === "--") { + process.argv.splice(2, 1); +} diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index fd040f0ba..2aecc1d15 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -197,8 +197,10 @@ type LocalCommandBuilderArguments< * * Destructive commands are listed in the agent Safety Rules (generated from * this flag), so agents ask the user before running them. - * `buildDeleteCommand` sets this automatically; set it explicitly for other - * irreversible operations (e.g. `issue merge`, `trial start`). + * `buildDeleteCommand` sets this automatically, and `check:destructive` + * fails CI when a command that sends an HTTP DELETE lacks it. Set it + * explicitly for irreversible operations that use other verbs (e.g. + * `issue merge`, `trial start`). */ readonly destructive?: boolean; }; diff --git a/packages/cli/src/lib/detect-agent.ts b/packages/cli/src/lib/detect-agent.ts index 89c83fa47..d5c5ecd6b 100644 --- a/packages/cli/src/lib/detect-agent.ts +++ b/packages/cli/src/lib/detect-agent.ts @@ -10,8 +10,8 @@ * names for known agent executables. Runs as a non-blocking background * task so it never delays CLI startup. * - * To add a new agent, add entries to {@link ENV_VAR_AGENTS} and/or - * {@link PROCESS_NAME_AGENTS}. + * To add a new agent, add it to {@link AGENT_DISPLAY_NAMES}, then add entries + * to {@link ENV_VAR_AGENTS} and/or {@link PROCESS_NAME_AGENTS}. */ import { execFile } from "node:child_process"; @@ -31,11 +31,42 @@ export type AgentInfo = { role?: string; }; +/** + * Canonical agent name → display name, in documentation order. + * + * This is the list of agents the CLI can detect: {@link KnownAgent} is derived + * from its keys, and the detection tables below only accept known agents, so + * TypeScript rejects a detectable agent with no display name. The + * agentic-usage docs list is generated from it. + */ +export const AGENT_DISPLAY_NAMES = { + claude: "Claude Code", + cowork: "Claude Cowork", + cursor: "Cursor", + windsurf: "Windsurf", + "github-copilot": "GitHub Copilot", + gemini: "Gemini CLI", + codex: "OpenAI Codex", + antigravity: "Antigravity", + goose: "Goose", + amp: "Amp", + augment: "Augment", + opencode: "OpenCode", + cline: "Cline", + grok: "Grok", + kimi: "Kimi", + junie: "Junie", + openclaw: "OpenClaw", +} as const satisfies Record; + +/** Canonical name of an agent the CLI can detect. */ +export type KnownAgent = keyof typeof AGENT_DISPLAY_NAMES; + /** * Canonical name aliases — maps alternative agent identifiers to their * canonical family name. Checked after lowercasing. */ -export const AGENT_ALIASES = new Map([ +export const AGENT_ALIASES = new Map([ ["claude-code", "claude"], ["claudecode", "claude"], ["claude_code", "claude"], @@ -123,7 +154,7 @@ export function normalizeAgent(raw: string): AgentInfo | undefined { * Env var → agent name. Checked in insertion order — first match wins. * Each env var maps directly to the agent that sets it. */ -export const ENV_VAR_AGENTS = new Map([ +export const ENV_VAR_AGENTS = new Map([ // Cursor ["CURSOR_TRACE_ID", "cursor"], ["CURSOR_AGENT", "cursor"], @@ -170,7 +201,7 @@ export const ENV_VAR_AGENTS = new Map([ * Process executable basename (lowercase) → agent name. * Used when scanning the parent process tree as a fallback. */ -export const PROCESS_NAME_AGENTS = new Map([ +export const PROCESS_NAME_AGENTS = new Map([ ["cursor", "cursor"], ["claude", "claude"], ["goose", "goose"], @@ -182,34 +213,6 @@ export const PROCESS_NAME_AGENTS = new Map([ ["gemini", "gemini"], ]); -/** - * Canonical agent name → display name, in documentation order. - * - * The agentic-usage docs list is generated from this map, and a unit test - * fails when a detected agent (from {@link ENV_VAR_AGENTS}, - * {@link PROCESS_NAME_AGENTS}, {@link AGENT_ALIASES}, or the Claude/Cowork - * branch in {@link detectAgent}) has no entry here. - */ -export const AGENT_DISPLAY_NAMES = new Map([ - ["claude", "Claude Code"], - ["cowork", "Claude Cowork"], - ["cursor", "Cursor"], - ["windsurf", "Windsurf"], - ["github-copilot", "GitHub Copilot"], - ["gemini", "Gemini CLI"], - ["codex", "OpenAI Codex"], - ["antigravity", "Antigravity"], - ["goose", "Goose"], - ["amp", "Amp"], - ["augment", "Augment"], - ["opencode", "OpenCode"], - ["cline", "Cline"], - ["grok", "Grok"], - ["kimi", "Kimi"], - ["junie", "Junie"], - ["openclaw", "OpenClaw"], -]); - /** Max levels to walk up the process tree before giving up. */ const MAX_ANCESTOR_DEPTH = 5; @@ -278,7 +281,7 @@ export function detectAgent(): AgentInfo | undefined { // The role marker also exists outside agent execution; only this value // identifies an agent. Check before the map to retain Cursor's priority. if (env.CURSOR_EXTENSION_HOST_ROLE === "agent-exec") { - return normalizeAgent("cursor"); + return normalizeAgent("cursor" satisfies KnownAgent); } // 2. Table-driven env var check (Map iteration preserves insertion order). @@ -291,7 +294,8 @@ export function detectAgent(): AgentInfo | undefined { // 3. Claude Code / Cowork — requires branching logic, so not in the map if (env.CLAUDECODE || env.CLAUDE_CODE) { - return normalizeAgent(env.CLAUDE_CODE_IS_COWORK ? "cowork" : "claude"); + const agent: KnownAgent = env.CLAUDE_CODE_IS_COWORK ? "cowork" : "claude"; + return normalizeAgent(agent); } // 4. Lowest priority: generic AGENT fallback diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index 819266b9b..f033386a0 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -176,6 +176,13 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ example: "1", installOnly: true, }, + { + name: "SENTRY_NO_MODIFY_PATH", + description: + "Used with the PowerShell install script (`install.ps1`). When set to `1`, the installer leaves the user `PATH` unchanged. The bash install script takes the `--no-modify-path` flag instead.", + example: "1", + installOnly: true, + }, // -- Init wizard -- { name: "SENTRY_INIT_TUI", diff --git a/packages/cli/src/lib/target-syntax.ts b/packages/cli/src/lib/target-syntax.ts index ec61f4180..810f8ded1 100644 --- a/packages/cli/src/lib/target-syntax.ts +++ b/packages/cli/src/lib/target-syntax.ts @@ -1,15 +1,17 @@ /** - * Documented forms of the `/` target positional. + * Documented forms of the `/` target positional, keyed by the + * `parseOrgProjectArg()` result type each form produces. * * The target syntax tables in the commands overview and CONTRIBUTING.md are - * generated from this list (`script/generate-docs-sections.ts`), and - * `test/lib/target-syntax.test.ts` parses each example with - * `parseOrgProjectArg()` to check it yields the documented type. Change the - * parser and this list together. + * generated from this map (`script/generate-docs-sections.ts`). The key set is + * checked by TypeScript, so a new parse result type cannot ship undocumented, + * and the generator parses every example and fails when the parser returns a + * different type, so the table cannot disagree with the parser. */ import type { ProjectSpecificationType } from "./arg-parsing.js"; +/** A `parseOrgProjectArg()` result type. */ type SpecificationType = (typeof ProjectSpecificationType)[keyof typeof ProjectSpecificationType]; @@ -21,45 +23,47 @@ export type TargetForm = { readonly example: string | undefined; /** Whether the example needs `parseOrgProjectArg(..., { multi: true })`. */ readonly multi?: boolean; - /** Parse result type the example must produce. */ - readonly type: SpecificationType; /** What the form selects (Markdown). */ readonly meaning: string; }; -export const TARGET_FORMS: readonly TargetForm[] = [ - { - syntax: "_(omitted)_", - example: undefined, - type: "auto-detect", - meaning: - "Auto-detect the org and project from config, DSNs, or the directory name", - }, - { - syntax: "`/`", - example: "my-org/my-project", - type: "explicit", - meaning: "That project in that organization", - }, - { - syntax: "`/,`", - example: "my-org/web,api", - multi: true, - type: "explicit", - meaning: - "Several projects in the same organization (commands that accept several projects)", - }, - { - syntax: "`/`", - example: "my-org/", - type: "org-all", - meaning: "The whole organization", - }, - { - syntax: "``", - example: "my-project", - type: "project-search", - meaning: - "A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization", - }, -]; +/** Target forms by parse result type, in documentation order. */ +export const TARGET_FORMS = { + "auto-detect": [ + { + syntax: "_(omitted)_", + example: undefined, + meaning: + "Auto-detect the org and project from config, DSNs, or the directory name", + }, + ], + explicit: [ + { + syntax: "`/`", + example: "my-org/my-project", + meaning: "That project in that organization", + }, + { + syntax: "`/,`", + example: "my-org/web,api", + multi: true, + meaning: + "Several projects in the same organization (commands that accept several projects)", + }, + ], + "org-all": [ + { + syntax: "`/`", + example: "my-org/", + meaning: "The whole organization", + }, + ], + "project-search": [ + { + syntax: "``", + example: "my-project", + meaning: + "A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization", + }, + ], +} as const satisfies Record; diff --git a/packages/cli/test/lib/detect-agent.test.ts b/packages/cli/test/lib/detect-agent.test.ts index c6f010a00..7e80e79c3 100644 --- a/packages/cli/test/lib/detect-agent.test.ts +++ b/packages/cli/test/lib/detect-agent.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, test } from "vitest"; import { AGENT_ALIASES, - AGENT_DISPLAY_NAMES, type AgentInfo, detectAgent, detectAgentFromProcessTree, @@ -646,32 +645,6 @@ describe("AGENT_ALIASES map structure", () => { }); }); -describe("AGENT_DISPLAY_NAMES", () => { - // The agentic-usage docs list is generated from AGENT_DISPLAY_NAMES, so it - // must name exactly the agents detectAgent() can return. - const detectable = new Set([ - ...ENV_VAR_AGENTS.values(), - ...PROCESS_NAME_AGENTS.values(), - ...AGENT_ALIASES.values(), - // Branches in detectAgent() that are not table-driven - "claude", - "cowork", - "cursor", - ]); - - test("names every detectable agent", () => { - const missing = [...detectable].filter((a) => !AGENT_DISPLAY_NAMES.has(a)); - expect(missing).toEqual([]); - }); - - test("names only detectable agents", () => { - const extra = [...AGENT_DISPLAY_NAMES.keys()].filter( - (a) => !detectable.has(a), - ); - expect(extra).toEqual([]); - }); -}); - describe("getProcessInfoFromOS", () => { test("returns info for own process", async () => { const info = await getProcessInfoFromOS(process.pid); diff --git a/packages/cli/test/lib/target-syntax.test.ts b/packages/cli/test/lib/target-syntax.test.ts deleted file mode 100644 index 195e610b4..000000000 --- a/packages/cli/test/lib/target-syntax.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * The generated target syntax docs come from TARGET_FORMS. These tests keep - * that table honest against the real parser. - */ - -import { describe, expect, test } from "vitest"; -import { - ProjectSpecificationType, - parseOrgProjectArg, -} from "../../src/lib/arg-parsing.js"; -import { TARGET_FORMS } from "../../src/lib/target-syntax.js"; - -describe("TARGET_FORMS", () => { - test.each(TARGET_FORMS.map((form) => [form.syntax, form] as const))( - "%s example parses to the documented type", - (_syntax, form) => { - const parsed = parseOrgProjectArg(form.example, { multi: form.multi }); - expect(parsed.type).toBe(form.type); - }, - ); - - test("documents every parse result type", () => { - const documented = new Set(TARGET_FORMS.map((form) => form.type)); - const missing = Object.values(ProjectSpecificationType).filter( - (type) => !documented.has(type), - ); - expect(missing).toEqual([]); - }); -}); From d1fb35fac7c908e8423a90295bb5f230f7c3ee56 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:20:50 +0000 Subject: [PATCH 3/7] ref(cli): Guard lazy targets of every destructive command Follow-up to #1594: `buildCommand` now calls `markDestructive` when `destructive: true`, instead of `buildDeleteCommand` doing it, so the `destructive` flag is the single source for the Safety Rules list and the auto-detect guard. `issue merge` and `trial start` get the guard if they move to lazy target positionals. check:doc-examples now recognizes target positionals by their `org/project` completion kind, which covers the new lazy target parsers as well as `parseOrgProjectTarget`. --- packages/cli/AGENTS.md | 5 +++-- packages/cli/script/check-doc-examples.ts | 4 ++-- packages/cli/src/lib/command.ts | 13 +++++++++++-- packages/cli/src/lib/mutate-command.ts | 6 +----- packages/cli/src/lib/positional-parse.ts | 5 +++-- packages/cli/src/lib/target-positional.ts | 4 ++-- packages/cli/test/lib/positional-parse.test.ts | 15 ++++++++++++++- 7 files changed, 36 insertions(+), 16 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index be6583549..ecf363e18 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -253,8 +253,9 @@ one of these types and never reads `.resolved`. Resolvers need no command name: they read it from `this.commandPrefix`, which Stricli's `forCommand` and the SDK invoker set (test mock contexts set it -too). In a `buildDeleteCommand` command, the target refuses to auto-detect, so -delete commands don't call `requireExplicitTarget` for it. +too). In a destructive command (`buildDeleteCommand`, or `buildCommand` with +`destructive: true`), the target refuses to auto-detect, so those commands +don't call `requireExplicitTarget` for it. Both steps are lazy (`lazyPositional` in `src/lib/positional-parse.ts`): the parse runs on the first `parsed` read and the resolver on the first diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts index a09b48a69..a32e2c161 100644 --- a/packages/cli/script/check-doc-examples.ts +++ b/packages/cli/script/check-doc-examples.ts @@ -49,7 +49,7 @@ if (!skillContentExists) { const { routes } = await import("../src/app.js"); const { isCommand, isRouteMap } = await import("../src/lib/introspect.js"); -const { parseOrgProjectTarget } = +const { getCompletionTarget, ORG_PROJECT_TARGET } = await import("../src/lib/completion-target.js"); /** Generated from the route tree, so not worth re-checking. */ @@ -215,7 +215,7 @@ function isTargetParam(param: PositionalParam | undefined): boolean { return false; } return ( - param.parse === parseOrgProjectTarget || + getCompletionTarget(param.parse) === ORG_PROJECT_TARGET || TARGET_PLACEHOLDER_RE.test(param.placeholder ?? "") ); } diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 20da8b05c..c3df5aafc 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -65,7 +65,11 @@ import { parseLogLevel, setLogLevel, } from "./logger.js"; -import { applyLazyPositionals, rawPositional } from "./positional-parse.js"; +import { + applyLazyPositionals, + markDestructive, + rawPositional, +} from "./positional-parse.js"; import { setArgsContext, setFlagContext, withTracing } from "./telemetry.js"; import { applyTipsPreference } from "./tips.js"; @@ -197,7 +201,8 @@ type LocalCommandBuilderArguments< * Mark the command as destructive or irreversible. Defaults to `false`. * * Destructive commands are listed in the agent Safety Rules (generated from - * this flag), so agents ask the user before running them. + * this flag), so agents ask the user before running them, and their lazy + * target positionals refuse an auto-detected target. * `buildDeleteCommand` sets this automatically, and `check:destructive` * fails CI when a command that sends an HTTP DELETE lacks it. Set it * explicitly for irreversible operations that use other verbs (e.g. @@ -732,6 +737,10 @@ export function buildCommand< existingParams.positional, this, ); + // Lazy target positionals of destructive commands refuse to auto-detect + if (builderArgs.destructive) { + markDestructive(args); + } applyLoggingFlags( flags[LOG_LEVEL_KEY] as LogLevelName | undefined, diff --git a/packages/cli/src/lib/mutate-command.ts b/packages/cli/src/lib/mutate-command.ts index edfa02ead..baed2da39 100644 --- a/packages/cli/src/lib/mutate-command.ts +++ b/packages/cli/src/lib/mutate-command.ts @@ -23,7 +23,6 @@ import { buildCommand, type CommandDocumentation } from "./command.js"; import { CliError, ContextError } from "./errors.js"; import type { CommandReturn } from "./formatters/output.js"; import { logger } from "./logger.js"; -import { markDestructive } from "./positional-parse.js"; // --------------------------------------------------------------------------- // Level A: shared flag / alias definitions @@ -296,7 +295,7 @@ export type DeleteCommandOptions = { * stdin is not a TTY and neither `--yes`/`--force` was passed (dry-run * bypasses the guard since it makes no changes) * 4. Marks the command `destructive`, which lists it in the generated agent - * Safety Rules + * Safety Rules and makes its lazy target positionals refuse auto-detect * * Commands still handle their own confirmation prompts using * {@link confirmByTyping} + {@link isConfirmationBypassed} inside `func()`, @@ -402,9 +401,6 @@ export function buildDeleteCommand< ); } - // Lazy target positionals refuse to auto-detect when they resolve - markDestructive(args); - return originalFunc.call(this, flags, ...(args as unknown as ARGS)); } as typeof originalFunc; diff --git a/packages/cli/src/lib/positional-parse.ts b/packages/cli/src/lib/positional-parse.ts index b91c5fe52..aec33f5c8 100644 --- a/packages/cli/src/lib/positional-parse.ts +++ b/packages/cli/src/lib/positional-parse.ts @@ -29,8 +29,9 @@ * * Shared code that handles any positional (plural alias interception, `help` * recovery, telemetry) reads the raw input with {@link rawPositional}. - * `buildDeleteCommand` calls {@link markDestructive} on its positionals, so - * resolvers see `destructive: true` and can refuse to auto-detect. + * `buildCommand` calls {@link markDestructive} on the positionals of + * destructive commands (`destructive: true`, which `buildDeleteCommand` sets), + * so resolvers see `destructive: true` and can refuse to auto-detect. */ import { diff --git a/packages/cli/src/lib/target-positional.ts b/packages/cli/src/lib/target-positional.ts index c73f85c0a..47ce9a6cc 100644 --- a/packages/cli/src/lib/target-positional.ts +++ b/packages/cli/src/lib/target-positional.ts @@ -11,8 +11,8 @@ * * Resolution gets the command path from `commandPrefix`, which Stricli's * `forCommand` (and the SDK invoker) set on the context, so usage hints need - * no hand-written command name. Destructive commands (`buildDeleteCommand`) - * refuse an auto-detected target. + * no hand-written command name. Destructive commands (`destructive: true`, + * which `buildDeleteCommand` sets) refuse an auto-detected target. */ import { type ParsedOrgProject, parseOrgProjectArg } from "./arg-parsing.js"; diff --git a/packages/cli/test/lib/positional-parse.test.ts b/packages/cli/test/lib/positional-parse.test.ts index 0103e0536..3899744f9 100644 --- a/packages/cli/test/lib/positional-parse.test.ts +++ b/packages/cli/test/lib/positional-parse.test.ts @@ -49,7 +49,10 @@ const echoTarget = orgProjectTarget(echoResolver); type EchoTarget = OrgProjectTarget>>; /** Run `test cmd ...args`, recording what `target.resolved` yields. */ -async function runWithTarget(args: string[]): Promise<{ +async function runWithTarget( + args: string[], + { destructive = false } = {}, +): Promise<{ resolved: unknown[]; stderr: string; exitCode: number | undefined; @@ -62,6 +65,7 @@ async function runWithTarget(args: string[]): Promise<{ >({ docs: { brief: "Takes a target" }, auth: false, + destructive, parameters: { positional: { kind: "tuple", @@ -127,6 +131,15 @@ describe("orgProjectTarget", () => { ]); }); + test("a destructive: true command refuses an auto-detected target", async () => { + const { resolved, stderr, exitCode } = await runWithTarget([], { + destructive: true, + }); + expect(resolved).toEqual([]); + expect(exitCode).toBe(EXIT.CONTEXT_MISSING); + expect(stderr).toContain("Auto-detection is disabled"); + }); + test("invalid input fails inside the command with the CliError exit code", async () => { const { resolved, stderr, exitCode } = await runWithTarget(["@latest"]); expect(resolved).toEqual([]); From eedb5bbd337164404ec831039febdb41889b7136 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:23:42 +0000 Subject: [PATCH 4/7] ref(cli): Define the org/project target syntax as a PEG grammar Replace the hand-written `/` parser with a peggy grammar (packages/cli/grammar/org-project-target.pegjs). parseOrgProjectArg keeps its signature and still handles blank input and Sentry URLs; character validation goes through options hooks so error messages are unchanged. The target syntax tables are now generated from `@form` annotations in the grammar, and generation fails when a `target` alternative is unannotated. This replaces TARGET_FORMS and documents the forms it missed: Sentry URLs, `/`, and display names with and without an org. The parser generator moves to scripts/generate-parsers.mjs so toolkit-core and the CLI share it, check:generated, and the pre-commit hook. Closes #1637 Co-Authored-By: BYK --- .oxfmtrc.json | 1 + .oxlintrc.json | 1 + apps/cli-docs/src/fragments/commands/index.md | 4 + package.json | 4 +- packages/cli/.gitignore | 5 +- packages/cli/CONTRIBUTING.md | 6 +- packages/cli/grammar/org-project-target.pegjs | 114 +++ packages/cli/package.json | 1 + packages/cli/script/generate-docs-sections.ts | 99 +- .../generated/org-project-target-grammar.d.ts | 31 + .../generated/org-project-target-grammar.js | 925 ++++++++++++++++++ packages/cli/src/lib/arg-parsing.ts | 213 +--- packages/cli/src/lib/target-syntax.ts | 69 -- packages/toolkit-core/README.md | 4 +- packages/toolkit-core/package.json | 2 - .../toolkit-core/script/generate-parsers.ts | 88 -- .../src/generated/search-syntax-grammar.d.ts | 2 +- .../src/generated/search-syntax-grammar.js | 2 +- pnpm-lock.yaml | 9 +- scripts/generate-parsers.mjs | 121 +++ scripts/pre-commit-generated.mjs | 10 +- 21 files changed, 1322 insertions(+), 389 deletions(-) create mode 100644 packages/cli/grammar/org-project-target.pegjs create mode 100644 packages/cli/src/generated/org-project-target-grammar.d.ts create mode 100644 packages/cli/src/generated/org-project-target-grammar.js delete mode 100644 packages/cli/src/lib/target-syntax.ts delete mode 100644 packages/toolkit-core/script/generate-parsers.ts create mode 100644 scripts/generate-parsers.mjs diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 4e98fd9c6..d155bf0ff 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -19,6 +19,7 @@ "**/worker-configuration.d.ts", "**/tsconfig*.json", "packages/toolkit-core/src/generated/**", + "packages/cli/src/generated/**", "packages/toolkit-core/test/fixtures/**" ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 4848b6473..e20d1e687 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -13,6 +13,7 @@ "**/worker-configuration.d.ts", "**/tsconfig*.json", "packages/toolkit-core/src/generated/**", + "packages/cli/src/generated/**", "packages/toolkit-core/test/fixtures/**", "packages/mcp-server-mocks/src/fixtures/**", "packages/cli/test/init-eval/templates/sveltekit-app/**" diff --git a/apps/cli-docs/src/fragments/commands/index.md b/apps/cli-docs/src/fragments/commands/index.md index 9748a71f0..2688bbb6b 100644 --- a/apps/cli-docs/src/fragments/commands/index.md +++ b/apps/cli-docs/src/fragments/commands/index.md @@ -17,10 +17,14 @@ Many commands take an optional `/` target as their first positiona | Target | Meaning | |--------|---------| | _(omitted)_ | Auto-detect the org and project from config, DSNs, or the directory name | +| `https://.sentry.io/...` | A Sentry web URL. Selects the org and project in the URL, or the whole organization when it names no project | | `/` | That project in that organization | | `/,` | Several projects in the same organization (commands that accept several projects) | +| `/` | A project in that organization whose display name matches. A target with a space is a name, not a slug | | `/` | The whole organization | | `` | A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization | +| `/` | Same as `` | +| `` | A project whose display name matches, in any accessible organization | ## JSON Output diff --git a/package.json b/package.json index 05cb298fc..d5883b7c0 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "dev:stdio": "pnpm --filter '@sentry/mcp-server...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --parallel --filter @sentry/mcp-server --filter @sentry/mcp-core --filter @sentry/mcp-server-mocks --if-present run dev", "build": "dotenv -e .env -e .env.local -- pnpm -r --filter '!sentry' --filter '!sentry-cli-docs' --if-present run build", "build:cli": "dotenv -e .env -e .env.local -- pnpm --filter sentry run build && pnpm --filter sentry-cli-docs run build", - "check:generated": "pnpm --filter @sentry/mcp-core generate-definitions && pnpm --filter @sentry/toolkit-core generate && git diff --exit-code -- packages/mcp-core/src/toolDefinitions.json packages/mcp-core/src/skillDefinitions.json plugins/sentry-mcp/agents/sentry-mcp.md plugins/sentry-mcp-experimental/agents/sentry-mcp.md packages/toolkit-core/src/generated", + "check:generated": "pnpm --filter @sentry/mcp-core generate-definitions && pnpm run generate:parsers && git diff --exit-code -- packages/mcp-core/src/toolDefinitions.json packages/mcp-core/src/skillDefinitions.json plugins/sentry-mcp/agents/sentry-mcp.md plugins/sentry-mcp-experimental/agents/sentry-mcp.md packages/toolkit-core/src/generated packages/cli/src/generated/org-project-target-grammar.js packages/cli/src/generated/org-project-target-grammar.d.ts", + "generate:parsers": "node scripts/generate-parsers.mjs", "deploy:docs": "pnpm --filter sentry run generate:schema && pnpm --filter sentry run generate:docs && pnpm --filter sentry-cli-docs run build && pnpm --filter sentry-cli-docs run deploy", "eval": "pnpm --filter '@sentry/mcp-server-evals...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --filter @sentry/mcp-server-evals run eval", "eval:ci": "pnpm --filter '@sentry/mcp-server-evals...' --if-present run build && CI=true dotenv -e .env -e .env.local -- pnpm --stream -r run eval:ci", @@ -58,6 +59,7 @@ "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", + "peggy": "^5.1.0", "simple-git-hooks": "catalog:", "tsdown": "catalog:", "tsx": "catalog:", diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore index 9af50246e..3a7264eb1 100644 --- a/packages/cli/.gitignore +++ b/packages/cli/.gitignore @@ -35,7 +35,10 @@ test/skill-eval/results.json *.tsbuildinfo # generated files (rebuilt at build time) -src/generated/ +src/generated/* src/sdk.generated.ts src/sdk.generated.d.cts !src/generated/banner-sixel.ts +# committed: built by `pnpm run generate:parsers` from grammar/ +!src/generated/org-project-target-grammar.js +!src/generated/org-project-target-grammar.d.ts diff --git a/packages/cli/CONTRIBUTING.md b/packages/cli/CONTRIBUTING.md index 524dca951..5951b2020 100644 --- a/packages/cli/CONTRIBUTING.md +++ b/packages/cli/CONTRIBUTING.md @@ -16,16 +16,20 @@ sentry project list [org] [--limit N] [--json] sentry issue list [/] [--json] ``` -**Target syntax** (generated from `src/lib/target-syntax.ts`): +**Target syntax** (generated from `grammar/org-project-target.pegjs`): | Target | Meaning | |--------|---------| | _(omitted)_ | Auto-detect the org and project from config, DSNs, or the directory name | +| `https://.sentry.io/...` | A Sentry web URL. Selects the org and project in the URL, or the whole organization when it names no project | | `/` | That project in that organization | | `/,` | Several projects in the same organization (commands that accept several projects) | +| `/` | A project in that organization whose display name matches. A target with a space is a name, not a slug | | `/` | The whole organization | | `` | A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization | +| `/` | Same as `` | +| `` | A project whose display name matches, in any accessible organization | **Rationale**: Positional arguments follow `gh` CLI conventions and are more concise than flags. diff --git a/packages/cli/grammar/org-project-target.pegjs b/packages/cli/grammar/org-project-target.pegjs new file mode 100644 index 000000000..15f699b08 --- /dev/null +++ b/packages/cli/grammar/org-project-target.pegjs @@ -0,0 +1,114 @@ +// Grammar for the `[/][[,...]]` target positional. +// +// `parseOrgProjectArg()` (src/lib/arg-parsing.ts) runs this parser after it +// keeps the first non-blank line and handles the omitted and URL forms. The +// grammar owns the structure; character-level checks go through the +// `checkSlug`, `rejectAt`, and `invalid` hooks in `options`, so its errors +// match the rest of the CLI. +// +// The target syntax tables in the docs are generated from the `@form` lines +// here (script/generate-docs-sections.ts). Every `target` alternative needs +// at least one `@form` line, or `@invalid` when it only rejects input. A +// `@form` line reads `@form — `, both in Markdown; indented +// `//` lines continue the meaning. The two forms below never reach the +// grammar, so they are listed here: +// +// @form _(omitted)_ — Auto-detect the org and project from config, DSNs, or +// the directory name +// @form `https://.sentry.io/...` — A Sentry web URL. Selects the org and +// project in the URL, or the whole organization when it names no project + +{ + const { multi = false, checkSlug, rejectAt, invalid } = options; + + function slug(value, label) { + rejectAt(value, label); + checkSlug(value, label); + return value; + } + + function orgSlug(value) { + return slug(value, "organization slug"); + } + + function projectSlug(value) { + return slug(value, "project slug"); + } + + function displayName(value, org) { + rejectAt(value, "project slug"); + return { + type: "project-search", + projectSlug: value, + originalSlug: value, + ...(org !== undefined && { org }), + }; + } + + // Slugs cannot contain commas, so `org/web,api` is unambiguous. Tokens are + // trimmed, empty ones dropped, and duplicates removed in first-seen order. + function projectList(org, raw) { + const tokens = [...new Set(raw.split(",").map((t) => t.trim()))].filter( + (t) => t !== "", + ); + if (tokens.length === 0) { + invalid("Invalid project slug: comma-separated list is empty.", "project"); + } + if (tokens.some((t) => t.includes(" "))) { + invalid( + "Comma-separated project targets must be slugs, not display names.", + "project", + ); + } + const [first, second, ...others] = tokens.map(projectSlug); + return { + type: "explicit", + org, + project: first, + ...(second !== undefined && { projects: [first, second, ...others] }), + }; + } +} + +// Peggy runs a rule's action as soon as that rule matches, even when the +// enclosing alternative fails later and the parser backtracks. The token +// rules below therefore only capture text, and validation happens in the +// action of the `target` alternative that wins. Otherwise `@latest` would be +// rejected as an organization slug before it is parsed as a project (covered +// by "@latest throws with redirect to issue view" in arg-parsing.test.ts). +target + // @form `/` — That project in that organization + = o:org "/" p:slug !{ return multi && p.includes(","); } + { const org = orgSlug(o); return { type: "explicit", org, project: projectSlug(p) }; } + // @form `/,` — Several projects in the same + // organization (commands that accept several projects) + / o:org "/" &{ return multi; } l:list { return projectList(orgSlug(o), l); } + // @form `/` — A project in that organization whose + // display name matches. A target with a space is a name, not a slug + / o:org "/" p:name { const org = orgSlug(o); return displayName(p, org); } + // @form `/` — The whole organization + / o:org "/" !. { return { type: "org-all", org: orgSlug(o) }; } + // @form `` — A project named `` in any accessible organization. + // If no project matches, commands that accept an organization use the + // organization named ``. When both exist, the project wins: add the + // trailing slash (`/`) to select the organization + // @form `/` — Same as `` + / "/"? p:slug { return { type: "project-search", projectSlug: projectSlug(p) }; } + // @form `` — A project whose display name matches, in any + // accessible organization + / "/"? p:name { return displayName(p); } + // @invalid + / "/" !. { invalid('Invalid format: "/" requires a project slug (e.g., "/cli")'); } + +// Everything before the first slash. +org = $[^/]+ + +// The rest of the input when it has no spaces. Slashes, commas, and other +// characters are kept so `checkSlug` reports them. +slug = $([^ ]+ !.) + +// The rest of the input when it has a space: a display name. +name = $([^ ]* " " .*) + +// The rest of the input when it has a comma: a project list. +list = $([^,]* "," .*) diff --git a/packages/cli/package.json b/packages/cli/package.json index a63b8a3b3..61b8ce06e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -136,6 +136,7 @@ "marked": "^15.0.12", "node-sqlite3-wasm": "0.8.59", "p-limit": "^7.3.0", + "peggy": "^5.1.0", "picomatch": "^4.0.4", "pixelmatch": "^7.2.0", "pngjs": "^7.0.0", diff --git a/packages/cli/script/generate-docs-sections.ts b/packages/cli/script/generate-docs-sections.ts index edfa991fa..524cf4a1e 100644 --- a/packages/cli/script/generate-docs-sections.ts +++ b/packages/cli/script/generate-docs-sections.ts @@ -13,7 +13,8 @@ * - getting-started.mdx: platform support table * - agent-guidance.md: destructive commands (from `destructive` commands) * - agentic-usage.md: detected agents (from AGENT_DISPLAY_NAMES) - * - commands/index.md fragment, CONTRIBUTING.md: target syntax (TARGET_FORMS) + * - commands/index.md fragment, CONTRIBUTING.md: target syntax (from the + * `@form` lines in grammar/org-project-target.pegjs) * - plugins/README.md: skill file layout (paths checked on disk) * * Unlike generate-command-docs.ts (which produces gitignored files from scratch), @@ -26,6 +27,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; +import peggy from "peggy"; import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; // Bootstrap: skill-content stub (same pattern as generate-command-docs.ts) @@ -42,7 +44,6 @@ if (!skillContentExists) { } import type { EnvVarEntry } from "../src/lib/env-registry.js"; -import type { TargetForm } from "../src/lib/target-syntax.js"; import type { RouteInfo, RouteMap } from "../src/lib/introspect.js"; const { routes } = await import("../src/app.js"); @@ -50,9 +51,9 @@ const { extractAllRoutes } = await import("../src/lib/introspect.js"); const { OAUTH_SCOPES } = await import("../src/lib/oauth.js"); const { ENV_VAR_REGISTRY } = await import("../src/lib/env-registry.js"); const { AGENT_DISPLAY_NAMES } = await import("../src/lib/detect-agent.js"); -const { TARGET_FORMS } = await import("../src/lib/target-syntax.js"); -const { parseOrgProjectArg } = await import("../src/lib/arg-parsing.js"); const pkg = JSON.parse(await readFile("package.json", "utf-8")); +const TARGET_GRAMMAR = "grammar/org-project-target.pegjs"; +const targetGrammar = await readFile(TARGET_GRAMMAR, "utf-8"); const workspace = JSON.parse(await readFile("../../package.json", "utf-8")); const isCheck = process.argv.includes("--check"); @@ -605,25 +606,87 @@ function generateDetectedAgents(): string { // Section: Target Syntax (commands/index.md fragment, CONTRIBUTING.md) // --------------------------------------------------------------------------- +/** `// @form — ` or `// @invalid`. */ +const TARGET_ANNOTATION_RE = /^\s*\/\/ @(form|invalid)\b ?(.*)$/; +/** An indented `//` line continuing the annotation above it. */ +const TARGET_CONTINUATION_RE = /^\s*\/\/ {3}(\S.*)$/; +const COMMENT_LINE_RE = /^\s*\/\//; + +/** One annotation; `start` and `end` are 1-based line numbers. */ +type TargetAnnotation = { + start: number; + end: number; + kind: string; + text: string; +}; + +/** Collect the `@form` and `@invalid` annotations of a grammar. */ +function readTargetAnnotations(lines: string[]): TargetAnnotation[] { + const annotations: TargetAnnotation[] = []; + for (const [index, line] of lines.entries()) { + const annotation = TARGET_ANNOTATION_RE.exec(line); + const continuation = TARGET_CONTINUATION_RE.exec(line); + const previous = annotations.at(-1); + if (annotation) { + annotations.push({ + start: index + 1, + end: index + 1, + kind: annotation[1] ?? "", + text: annotation[2] ?? "", + }); + } else if (continuation && previous?.end === index) { + previous.text += ` ${continuation[1]}`; + previous.end = index + 1; + } + } + return annotations; +} + /** - * Generate the target syntax table from TARGET_FORMS. Each example goes - * through the real parser, so generation fails when the docs and the parser - * disagree about what a form means. + * Generate the target syntax table from the `@form` lines in the target + * grammar. Generation fails when an alternative of the `target` rule has no + * `@form` or `@invalid` line in the comments right above it, so a new form + * cannot ship undocumented. */ function generateTargetSyntax(): string { - const lines = ["| Target | Meaning |", "|--------|---------|"]; - for (const [type, forms] of Object.entries(TARGET_FORMS)) { - for (const form of forms as readonly TargetForm[]) { - const parsed = parseOrgProjectArg(form.example, { multi: form.multi }); - if (parsed.type !== type) { - throw new Error( - `TARGET_FORMS: ${form.syntax} example ${JSON.stringify(form.example)} parses as "${parsed.type}", not "${type}"`, - ); - } - lines.push(`| ${form.syntax} | ${form.meaning} |`); + const lines = targetGrammar.split("\n"); + const annotations = readTargetAnnotations(lines); + + const target = peggy.parser + .parse(targetGrammar, { grammarSource: TARGET_GRAMMAR }) + .rules.find((rule) => rule.name === "target"); + if (target?.expression.type !== "choice") { + throw new Error( + `${TARGET_GRAMMAR}: expected a "target" rule with alternatives`, + ); + } + for (const alternative of target.expression.alternatives) { + const line = alternative.location.start.line; + let first = line; + while (first > 1 && COMMENT_LINE_RE.test(lines[first - 2] ?? "")) { + first -= 1; + } + if (!annotations.some((a) => a.start >= first && a.start < line)) { + throw new Error( + `${TARGET_GRAMMAR}:${line}: target alternative has no @form or @invalid line`, + ); } } - return lines.join("\n"); + + const rows = ["| Target | Meaning |", "|--------|---------|"]; + for (const { start, kind, text } of annotations) { + if (kind !== "form") { + continue; + } + const [syntax, meaning] = text.split(" — "); + if (!(syntax && meaning)) { + throw new Error( + `${TARGET_GRAMMAR}:${start}: expected "@form — "`, + ); + } + rows.push(`| ${syntax} | ${meaning} |`); + } + return rows.join("\n"); } // --------------------------------------------------------------------------- diff --git a/packages/cli/src/generated/org-project-target-grammar.d.ts b/packages/cli/src/generated/org-project-target-grammar.d.ts new file mode 100644 index 000000000..406732d68 --- /dev/null +++ b/packages/cli/src/generated/org-project-target-grammar.d.ts @@ -0,0 +1,31 @@ +// Auto-generated by scripts/generate-parsers.mjs — DO NOT EDIT + +import type { ParsedOrgProject } from "../lib/arg-parsing.js"; + +/** + * Hooks the target grammar calls for character-level validation, so its + * errors match the rest of the CLI. See `parseOrgProjectArg()`. + */ +export type OrgProjectTargetGrammarOptions = { + /** Accept `/,` lists. */ + multi?: boolean; + /** Throw when `value` is not a valid slug. */ + checkSlug: (value: string, label: string) => void; + /** Throw when `value` starts with `@`. */ + rejectAt: (value: string, label: string) => void; + /** Throw a validation error. */ + invalid: (message: string, field?: string) => never; + startRule?: "target"; +}; + +export declare class SyntaxError extends Error { + expected: unknown; + found: string | null; + location: unknown; +} + +/** Parse a non-empty target that is not a URL. */ +export declare function parse( + input: string, + options: OrgProjectTargetGrammarOptions, +): Exclude; diff --git a/packages/cli/src/generated/org-project-target-grammar.js b/packages/cli/src/generated/org-project-target-grammar.js new file mode 100644 index 000000000..362a8a644 --- /dev/null +++ b/packages/cli/src/generated/org-project-target-grammar.js @@ -0,0 +1,925 @@ +// Auto-generated by scripts/generate-parsers.mjs — DO NOT EDIT + +// @generated by Peggy 5.1.0. +// +// https://peggyjs.org/ + + +class peg$SyntaxError extends SyntaxError { + constructor(message, expected, found, location) { + super(message); + this.expected = expected; + this.found = found; + this.location = location; + this.name = "SyntaxError"; + } + + format(sources) { + let str = "Error: " + this.message; + if (this.location) { + let src = null; + const st = sources.find(s => s.source === this.location.source); + if (st) { + src = st.text.split(/\r\n|\n|\r/g); + } + const s = this.location.start; + const offset_s = (this.location.source && (typeof this.location.source.offset === "function")) + ? this.location.source.offset(s) + : s; + const loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; + if (src) { + const e = this.location.end; + const filler = "".padEnd(offset_s.line.toString().length, " "); + const line = src[s.line - 1]; + const last = s.line === e.line ? e.column : line.length + 1; + const hatLen = (last - s.column) || 1; + str += "\n --> " + loc + "\n" + + filler + " |\n" + + offset_s.line + " | " + line + "\n" + + filler + " | " + "".padEnd(s.column - 1, " ") + + "".padEnd(hatLen, "^"); + } else { + str += "\n at " + loc; + } + } + return str; + } + + static buildMessage(expected, found) { + function hex(ch) { + return ch.codePointAt(0).toString(16).toUpperCase(); + } + + const nonPrintable = Object.prototype.hasOwnProperty.call(RegExp.prototype, "unicode") + ? new RegExp("[\\p{C}\\p{Mn}\\p{Mc}]", "gu") + : null; + function unicodeEscape(s) { + if (nonPrintable) { + return s.replace(nonPrintable, ch => "\\u{" + hex(ch) + "}"); + } + return s; + } + + function literalEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } + + function classEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/\]/g, "\\]") + .replace(/\^/g, "\\^") + .replace(/-/g, "\\-") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } + + const DESCRIBE_EXPECTATION_FNS = { + literal(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, + + class(expectation) { + const escapedParts = expectation.parts.map( + part => (Array.isArray(part) + ? classEscape(part[0]) + "-" + classEscape(part[1]) + : classEscape(part)) + ); + + return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]" + (expectation.unicode ? "u" : ""); + }, + + any() { + return "any character"; + }, + + end() { + return "end of input"; + }, + + other(expectation) { + return expectation.description; + }, + }; + + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } + + function describeExpected(expected) { + const descriptions = expected.map(describeExpectation); + descriptions.sort(); + + if (descriptions.length > 0) { + let j = 1; + for (let i = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } + + switch (descriptions.length) { + case 1: + return descriptions[0]; + + case 2: + return descriptions[0] + " or " + descriptions[1]; + + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; + } + } + + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + } + + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; + } +} + +function peg$parse(input, options) { + options = options !== undefined ? options : {}; + + const peg$FAILED = {}; + const peg$source = options.grammarSource; + + const peg$startRuleFunctions = { + target: peg$parsetarget, + }; + let peg$startRuleFunction = peg$parsetarget; + + const peg$c0 = "/"; + const peg$c1 = " "; + const peg$c2 = ","; + + const peg$r0 = /^[^\/]/; + const peg$r1 = /^[^ ]/; + const peg$r2 = /^[^,]/; + + const peg$e0 = peg$literalExpectation("/", false); + const peg$e1 = peg$anyExpectation(); + const peg$e2 = peg$classExpectation(["/"], true, false, false); + const peg$e3 = peg$classExpectation([" "], true, false, false); + const peg$e4 = peg$literalExpectation(" ", false); + const peg$e5 = peg$classExpectation([","], true, false, false); + const peg$e6 = peg$literalExpectation(",", false); + + function peg$f0(o, p) { return multi && p.includes(","); } + function peg$f1(o, p) { const org = orgSlug(o); return { type: "explicit", org, project: projectSlug(p) }; } + function peg$f2(o) { return multi; } + function peg$f3(o, l) { return projectList(orgSlug(o), l); } + function peg$f4(o, p) { const org = orgSlug(o); return displayName(p, org); } + function peg$f5(o) { return { type: "org-all", org: orgSlug(o) }; } + function peg$f6(p) { return { type: "project-search", projectSlug: projectSlug(p) }; } + function peg$f7(p) { return displayName(p); } + function peg$f8() { invalid('Invalid format: "/" requires a project slug (e.g., "/cli")'); } + let peg$currPos = options.peg$currPos | 0; + let peg$savedPos = peg$currPos; + const peg$posDetailsCache = [{ line: 1, column: 1 }]; + let peg$maxFailPos = peg$currPos; + let peg$maxFailExpected = options.peg$maxFailExpected || []; + let peg$silentFails = options.peg$silentFails | 0; + + let peg$result; + + if (options.startRule) { + if (!(options.startRule in peg$startRuleFunctions)) { + throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); + } + + peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; + } + + function text() { + return input.substring(peg$savedPos, peg$currPos); + } + + function offset() { + return peg$savedPos; + } + + function range() { + return { + source: peg$source, + start: peg$savedPos, + end: peg$currPos, + }; + } + + function location() { + return peg$computeLocation(peg$savedPos, peg$currPos); + } + + function expected(description, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildStructuredError( + [peg$otherExpectation(description)], + input.substring(peg$savedPos, peg$currPos), + location + ); + } + + function error(message, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildSimpleError(message, location); + } + + function peg$getUnicode(pos = peg$currPos) { + const cp = input.codePointAt(pos); + if (cp === undefined) { + return ""; + } + return String.fromCodePoint(cp); + } + + function peg$literalExpectation(text, ignoreCase) { + return { type: "literal", text, ignoreCase }; + } + + function peg$classExpectation(parts, inverted, ignoreCase, unicode) { + return { type: "class", parts, inverted, ignoreCase, unicode }; + } + + function peg$anyExpectation() { + return { type: "any" }; + } + + function peg$endExpectation() { + return { type: "end" }; + } + + function peg$otherExpectation(description) { + return { type: "other", description }; + } + + function peg$computePosDetails(pos) { + let details = peg$posDetailsCache[pos]; + let p; + + if (details) { + return details; + } else { + if (pos >= peg$posDetailsCache.length) { + p = peg$posDetailsCache.length - 1; + } else { + p = pos; + while (!peg$posDetailsCache[--p]) {} + } + + details = peg$posDetailsCache[p]; + details = { + line: details.line, + column: details.column, + }; + + while (p < pos) { + if (input.charCodeAt(p) === 10) { + details.line++; + details.column = 1; + } else { + details.column++; + } + + p++; + } + + peg$posDetailsCache[pos] = details; + + return details; + } + } + + function peg$computeLocation(startPos, endPos, offset) { + const startPosDetails = peg$computePosDetails(startPos); + const endPosDetails = peg$computePosDetails(endPos); + + const res = { + source: peg$source, + start: { + offset: startPos, + line: startPosDetails.line, + column: startPosDetails.column, + }, + end: { + offset: endPos, + line: endPosDetails.line, + column: endPosDetails.column, + }, + }; + if (offset && peg$source && (typeof peg$source.offset === "function")) { + res.start = peg$source.offset(res.start); + res.end = peg$source.offset(res.end); + } + return res; + } + + function peg$fail(expected) { + if (peg$currPos < peg$maxFailPos) { return; } + + if (peg$currPos > peg$maxFailPos) { + peg$maxFailPos = peg$currPos; + peg$maxFailExpected = []; + } + + peg$maxFailExpected.push(expected); + } + + function peg$buildSimpleError(message, location) { + return new peg$SyntaxError(message, null, null, location); + } + + function peg$buildStructuredError(expected, found, location) { + return new peg$SyntaxError( + peg$SyntaxError.buildMessage(expected, found), + expected, + found, + location + ); + } + + function peg$parsetarget() { + let s0, s1, s2, s3, s4; + + s0 = peg$currPos; + s1 = peg$parseorg(); + if (s1 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 47) { + s2 = peg$c0; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s2 !== peg$FAILED) { + s3 = peg$parseslug(); + if (s3 !== peg$FAILED) { + peg$savedPos = peg$currPos; + s4 = peg$f0(s1, s3); + if (s4) { + s4 = peg$FAILED; + } else { + s4 = undefined; + } + if (s4 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f1(s1, s3); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = peg$parseorg(); + if (s1 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 47) { + s2 = peg$c0; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s2 !== peg$FAILED) { + peg$savedPos = peg$currPos; + s3 = peg$f2(s1); + if (s3) { + s3 = undefined; + } else { + s3 = peg$FAILED; + } + if (s3 !== peg$FAILED) { + s4 = peg$parselist(); + if (s4 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f3(s1, s4); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = peg$parseorg(); + if (s1 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 47) { + s2 = peg$c0; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s2 !== peg$FAILED) { + s3 = peg$parsename(); + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f4(s1, s3); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = peg$parseorg(); + if (s1 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 47) { + s2 = peg$c0; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s2 !== peg$FAILED) { + s3 = peg$currPos; + peg$silentFails++; + if (input.length > peg$currPos) { + s4 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + peg$silentFails--; + if (s4 === peg$FAILED) { + s3 = undefined; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f5(s1); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 47) { + s1 = peg$c0; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s1 === peg$FAILED) { + s1 = null; + } + s2 = peg$parseslug(); + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f6(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 47) { + s1 = peg$c0; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s1 === peg$FAILED) { + s1 = null; + } + s2 = peg$parsename(); + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f7(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 47) { + s1 = peg$c0; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + if (input.length > peg$currPos) { + s3 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + peg$silentFails--; + if (s3 === peg$FAILED) { + s2 = undefined; + } else { + peg$currPos = s2; + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f8(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } + } + } + } + } + } + + return s0; + } + + function peg$parseorg() { + let s0, s1, s2; + + s0 = peg$currPos; + s1 = []; + s2 = input.charAt(peg$currPos); + if (peg$r0.test(s2)) { + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s2 !== peg$FAILED) { + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = input.charAt(peg$currPos); + if (peg$r0.test(s2)) { + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + } + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + s0 = input.substring(s0, peg$currPos); + } else { + s0 = s1; + } + + return s0; + } + + function peg$parseslug() { + let s0, s1, s2, s3, s4; + + s0 = peg$currPos; + s1 = peg$currPos; + s2 = []; + s3 = input.charAt(peg$currPos); + if (peg$r1.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e3); } + } + if (s3 !== peg$FAILED) { + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = input.charAt(peg$currPos); + if (peg$r1.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e3); } + } + } + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + s3 = peg$currPos; + peg$silentFails++; + if (input.length > peg$currPos) { + s4 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + peg$silentFails--; + if (s4 === peg$FAILED) { + s3 = undefined; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + if (s3 !== peg$FAILED) { + s2 = [s2, s3]; + s1 = s2; + } else { + peg$currPos = s1; + s1 = peg$FAILED; + } + } else { + peg$currPos = s1; + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + s0 = input.substring(s0, peg$currPos); + } else { + s0 = s1; + } + + return s0; + } + + function peg$parsename() { + let s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + s1 = peg$currPos; + s2 = []; + s3 = input.charAt(peg$currPos); + if (peg$r1.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e3); } + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = input.charAt(peg$currPos); + if (peg$r1.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e3); } + } + } + if (input.charCodeAt(peg$currPos) === 32) { + s3 = peg$c1; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e4); } + } + if (s3 !== peg$FAILED) { + s4 = []; + if (input.length > peg$currPos) { + s5 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + while (s5 !== peg$FAILED) { + s4.push(s5); + if (input.length > peg$currPos) { + s5 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + } + s2 = [s2, s3, s4]; + s1 = s2; + } else { + peg$currPos = s1; + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + s0 = input.substring(s0, peg$currPos); + } else { + s0 = s1; + } + + return s0; + } + + function peg$parselist() { + let s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + s1 = peg$currPos; + s2 = []; + s3 = input.charAt(peg$currPos); + if (peg$r2.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e5); } + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = input.charAt(peg$currPos); + if (peg$r2.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e5); } + } + } + if (input.charCodeAt(peg$currPos) === 44) { + s3 = peg$c2; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e6); } + } + if (s3 !== peg$FAILED) { + s4 = []; + if (input.length > peg$currPos) { + s5 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + while (s5 !== peg$FAILED) { + s4.push(s5); + if (input.length > peg$currPos) { + s5 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + } + s2 = [s2, s3, s4]; + s1 = s2; + } else { + peg$currPos = s1; + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + s0 = input.substring(s0, peg$currPos); + } else { + s0 = s1; + } + + return s0; + } + + + const { multi = false, checkSlug, rejectAt, invalid } = options; + + function slug(value, label) { + rejectAt(value, label); + checkSlug(value, label); + return value; + } + + function orgSlug(value) { + return slug(value, "organization slug"); + } + + function projectSlug(value) { + return slug(value, "project slug"); + } + + function displayName(value, org) { + rejectAt(value, "project slug"); + return { + type: "project-search", + projectSlug: value, + originalSlug: value, + ...(org !== undefined && { org }), + }; + } + + // Slugs cannot contain commas, so `org/web,api` is unambiguous. Tokens are + // trimmed, empty ones dropped, and duplicates removed in first-seen order. + function projectList(org, raw) { + const tokens = [...new Set(raw.split(",").map((t) => t.trim()))].filter( + (t) => t !== "", + ); + if (tokens.length === 0) { + invalid("Invalid project slug: comma-separated list is empty.", "project"); + } + if (tokens.some((t) => t.includes(" "))) { + invalid( + "Comma-separated project targets must be slugs, not display names.", + "project", + ); + } + const [first, second, ...others] = tokens.map(projectSlug); + return { + type: "explicit", + org, + project: first, + ...(second !== undefined && { projects: [first, second, ...others] }), + }; + } + + peg$result = peg$startRuleFunction(); + + const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length); + function peg$throw() { + if (peg$result !== peg$FAILED && peg$currPos < input.length) { + peg$fail(peg$endExpectation()); + } + + throw peg$buildStructuredError( + peg$maxFailExpected, + peg$maxFailPos < input.length ? peg$getUnicode(peg$maxFailPos) : null, + peg$maxFailPos < input.length + ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) + : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) + ); + } + if (options.peg$library) { + return /** @type {any} */ ({ + peg$result, + peg$currPos, + peg$FAILED, + peg$maxFailExpected, + peg$maxFailPos, + peg$success, + peg$throw: peg$success ? undefined : peg$throw, + }); + } + if (peg$success) { + return peg$result; + } else { + peg$throw(); + } +} + +const peg$allowedStartRules = [ + "target" +]; + +export { + peg$allowedStartRules as StartRules, + peg$SyntaxError as SyntaxError, + peg$parse as parse +}; + diff --git a/packages/cli/src/lib/arg-parsing.ts b/packages/cli/src/lib/arg-parsing.ts index 0fa732f39..0e6d50792 100644 --- a/packages/cli/src/lib/arg-parsing.ts +++ b/packages/cli/src/lib/arg-parsing.ts @@ -6,6 +6,7 @@ * project list) and single-item commands (issue view, explain, plan). */ +import { parse as parseTarget } from "../generated/org-project-target-grammar.js"; import type { LogSortDirection } from "./api/logs.js"; import { ContextError, ValidationError, validationError } from "./errors.js"; import { validateResourceId } from "./input-validation.js"; @@ -26,9 +27,9 @@ import { isAllDigits } from "./utils.js"; * is effectively a no-op — valid slug characters pass through unchanged. * It remains as a stable call-site for future normalization rules. * - * Spaces are handled separately: {@link looksLikeDisplayName} detects them - * and the parsing layer routes to a display-name search path that skips - * slug validation and API lookup entirely. + * Spaces are handled separately: the target grammar + * (`grammar/org-project-target.pegjs`) routes them to a display-name search + * that skips slug validation. * * @param slug - Raw slug string from CLI input * @returns The slug unchanged with `normalized: false` @@ -40,38 +41,6 @@ export function normalizeSlug(slug: string): { return { slug, normalized: false }; } -/** - * Check if a string looks like a display name rather than a slug. - * Display names contain spaces, which are never valid in slugs. - */ -function looksLikeDisplayName(input: string): boolean { - return input.includes(" "); -} - -/** - * Split a project selector on commas. - * - * Sentry slugs cannot contain commas, so `org/web,api,worker` is unambiguous. - * Trims whitespace, drops empty tokens, and de-duplicates while preserving - * first-seen order. - * - * @param rawProject - Raw project selector, potentially containing commas - * @returns A non-empty list of unique project slugs in input order - * @throws {ValidationError} When every token is empty (`org/,,,`) - */ -function splitProjectSelector(rawProject: string): [string, ...string[]] { - const [first, ...rest] = [ - ...new Set(rawProject.split(",").map((part) => part.trim())), - ].filter((slug) => slug !== ""); - if (first === undefined) { - throw new ValidationError( - "Invalid project slug: comma-separated list is empty.", - "project", - ); - } - return [first, ...rest]; -} - /** * Project slugs from an explicit `org/project` parse. * @@ -759,147 +728,12 @@ function rejectAtSelector(value: string, label: string): void { ); } -/** - * Parse a slash-delimited `org/project` string into a {@link ParsedOrgProject}. - * Applies {@link normalizeSlug} to both components and validates against - * URL injection characters. - */ -function parseSlashOrgProject(input: string, multi: boolean): ParsedOrgProject { - const slashIndex = input.indexOf("/"); - const rawOrg = input.slice(0, slashIndex); - const rawProject = input.slice(slashIndex + 1); - - if (!rawOrg) { - // "/cli" → search for project across all orgs - if (!rawProject) { - throw new ValidationError( - 'Invalid format: "/" requires a project slug (e.g., "/cli")', - ); - } - rejectAtSelector(rawProject, "project slug"); - if (looksLikeDisplayName(rawProject)) { - // Spaces → display name, not a slug. Skip slug validation and let - // the resolution layer do a fuzzy name-based search. - return { - type: "project-search", - projectSlug: rawProject, - originalSlug: rawProject, - }; - } - const np = normalizeSlug(rawProject); - validateResourceId(np.slug, "project slug"); - return { - type: "project-search", - projectSlug: np.slug, - }; - } - - rejectAtSelector(rawOrg, "organization slug"); - const no = normalizeSlug(rawOrg); - validateResourceId(no.slug, "organization slug"); - - if (!rawProject) { - // "sentry/" → list all projects in org - return { - type: "org-all", - org: no.slug, - ...(no.normalized && { normalized: true }), - }; - } - - // "sentry/web,api,worker" for list commands that explicitly opt in - if (multi && rawProject.includes(",")) { - return parseExplicitProjectList(no, rawProject); - } - - rejectAtSelector(rawProject, "project slug"); - if (looksLikeDisplayName(rawProject)) { - // Spaces → display name, not a slug. Skip slug validation and let the - // resolution layer do a fuzzy name-based search (mirrors the bare-slug - // and leading-slash paths). Prevents a hard ValidationError when callers - // pass a project display name in "org/project" form (CLI-1RA). - return { - type: "project-search", - projectSlug: rawProject, - originalSlug: rawProject, - org: no.slug, - }; - } - const np = validateProjectSlugToken(rawProject); - const normalized = no.normalized || np.normalized; - return { - type: "explicit", - org: no.slug, - project: np.slug, - ...(normalized && { normalized: true }), - }; -} - -/** - * Validate one project slug token: reject `@` selectors, normalize, and - * check for URL-injection characters. Shared by the single-slug and - * comma-list explicit paths. - */ -function validateProjectSlugToken(token: string): { - slug: string; - normalized: boolean; -} { - rejectAtSelector(token, "project slug"); - const np = normalizeSlug(token); - validateResourceId(np.slug, "project slug"); - return np; -} - -/** - * Parse `org/web,api,worker` into an explicit target with `projects` set. - * - * Display names are rejected here: a comma list is a slug selector, not a - * search. Each token is validated independently so a bad slug fails at parse - * time instead of as a 404 against the concatenated string. - */ -function parseExplicitProjectList( - org: { slug: string; normalized: boolean }, - rawProject: string, -): ParsedOrgProject { - const [firstToken, ...restTokens] = splitProjectSelector(rawProject); - if ([firstToken, ...restTokens].some(looksLikeDisplayName)) { - throw new ValidationError( - "Comma-separated project targets must be slugs, not display names.", - "project", - ); - } - - const first = validateProjectSlugToken(firstToken); - const rest = restTokens.map(validateProjectSlugToken); - const normalized = - org.normalized || - first.normalized || - rest.some((token) => token.normalized); - const [second, ...others] = rest; - - return { - type: "explicit", - org: org.slug, - project: first.slug, - ...(second !== undefined && { - projects: [first.slug, second.slug, ...others.map((t) => t.slug)], - }), - ...(normalized && { normalized: true }), - }; -} - /** * Parse an org/project positional argument string. * - * Supports the following patterns: - * - `undefined` or empty → auto-detect from DSN/config - * - `https://sentry.io/organizations/org/...` → extract from Sentry URL - * - `sentry/cli` → explicit org and project - * - `sentry/web,api` → explicit org and multiple projects when - * `options.multi` is true - * - `sentry/` → org with all projects - * - `/cli` → search for project across all orgs (leading slash) - * - `cli` → search for project across all orgs + * Empty input auto-detects and Sentry web URLs are read here; every other + * form is defined by `grammar/org-project-target.pegjs`, which also + * documents them. * * @param arg - Input string from CLI positional argument * @param options.multi - Parse comma-separated project slugs after @@ -934,31 +768,14 @@ export function parseOrgProjectArg( return orgProjectFromUrl(urlParsed); } - let parsed: ParsedOrgProject; - if (trimmed.includes("/")) { - parsed = parseSlashOrgProject(trimmed, options.multi === true); - } else { - // No slash → search for project across all orgs - rejectAtSelector(trimmed, "project slug"); - if (looksLikeDisplayName(trimmed)) { - // Spaces → display name, not a slug. Skip slug validation and let - // the resolution layer do a fuzzy name-based search. - parsed = { - type: "project-search", - projectSlug: trimmed, - originalSlug: trimmed, - }; - } else { - const np = normalizeSlug(trimmed); - validateResourceId(np.slug, "project slug"); - parsed = { - type: "project-search", - projectSlug: np.slug, - }; - } - } - - return parsed; + return parseTarget(trimmed, { + multi: options.multi === true, + checkSlug: validateResourceId, + rejectAt: rejectAtSelector, + invalid: (message, field) => { + throw new ValidationError(message, field); + }, + }); } /** diff --git a/packages/cli/src/lib/target-syntax.ts b/packages/cli/src/lib/target-syntax.ts deleted file mode 100644 index 810f8ded1..000000000 --- a/packages/cli/src/lib/target-syntax.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Documented forms of the `/` target positional, keyed by the - * `parseOrgProjectArg()` result type each form produces. - * - * The target syntax tables in the commands overview and CONTRIBUTING.md are - * generated from this map (`script/generate-docs-sections.ts`). The key set is - * checked by TypeScript, so a new parse result type cannot ship undocumented, - * and the generator parses every example and fails when the parser returns a - * different type, so the table cannot disagree with the parser. - */ - -import type { ProjectSpecificationType } from "./arg-parsing.js"; - -/** A `parseOrgProjectArg()` result type. */ -type SpecificationType = - (typeof ProjectSpecificationType)[keyof typeof ProjectSpecificationType]; - -/** One documented target form. */ -export type TargetForm = { - /** Syntax shown in the docs table (Markdown). */ - readonly syntax: string; - /** Example value; `undefined` means the argument is omitted. */ - readonly example: string | undefined; - /** Whether the example needs `parseOrgProjectArg(..., { multi: true })`. */ - readonly multi?: boolean; - /** What the form selects (Markdown). */ - readonly meaning: string; -}; - -/** Target forms by parse result type, in documentation order. */ -export const TARGET_FORMS = { - "auto-detect": [ - { - syntax: "_(omitted)_", - example: undefined, - meaning: - "Auto-detect the org and project from config, DSNs, or the directory name", - }, - ], - explicit: [ - { - syntax: "`/`", - example: "my-org/my-project", - meaning: "That project in that organization", - }, - { - syntax: "`/,`", - example: "my-org/web,api", - multi: true, - meaning: - "Several projects in the same organization (commands that accept several projects)", - }, - ], - "org-all": [ - { - syntax: "`/`", - example: "my-org/", - meaning: "The whole organization", - }, - ], - "project-search": [ - { - syntax: "``", - example: "my-project", - meaning: - "A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization", - }, - ], -} as const satisfies Record; diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 5ab41b480..6fd5a87ef 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -32,7 +32,7 @@ subclasses `TokenConverter` and passes it as `converter`. The parser is compiled ahead of time into `src/generated/` and committed, since this package has no build step. After editing a grammar, run -`pnpm --filter @sentry/toolkit-core generate`; the pre-commit hook does this -for staged grammar changes, and `pnpm run check:generated` fails in CI when +`pnpm run generate:parsers`; the pre-commit hook does this for staged +grammar changes, and `pnpm run check:generated` fails in CI when the committed parser is out of date. When updating the grammar from upstream, copy the fixtures too. diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index 0c0a64fe1..8e726ca52 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -61,13 +61,11 @@ } }, "scripts": { - "generate": "tsx script/generate-parsers.ts", "test": "vitest run", "test:ci": "vitest run --reporter=default --reporter=junit --outputFile=tests.junit.xml", "tsc": "tsc --noEmit" }, "devDependencies": { - "peggy": "^5.1.0", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/packages/toolkit-core/script/generate-parsers.ts b/packages/toolkit-core/script/generate-parsers.ts deleted file mode 100644 index b789918f1..000000000 --- a/packages/toolkit-core/script/generate-parsers.ts +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env tsx -/** - * Compile the shared PEG grammars into committed ES module parsers. - * - * toolkit-core ships TypeScript source with no build step, so the CLI, the - * stdio MCP server, and the Cloudflare worker all import the generated - * parsers straight from `src/generated/`. `pnpm run check:generated` in the - * workspace root regenerates them and fails when the committed output is out - * of date. - * - * `grammar/search-syntax.pegjs` is a byte-for-byte copy of the Sentry - * frontend grammar at - * https://github.com/getsentry/sentry/blob/master/static/app/components/searchSyntax/grammar.pegjs - * so the two can converge on one source. - * - * Usage: - * pnpm --filter @sentry/toolkit-core generate - */ - -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import peggy from "peggy"; - -const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const HEADER = - "// Auto-generated by packages/toolkit-core/script/generate-parsers.ts — DO NOT EDIT\n"; - -type GrammarSpec = { - grammar: string; - output: string; - allowedStartRules: string[]; - declarations: string; -}; - -const GRAMMARS: GrammarSpec[] = [ - { - grammar: "grammar/search-syntax.pegjs", - output: "src/generated/search-syntax-grammar", - allowedStartRules: ["search"], - declarations: `${HEADER} -/** - * Options the search grammar reads from \`options\`. The grammar builds every - * token through \`TokenConverter\`, so callers decide the token shapes and the - * key-type predicates. See \`../search-syntax.ts\` for the default converter. - */ -export type SearchGrammarOptions = { - /** Constructed with \`{ text, location, config }\` once per parse. */ - TokenConverter: new (opts: never) => unknown; - TermOperator: { DEFAULT: string }; - FilterType: Record; - config: unknown; - startRule?: "search"; -}; - -export declare class SyntaxError extends Error { - expected: unknown; - found: string | null; - location: unknown; -} - -/** Parse a search query. Throws {@link SyntaxError} when it does not match. */ -export declare function parse( - input: string, - options: SearchGrammarOptions, -): unknown[]; -`, - }, -]; - -for (const spec of GRAMMARS) { - const grammarSource = await readFile( - resolve(PACKAGE_ROOT, spec.grammar), - "utf-8", - ); - const parserSource = peggy.generate(grammarSource, { - output: "source", - format: "es", - allowedStartRules: spec.allowedStartRules, - grammarSource: spec.grammar, - }); - - const outputBase = resolve(PACKAGE_ROOT, spec.output); - await mkdir(dirname(outputBase), { recursive: true }); - await writeFile(`${outputBase}.js`, `${HEADER}\n${parserSource}\n`); - await writeFile(`${outputBase}.d.ts`, spec.declarations); - console.log(`Generated ${spec.output}.js and ${spec.output}.d.ts`); -} diff --git a/packages/toolkit-core/src/generated/search-syntax-grammar.d.ts b/packages/toolkit-core/src/generated/search-syntax-grammar.d.ts index 9e69d214c..8cd4a173c 100644 --- a/packages/toolkit-core/src/generated/search-syntax-grammar.d.ts +++ b/packages/toolkit-core/src/generated/search-syntax-grammar.d.ts @@ -1,4 +1,4 @@ -// Auto-generated by packages/toolkit-core/script/generate-parsers.ts — DO NOT EDIT +// Auto-generated by scripts/generate-parsers.mjs — DO NOT EDIT /** * Options the search grammar reads from `options`. The grammar builds every diff --git a/packages/toolkit-core/src/generated/search-syntax-grammar.js b/packages/toolkit-core/src/generated/search-syntax-grammar.js index 7c418005a..ae63c86f5 100644 --- a/packages/toolkit-core/src/generated/search-syntax-grammar.js +++ b/packages/toolkit-core/src/generated/search-syntax-grammar.js @@ -1,4 +1,4 @@ -// Auto-generated by packages/toolkit-core/script/generate-parsers.ts — DO NOT EDIT +// Auto-generated by scripts/generate-parsers.mjs — DO NOT EDIT // @generated by Peggy 5.1.0. // diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 885adef6a..915b104dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,9 @@ importers: oxlint-tsgolint: specifier: 'catalog:' version: 7.0.2004 + peggy: + specifier: ^5.1.0 + version: 5.1.0 simple-git-hooks: specifier: 'catalog:' version: 2.13.1 @@ -554,6 +557,9 @@ importers: p-limit: specifier: ^7.3.0 version: 7.3.2 + peggy: + specifier: ^5.1.0 + version: 5.1.0 picomatch: specifier: ^4.0.4 version: 4.0.5 @@ -987,9 +993,6 @@ importers: packages/toolkit-core: devDependencies: - peggy: - specifier: ^5.1.0 - version: 5.1.0 typescript: specifier: 'catalog:' version: 5.8.3 diff --git a/scripts/generate-parsers.mjs b/scripts/generate-parsers.mjs new file mode 100644 index 000000000..a64d2489a --- /dev/null +++ b/scripts/generate-parsers.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +/** + * Compile the workspace PEG grammars into committed ES module parsers. + * + * Each parser is written next to its package's TypeScript source under + * `src/generated/`, with a hand-written `.d.ts`, and committed: toolkit-core + * has no build step, and the CLI imports its parser like any other module. + * `pnpm run check:generated` regenerates them and fails when the committed + * output is out of date; the pre-commit hook regenerates them for staged + * grammar changes. + * + * `packages/toolkit-core/grammar/search-syntax.pegjs` is a byte-for-byte copy + * of the Sentry frontend grammar at + * https://github.com/getsentry/sentry/blob/master/static/app/components/searchSyntax/grammar.pegjs + * so the two can converge on one source. + * + * Usage: + * pnpm run generate:parsers + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import peggy from "peggy"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const HEADER = + "// Auto-generated by scripts/generate-parsers.mjs — DO NOT EDIT\n"; + +const SYNTAX_ERROR_DECLARATION = `export declare class SyntaxError extends Error { + expected: unknown; + found: string | null; + location: unknown; +} +`; + +/** + * @typedef {object} GrammarSpec + * @property {string} grammar - Grammar path, relative to the workspace root + * @property {string} output - Output path without extension + * @property {string[]} allowedStartRules - Rules callers may start from + * @property {string} declarations - Body of the generated `.d.ts` + */ + +/** @type {GrammarSpec[]} */ +const GRAMMARS = [ + { + grammar: "packages/toolkit-core/grammar/search-syntax.pegjs", + output: "packages/toolkit-core/src/generated/search-syntax-grammar", + allowedStartRules: ["search"], + declarations: ` +/** + * Options the search grammar reads from \`options\`. The grammar builds every + * token through \`TokenConverter\`, so callers decide the token shapes and the + * key-type predicates. See \`../search-syntax.ts\` for the default converter. + */ +export type SearchGrammarOptions = { + /** Constructed with \`{ text, location, config }\` once per parse. */ + TokenConverter: new (opts: never) => unknown; + TermOperator: { DEFAULT: string }; + FilterType: Record; + config: unknown; + startRule?: "search"; +}; + +${SYNTAX_ERROR_DECLARATION} +/** Parse a search query. Throws {@link SyntaxError} when it does not match. */ +export declare function parse( + input: string, + options: SearchGrammarOptions, +): unknown[]; +`, + }, + { + grammar: "packages/cli/grammar/org-project-target.pegjs", + output: "packages/cli/src/generated/org-project-target-grammar", + allowedStartRules: ["target"], + declarations: ` +import type { ParsedOrgProject } from "../lib/arg-parsing.js"; + +/** + * Hooks the target grammar calls for character-level validation, so its + * errors match the rest of the CLI. See \`parseOrgProjectArg()\`. + */ +export type OrgProjectTargetGrammarOptions = { + /** Accept \`/,\` lists. */ + multi?: boolean; + /** Throw when \`value\` is not a valid slug. */ + checkSlug: (value: string, label: string) => void; + /** Throw when \`value\` starts with \`@\`. */ + rejectAt: (value: string, label: string) => void; + /** Throw a validation error. */ + invalid: (message: string, field?: string) => never; + startRule?: "target"; +}; + +${SYNTAX_ERROR_DECLARATION} +/** Parse a non-empty target that is not a URL. */ +export declare function parse( + input: string, + options: OrgProjectTargetGrammarOptions, +): Exclude; +`, + }, +]; + +for (const spec of GRAMMARS) { + const grammarSource = await readFile(resolve(ROOT, spec.grammar), "utf-8"); + const parserSource = peggy.generate(grammarSource, { + output: "source", + format: "es", + allowedStartRules: spec.allowedStartRules, + grammarSource: spec.grammar, + }); + + const outputBase = resolve(ROOT, spec.output); + await mkdir(dirname(outputBase), { recursive: true }); + await writeFile(`${outputBase}.js`, `${HEADER}\n${parserSource}\n`); + await writeFile(`${outputBase}.d.ts`, `${HEADER}${spec.declarations}`); + console.log(`Generated ${spec.output}.js and ${spec.output}.d.ts`); +} diff --git a/scripts/pre-commit-generated.mjs b/scripts/pre-commit-generated.mjs index 49ffccf3c..0221a13f6 100644 --- a/scripts/pre-commit-generated.mjs +++ b/scripts/pre-commit-generated.mjs @@ -27,15 +27,17 @@ const GENERATED_TASKS = [ ], }, { - name: "toolkit-core grammar parsers", - command: ["pnpm", "--filter", "@sentry/toolkit-core", "generate"], + name: "grammar parsers", + command: ["pnpm", "run", "generate:parsers"], inputs: [ - /^packages\/toolkit-core\/grammar\/.*\.pegjs$/, - /^packages\/toolkit-core\/script\/generate-parsers\.ts$/, + /^packages\/(toolkit-core|cli)\/grammar\/.*\.pegjs$/, + /^scripts\/generate-parsers\.mjs$/, ], outputs: [ "packages/toolkit-core/src/generated/search-syntax-grammar.js", "packages/toolkit-core/src/generated/search-syntax-grammar.d.ts", + "packages/cli/src/generated/org-project-target-grammar.js", + "packages/cli/src/generated/org-project-target-grammar.d.ts", ], }, ]; From 4a49a82fc7edb7d78b3b99a642dc338001c9855c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:29:38 +0000 Subject: [PATCH 5/7] ref(cli): Parse doc code blocks with marked in check:doc-examples #1633 added the `no-regex-markdown-parsing` lint rule. Replace the fenced block regex with the shared `markdownCodeBlocks` / `blankCodeBlocks` helpers in script/markdown.ts, which use the same lexer as the docs renderer. --- packages/cli/script/check-doc-examples.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts index 517472a28..e4258f965 100644 --- a/packages/cli/script/check-doc-examples.ts +++ b/packages/cli/script/check-doc-examples.ts @@ -31,6 +31,7 @@ import type { RouteMap, RouteTarget, } from "../src/lib/introspect.js"; +import { blankCodeBlocks, markdownCodeBlocks } from "./markdown.js"; import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; // Bootstrap: skill-content stub (same pattern as generate-docs-sections.ts) @@ -76,7 +77,6 @@ const ORG_PLACEHOLDER_RE = /^(?:my-org|acme)$/; /** Placeholders of positionals parsed with `parseOrgProjectArg()`. */ const TARGET_PLACEHOLDER_RE = /^(?:target|org\/.+)$/; -const FENCE_RE = /^(`{3,}|~{3,})\s*([\w-]*)[^\n]*\n([\s\S]*?)\n\1\s*$/gm; const SHELL_LANGS = new Set(["", "bash", "sh", "shell", "zsh", "console"]); const INLINE_CODE_RE = /(? Date: Fri, 23 Oct 2026 21:36:54 +0000 Subject: [PATCH 6/7] fix(cli): Read target grammar annotations from CRLF checkouts The Windows job checks out the grammar with CRLF line endings, so the annotation regexes never matched and docs generation failed. --- packages/cli/script/generate-docs-sections.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/script/generate-docs-sections.ts b/packages/cli/script/generate-docs-sections.ts index 524cf4a1e..099d77fc3 100644 --- a/packages/cli/script/generate-docs-sections.ts +++ b/packages/cli/script/generate-docs-sections.ts @@ -649,7 +649,8 @@ function readTargetAnnotations(lines: string[]): TargetAnnotation[] { * cannot ship undocumented. */ function generateTargetSyntax(): string { - const lines = targetGrammar.split("\n"); + // Checkouts with CRLF line endings (Windows) must parse the same. + const lines = targetGrammar.split(/\r?\n/); const annotations = readTargetAnnotations(lines); const target = peggy.parser From 7adec8cad6bf4609e2214870c67ce113eb742bc6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:29:01 +0000 Subject: [PATCH 7/7] build(cli): Generate the target parser at build time Stop committing the CLI target parser; src/generated/ is gitignored and lint/format-ignored again. generate:grammar builds it from generate:docs and ensure-generated, so build, typecheck, test, and cli runs get it. Parser types move out of string templates in generate-parsers.mjs into hand-written grammar/.d.ts files. The generator copies them and rebases relative module specifiers it finds with the TypeScript parser. toolkit-core's parser stays committed and checked by check:generated. --- .oxfmtrc.json | 1 - .oxlintrc.json | 1 - package.json | 4 +- packages/cli/.gitignore | 5 +- .../org-project-target.d.ts} | 6 +- packages/cli/grammar/org-project-target.pegjs | 3 + packages/cli/package.json | 3 +- packages/cli/script/ensure-generated.mjs | 17 +- .../generated/org-project-target-grammar.js | 925 ------------------ packages/toolkit-core/README.md | 3 +- .../toolkit-core/grammar/search-syntax.d.ts | 25 + .../src/generated/search-syntax-grammar.d.ts | 2 +- scripts/generate-parsers.mjs | 178 ++-- scripts/pre-commit-generated.mjs | 6 +- 14 files changed, 142 insertions(+), 1037 deletions(-) rename packages/cli/{src/generated/org-project-target-grammar.d.ts => grammar/org-project-target.d.ts} (76%) delete mode 100644 packages/cli/src/generated/org-project-target-grammar.js create mode 100644 packages/toolkit-core/grammar/search-syntax.d.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json index d155bf0ff..4e98fd9c6 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -19,7 +19,6 @@ "**/worker-configuration.d.ts", "**/tsconfig*.json", "packages/toolkit-core/src/generated/**", - "packages/cli/src/generated/**", "packages/toolkit-core/test/fixtures/**" ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 97ba800ea..2bb513015 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -13,7 +13,6 @@ "**/worker-configuration.d.ts", "**/tsconfig*.json", "packages/toolkit-core/src/generated/**", - "packages/cli/src/generated/**", "packages/toolkit-core/test/fixtures/**", "packages/mcp-server-mocks/src/fixtures/**", "packages/cli/test/init-eval/templates/sveltekit-app/**" diff --git a/package.json b/package.json index d5883b7c0..7019f8c3b 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "dev:stdio": "pnpm --filter '@sentry/mcp-server...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --parallel --filter @sentry/mcp-server --filter @sentry/mcp-core --filter @sentry/mcp-server-mocks --if-present run dev", "build": "dotenv -e .env -e .env.local -- pnpm -r --filter '!sentry' --filter '!sentry-cli-docs' --if-present run build", "build:cli": "dotenv -e .env -e .env.local -- pnpm --filter sentry run build && pnpm --filter sentry-cli-docs run build", - "check:generated": "pnpm --filter @sentry/mcp-core generate-definitions && pnpm run generate:parsers && git diff --exit-code -- packages/mcp-core/src/toolDefinitions.json packages/mcp-core/src/skillDefinitions.json plugins/sentry-mcp/agents/sentry-mcp.md plugins/sentry-mcp-experimental/agents/sentry-mcp.md packages/toolkit-core/src/generated packages/cli/src/generated/org-project-target-grammar.js packages/cli/src/generated/org-project-target-grammar.d.ts", - "generate:parsers": "node scripts/generate-parsers.mjs", + "check:generated": "pnpm --filter @sentry/mcp-core generate-definitions && pnpm run generate:parsers && git diff --exit-code -- packages/mcp-core/src/toolDefinitions.json packages/mcp-core/src/skillDefinitions.json plugins/sentry-mcp/agents/sentry-mcp.md plugins/sentry-mcp-experimental/agents/sentry-mcp.md packages/toolkit-core/src/generated", + "generate:parsers": "node scripts/generate-parsers.mjs packages/toolkit-core", "deploy:docs": "pnpm --filter sentry run generate:schema && pnpm --filter sentry run generate:docs && pnpm --filter sentry-cli-docs run build && pnpm --filter sentry-cli-docs run deploy", "eval": "pnpm --filter '@sentry/mcp-server-evals...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --filter @sentry/mcp-server-evals run eval", "eval:ci": "pnpm --filter '@sentry/mcp-server-evals...' --if-present run build && CI=true dotenv -e .env -e .env.local -- pnpm --stream -r run eval:ci", diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore index 3a7264eb1..9af50246e 100644 --- a/packages/cli/.gitignore +++ b/packages/cli/.gitignore @@ -35,10 +35,7 @@ test/skill-eval/results.json *.tsbuildinfo # generated files (rebuilt at build time) -src/generated/* +src/generated/ src/sdk.generated.ts src/sdk.generated.d.cts !src/generated/banner-sixel.ts -# committed: built by `pnpm run generate:parsers` from grammar/ -!src/generated/org-project-target-grammar.js -!src/generated/org-project-target-grammar.d.ts diff --git a/packages/cli/src/generated/org-project-target-grammar.d.ts b/packages/cli/grammar/org-project-target.d.ts similarity index 76% rename from packages/cli/src/generated/org-project-target-grammar.d.ts rename to packages/cli/grammar/org-project-target.d.ts index 406732d68..6782a8659 100644 --- a/packages/cli/src/generated/org-project-target-grammar.d.ts +++ b/packages/cli/grammar/org-project-target.d.ts @@ -1,6 +1,8 @@ -// Auto-generated by scripts/generate-parsers.mjs — DO NOT EDIT +// Types of the parser generated from org-project-target.pegjs. The generator +// copies this file to src/generated/org-project-target-grammar.d.ts and +// rewrites relative imports to resolve from there. -import type { ParsedOrgProject } from "../lib/arg-parsing.js"; +import type { ParsedOrgProject } from "../src/lib/arg-parsing.js"; /** * Hooks the target grammar calls for character-level validation, so its diff --git a/packages/cli/grammar/org-project-target.pegjs b/packages/cli/grammar/org-project-target.pegjs index 15f699b08..6d37e6f47 100644 --- a/packages/cli/grammar/org-project-target.pegjs +++ b/packages/cli/grammar/org-project-target.pegjs @@ -1,4 +1,7 @@ // Grammar for the `[/][[,...]]` target positional. +// `pnpm run generate:grammar` compiles it, with the types in +// org-project-target.d.ts, into the gitignored src/generated/; the build, +// typecheck, and test scripts run it. // // `parseOrgProjectArg()` (src/lib/arg-parsing.ts) runs this parser after it // keeps the first non-blank line and handles the omitted and URL forms. The diff --git a/packages/cli/package.json b/packages/cli/package.json index 61b8ce06e..23a39bc24 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -72,7 +72,8 @@ "generate:sdk": "pnpm tsx script/generate-sdk.ts", "generate:skill": "pnpm tsx script/generate-skill.ts", "generate:banner": "pnpm tsx script/generate-banner-sixel.ts", - "generate:docs": "node script/ensure-generated.mjs schema && pnpm run generate:banner && pnpm run generate:completions && pnpm run generate:command-docs && pnpm run generate:docs-sections && pnpm run generate:skill", + "generate:docs": "node script/ensure-generated.mjs schema && pnpm run generate:grammar && pnpm run generate:banner && pnpm run generate:completions && pnpm run generate:command-docs && pnpm run generate:docs-sections && pnpm run generate:skill", + "generate:grammar": "node ../../scripts/generate-parsers.mjs .", "generate:completions": "pnpm tsx script/generate-completion-targets.ts", "generate:docs-sections": "pnpm tsx script/generate-docs-sections.ts", "generate:schema": "pnpm tsx script/generate-api-schema.ts", diff --git a/packages/cli/script/ensure-generated.mjs b/packages/cli/script/ensure-generated.mjs index 92370db0e..2a7cd780f 100644 --- a/packages/cli/script/ensure-generated.mjs +++ b/packages/cli/script/ensure-generated.mjs @@ -2,10 +2,11 @@ /** * Generate the gitignored `src/generated/` files when they are missing. * - * A fresh checkout has no API schema, skill content, or completion targets, so - * `pnpm run cli`, `typecheck`, and the tests used to fail until contributors - * ran `generate:schema` and `generate:docs` by hand. Scripts that need those - * files call this first, so there is no bootstrap step to document or forget. + * A fresh checkout has no API schema, target parser, skill content, or + * completion targets, so `pnpm run cli`, `typecheck`, and the tests used to + * fail until contributors ran `generate:schema` and `generate:docs` by hand. + * Scripts that need those files call this first, so there is no bootstrap + * step to document or forget. * * Usage: * node script/ensure-generated.mjs # everything the CLI imports @@ -18,6 +19,11 @@ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; const SCHEMA = "src/generated/api-schema.json"; +/** Produced by `generate:grammar`; imported by `src/lib/arg-parsing.ts`. */ +const GRAMMAR_OUTPUTS = [ + "src/generated/org-project-target-grammar.js", + "src/generated/org-project-target-grammar.d.ts", +]; /** Produced by `generate:docs`; imported at runtime by `src/`. */ const DOCS_OUTPUTS = [ "src/generated/skill-content.ts", @@ -32,6 +38,9 @@ function run(script) { if (!existsSync(SCHEMA)) { run("generate:schema"); } +if (process.argv[2] !== "schema" && !GRAMMAR_OUTPUTS.every(existsSync)) { + run("generate:grammar"); +} if (process.argv[2] !== "schema" && !DOCS_OUTPUTS.every(existsSync)) { run("generate:docs"); } diff --git a/packages/cli/src/generated/org-project-target-grammar.js b/packages/cli/src/generated/org-project-target-grammar.js deleted file mode 100644 index 362a8a644..000000000 --- a/packages/cli/src/generated/org-project-target-grammar.js +++ /dev/null @@ -1,925 +0,0 @@ -// Auto-generated by scripts/generate-parsers.mjs — DO NOT EDIT - -// @generated by Peggy 5.1.0. -// -// https://peggyjs.org/ - - -class peg$SyntaxError extends SyntaxError { - constructor(message, expected, found, location) { - super(message); - this.expected = expected; - this.found = found; - this.location = location; - this.name = "SyntaxError"; - } - - format(sources) { - let str = "Error: " + this.message; - if (this.location) { - let src = null; - const st = sources.find(s => s.source === this.location.source); - if (st) { - src = st.text.split(/\r\n|\n|\r/g); - } - const s = this.location.start; - const offset_s = (this.location.source && (typeof this.location.source.offset === "function")) - ? this.location.source.offset(s) - : s; - const loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; - if (src) { - const e = this.location.end; - const filler = "".padEnd(offset_s.line.toString().length, " "); - const line = src[s.line - 1]; - const last = s.line === e.line ? e.column : line.length + 1; - const hatLen = (last - s.column) || 1; - str += "\n --> " + loc + "\n" - + filler + " |\n" - + offset_s.line + " | " + line + "\n" - + filler + " | " + "".padEnd(s.column - 1, " ") - + "".padEnd(hatLen, "^"); - } else { - str += "\n at " + loc; - } - } - return str; - } - - static buildMessage(expected, found) { - function hex(ch) { - return ch.codePointAt(0).toString(16).toUpperCase(); - } - - const nonPrintable = Object.prototype.hasOwnProperty.call(RegExp.prototype, "unicode") - ? new RegExp("[\\p{C}\\p{Mn}\\p{Mc}]", "gu") - : null; - function unicodeEscape(s) { - if (nonPrintable) { - return s.replace(nonPrintable, ch => "\\u{" + hex(ch) + "}"); - } - return s; - } - - function literalEscape(s) { - return unicodeEscape(s - .replace(/\\/g, "\\\\") - .replace(/"/g, "\\\"") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) - .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); - } - - function classEscape(s) { - return unicodeEscape(s - .replace(/\\/g, "\\\\") - .replace(/\]/g, "\\]") - .replace(/\^/g, "\\^") - .replace(/-/g, "\\-") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) - .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); - } - - const DESCRIBE_EXPECTATION_FNS = { - literal(expectation) { - return "\"" + literalEscape(expectation.text) + "\""; - }, - - class(expectation) { - const escapedParts = expectation.parts.map( - part => (Array.isArray(part) - ? classEscape(part[0]) + "-" + classEscape(part[1]) - : classEscape(part)) - ); - - return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]" + (expectation.unicode ? "u" : ""); - }, - - any() { - return "any character"; - }, - - end() { - return "end of input"; - }, - - other(expectation) { - return expectation.description; - }, - }; - - function describeExpectation(expectation) { - return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); - } - - function describeExpected(expected) { - const descriptions = expected.map(describeExpectation); - descriptions.sort(); - - if (descriptions.length > 0) { - let j = 1; - for (let i = 1; i < descriptions.length; i++) { - if (descriptions[i - 1] !== descriptions[i]) { - descriptions[j] = descriptions[i]; - j++; - } - } - descriptions.length = j; - } - - switch (descriptions.length) { - case 1: - return descriptions[0]; - - case 2: - return descriptions[0] + " or " + descriptions[1]; - - default: - return descriptions.slice(0, -1).join(", ") - + ", or " - + descriptions[descriptions.length - 1]; - } - } - - function describeFound(found) { - return found ? "\"" + literalEscape(found) + "\"" : "end of input"; - } - - return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; - } -} - -function peg$parse(input, options) { - options = options !== undefined ? options : {}; - - const peg$FAILED = {}; - const peg$source = options.grammarSource; - - const peg$startRuleFunctions = { - target: peg$parsetarget, - }; - let peg$startRuleFunction = peg$parsetarget; - - const peg$c0 = "/"; - const peg$c1 = " "; - const peg$c2 = ","; - - const peg$r0 = /^[^\/]/; - const peg$r1 = /^[^ ]/; - const peg$r2 = /^[^,]/; - - const peg$e0 = peg$literalExpectation("/", false); - const peg$e1 = peg$anyExpectation(); - const peg$e2 = peg$classExpectation(["/"], true, false, false); - const peg$e3 = peg$classExpectation([" "], true, false, false); - const peg$e4 = peg$literalExpectation(" ", false); - const peg$e5 = peg$classExpectation([","], true, false, false); - const peg$e6 = peg$literalExpectation(",", false); - - function peg$f0(o, p) { return multi && p.includes(","); } - function peg$f1(o, p) { const org = orgSlug(o); return { type: "explicit", org, project: projectSlug(p) }; } - function peg$f2(o) { return multi; } - function peg$f3(o, l) { return projectList(orgSlug(o), l); } - function peg$f4(o, p) { const org = orgSlug(o); return displayName(p, org); } - function peg$f5(o) { return { type: "org-all", org: orgSlug(o) }; } - function peg$f6(p) { return { type: "project-search", projectSlug: projectSlug(p) }; } - function peg$f7(p) { return displayName(p); } - function peg$f8() { invalid('Invalid format: "/" requires a project slug (e.g., "/cli")'); } - let peg$currPos = options.peg$currPos | 0; - let peg$savedPos = peg$currPos; - const peg$posDetailsCache = [{ line: 1, column: 1 }]; - let peg$maxFailPos = peg$currPos; - let peg$maxFailExpected = options.peg$maxFailExpected || []; - let peg$silentFails = options.peg$silentFails | 0; - - let peg$result; - - if (options.startRule) { - if (!(options.startRule in peg$startRuleFunctions)) { - throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); - } - - peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; - } - - function text() { - return input.substring(peg$savedPos, peg$currPos); - } - - function offset() { - return peg$savedPos; - } - - function range() { - return { - source: peg$source, - start: peg$savedPos, - end: peg$currPos, - }; - } - - function location() { - return peg$computeLocation(peg$savedPos, peg$currPos); - } - - function expected(description, location) { - location = location !== undefined - ? location - : peg$computeLocation(peg$savedPos, peg$currPos); - - throw peg$buildStructuredError( - [peg$otherExpectation(description)], - input.substring(peg$savedPos, peg$currPos), - location - ); - } - - function error(message, location) { - location = location !== undefined - ? location - : peg$computeLocation(peg$savedPos, peg$currPos); - - throw peg$buildSimpleError(message, location); - } - - function peg$getUnicode(pos = peg$currPos) { - const cp = input.codePointAt(pos); - if (cp === undefined) { - return ""; - } - return String.fromCodePoint(cp); - } - - function peg$literalExpectation(text, ignoreCase) { - return { type: "literal", text, ignoreCase }; - } - - function peg$classExpectation(parts, inverted, ignoreCase, unicode) { - return { type: "class", parts, inverted, ignoreCase, unicode }; - } - - function peg$anyExpectation() { - return { type: "any" }; - } - - function peg$endExpectation() { - return { type: "end" }; - } - - function peg$otherExpectation(description) { - return { type: "other", description }; - } - - function peg$computePosDetails(pos) { - let details = peg$posDetailsCache[pos]; - let p; - - if (details) { - return details; - } else { - if (pos >= peg$posDetailsCache.length) { - p = peg$posDetailsCache.length - 1; - } else { - p = pos; - while (!peg$posDetailsCache[--p]) {} - } - - details = peg$posDetailsCache[p]; - details = { - line: details.line, - column: details.column, - }; - - while (p < pos) { - if (input.charCodeAt(p) === 10) { - details.line++; - details.column = 1; - } else { - details.column++; - } - - p++; - } - - peg$posDetailsCache[pos] = details; - - return details; - } - } - - function peg$computeLocation(startPos, endPos, offset) { - const startPosDetails = peg$computePosDetails(startPos); - const endPosDetails = peg$computePosDetails(endPos); - - const res = { - source: peg$source, - start: { - offset: startPos, - line: startPosDetails.line, - column: startPosDetails.column, - }, - end: { - offset: endPos, - line: endPosDetails.line, - column: endPosDetails.column, - }, - }; - if (offset && peg$source && (typeof peg$source.offset === "function")) { - res.start = peg$source.offset(res.start); - res.end = peg$source.offset(res.end); - } - return res; - } - - function peg$fail(expected) { - if (peg$currPos < peg$maxFailPos) { return; } - - if (peg$currPos > peg$maxFailPos) { - peg$maxFailPos = peg$currPos; - peg$maxFailExpected = []; - } - - peg$maxFailExpected.push(expected); - } - - function peg$buildSimpleError(message, location) { - return new peg$SyntaxError(message, null, null, location); - } - - function peg$buildStructuredError(expected, found, location) { - return new peg$SyntaxError( - peg$SyntaxError.buildMessage(expected, found), - expected, - found, - location - ); - } - - function peg$parsetarget() { - let s0, s1, s2, s3, s4; - - s0 = peg$currPos; - s1 = peg$parseorg(); - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 47) { - s2 = peg$c0; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e0); } - } - if (s2 !== peg$FAILED) { - s3 = peg$parseslug(); - if (s3 !== peg$FAILED) { - peg$savedPos = peg$currPos; - s4 = peg$f0(s1, s3); - if (s4) { - s4 = peg$FAILED; - } else { - s4 = undefined; - } - if (s4 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f1(s1, s3); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = peg$parseorg(); - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 47) { - s2 = peg$c0; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e0); } - } - if (s2 !== peg$FAILED) { - peg$savedPos = peg$currPos; - s3 = peg$f2(s1); - if (s3) { - s3 = undefined; - } else { - s3 = peg$FAILED; - } - if (s3 !== peg$FAILED) { - s4 = peg$parselist(); - if (s4 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f3(s1, s4); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = peg$parseorg(); - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 47) { - s2 = peg$c0; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e0); } - } - if (s2 !== peg$FAILED) { - s3 = peg$parsename(); - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f4(s1, s3); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = peg$parseorg(); - if (s1 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 47) { - s2 = peg$c0; - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e0); } - } - if (s2 !== peg$FAILED) { - s3 = peg$currPos; - peg$silentFails++; - if (input.length > peg$currPos) { - s4 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e1); } - } - peg$silentFails--; - if (s4 === peg$FAILED) { - s3 = undefined; - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f5(s1); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 47) { - s1 = peg$c0; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e0); } - } - if (s1 === peg$FAILED) { - s1 = null; - } - s2 = peg$parseslug(); - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f6(s2); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 47) { - s1 = peg$c0; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e0); } - } - if (s1 === peg$FAILED) { - s1 = null; - } - s2 = peg$parsename(); - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f7(s2); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 47) { - s1 = peg$c0; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e0); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - if (input.length > peg$currPos) { - s3 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e1); } - } - peg$silentFails--; - if (s3 === peg$FAILED) { - s2 = undefined; - } else { - peg$currPos = s2; - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f8(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } - } - } - } - } - } - - return s0; - } - - function peg$parseorg() { - let s0, s1, s2; - - s0 = peg$currPos; - s1 = []; - s2 = input.charAt(peg$currPos); - if (peg$r0.test(s2)) { - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e2); } - } - if (s2 !== peg$FAILED) { - while (s2 !== peg$FAILED) { - s1.push(s2); - s2 = input.charAt(peg$currPos); - if (peg$r0.test(s2)) { - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e2); } - } - } - } else { - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - s0 = input.substring(s0, peg$currPos); - } else { - s0 = s1; - } - - return s0; - } - - function peg$parseslug() { - let s0, s1, s2, s3, s4; - - s0 = peg$currPos; - s1 = peg$currPos; - s2 = []; - s3 = input.charAt(peg$currPos); - if (peg$r1.test(s3)) { - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e3); } - } - if (s3 !== peg$FAILED) { - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = input.charAt(peg$currPos); - if (peg$r1.test(s3)) { - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e3); } - } - } - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - s3 = peg$currPos; - peg$silentFails++; - if (input.length > peg$currPos) { - s4 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e1); } - } - peg$silentFails--; - if (s4 === peg$FAILED) { - s3 = undefined; - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - if (s3 !== peg$FAILED) { - s2 = [s2, s3]; - s1 = s2; - } else { - peg$currPos = s1; - s1 = peg$FAILED; - } - } else { - peg$currPos = s1; - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - s0 = input.substring(s0, peg$currPos); - } else { - s0 = s1; - } - - return s0; - } - - function peg$parsename() { - let s0, s1, s2, s3, s4, s5; - - s0 = peg$currPos; - s1 = peg$currPos; - s2 = []; - s3 = input.charAt(peg$currPos); - if (peg$r1.test(s3)) { - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e3); } - } - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = input.charAt(peg$currPos); - if (peg$r1.test(s3)) { - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e3); } - } - } - if (input.charCodeAt(peg$currPos) === 32) { - s3 = peg$c1; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e4); } - } - if (s3 !== peg$FAILED) { - s4 = []; - if (input.length > peg$currPos) { - s5 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e1); } - } - while (s5 !== peg$FAILED) { - s4.push(s5); - if (input.length > peg$currPos) { - s5 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e1); } - } - } - s2 = [s2, s3, s4]; - s1 = s2; - } else { - peg$currPos = s1; - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - s0 = input.substring(s0, peg$currPos); - } else { - s0 = s1; - } - - return s0; - } - - function peg$parselist() { - let s0, s1, s2, s3, s4, s5; - - s0 = peg$currPos; - s1 = peg$currPos; - s2 = []; - s3 = input.charAt(peg$currPos); - if (peg$r2.test(s3)) { - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e5); } - } - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = input.charAt(peg$currPos); - if (peg$r2.test(s3)) { - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e5); } - } - } - if (input.charCodeAt(peg$currPos) === 44) { - s3 = peg$c2; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e6); } - } - if (s3 !== peg$FAILED) { - s4 = []; - if (input.length > peg$currPos) { - s5 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e1); } - } - while (s5 !== peg$FAILED) { - s4.push(s5); - if (input.length > peg$currPos) { - s5 = input.charAt(peg$currPos); - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e1); } - } - } - s2 = [s2, s3, s4]; - s1 = s2; - } else { - peg$currPos = s1; - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - s0 = input.substring(s0, peg$currPos); - } else { - s0 = s1; - } - - return s0; - } - - - const { multi = false, checkSlug, rejectAt, invalid } = options; - - function slug(value, label) { - rejectAt(value, label); - checkSlug(value, label); - return value; - } - - function orgSlug(value) { - return slug(value, "organization slug"); - } - - function projectSlug(value) { - return slug(value, "project slug"); - } - - function displayName(value, org) { - rejectAt(value, "project slug"); - return { - type: "project-search", - projectSlug: value, - originalSlug: value, - ...(org !== undefined && { org }), - }; - } - - // Slugs cannot contain commas, so `org/web,api` is unambiguous. Tokens are - // trimmed, empty ones dropped, and duplicates removed in first-seen order. - function projectList(org, raw) { - const tokens = [...new Set(raw.split(",").map((t) => t.trim()))].filter( - (t) => t !== "", - ); - if (tokens.length === 0) { - invalid("Invalid project slug: comma-separated list is empty.", "project"); - } - if (tokens.some((t) => t.includes(" "))) { - invalid( - "Comma-separated project targets must be slugs, not display names.", - "project", - ); - } - const [first, second, ...others] = tokens.map(projectSlug); - return { - type: "explicit", - org, - project: first, - ...(second !== undefined && { projects: [first, second, ...others] }), - }; - } - - peg$result = peg$startRuleFunction(); - - const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length); - function peg$throw() { - if (peg$result !== peg$FAILED && peg$currPos < input.length) { - peg$fail(peg$endExpectation()); - } - - throw peg$buildStructuredError( - peg$maxFailExpected, - peg$maxFailPos < input.length ? peg$getUnicode(peg$maxFailPos) : null, - peg$maxFailPos < input.length - ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) - : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) - ); - } - if (options.peg$library) { - return /** @type {any} */ ({ - peg$result, - peg$currPos, - peg$FAILED, - peg$maxFailExpected, - peg$maxFailPos, - peg$success, - peg$throw: peg$success ? undefined : peg$throw, - }); - } - if (peg$success) { - return peg$result; - } else { - peg$throw(); - } -} - -const peg$allowedStartRules = [ - "target" -]; - -export { - peg$allowedStartRules as StartRules, - peg$SyntaxError as SyntaxError, - peg$parse as parse -}; - diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 6fd5a87ef..fdb1d9ab9 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -31,7 +31,8 @@ marks tokens invalid or fills `parsed` values. A product that needs those subclasses `TokenConverter` and passes it as `converter`. The parser is compiled ahead of time into `src/generated/` and committed, -since this package has no build step. After editing a grammar, run +since this package has no build step. Its types live in +`grammar/search-syntax.d.ts`. After editing either file, run `pnpm run generate:parsers`; the pre-commit hook does this for staged grammar changes, and `pnpm run check:generated` fails in CI when the committed parser is out of date. When updating the grammar from upstream, diff --git a/packages/toolkit-core/grammar/search-syntax.d.ts b/packages/toolkit-core/grammar/search-syntax.d.ts new file mode 100644 index 000000000..51dd8ec90 --- /dev/null +++ b/packages/toolkit-core/grammar/search-syntax.d.ts @@ -0,0 +1,25 @@ +/** + * Options the search grammar reads from `options`. The grammar builds every + * token through `TokenConverter`, so callers decide the token shapes and the + * key-type predicates. See `src/search-syntax.ts` for the default converter. + */ +export type SearchGrammarOptions = { + /** Constructed with `{ text, location, config }` once per parse. */ + TokenConverter: new (opts: never) => unknown; + TermOperator: { DEFAULT: string }; + FilterType: Record; + config: unknown; + startRule?: "search"; +}; + +export declare class SyntaxError extends Error { + expected: unknown; + found: string | null; + location: unknown; +} + +/** Parse a search query. Throws {@link SyntaxError} when it does not match. */ +export declare function parse( + input: string, + options: SearchGrammarOptions, +): unknown[]; diff --git a/packages/toolkit-core/src/generated/search-syntax-grammar.d.ts b/packages/toolkit-core/src/generated/search-syntax-grammar.d.ts index 8cd4a173c..bbc26e0a6 100644 --- a/packages/toolkit-core/src/generated/search-syntax-grammar.d.ts +++ b/packages/toolkit-core/src/generated/search-syntax-grammar.d.ts @@ -3,7 +3,7 @@ /** * Options the search grammar reads from `options`. The grammar builds every * token through `TokenConverter`, so callers decide the token shapes and the - * key-type predicates. See `../search-syntax.ts` for the default converter. + * key-type predicates. See `src/search-syntax.ts` for the default converter. */ export type SearchGrammarOptions = { /** Constructed with `{ text, location, config }` once per parse. */ diff --git a/scripts/generate-parsers.mjs b/scripts/generate-parsers.mjs index a64d2489a..c0e085c84 100644 --- a/scripts/generate-parsers.mjs +++ b/scripts/generate-parsers.mjs @@ -1,13 +1,18 @@ #!/usr/bin/env node /** - * Compile the workspace PEG grammars into committed ES module parsers. + * Compile a package's PEG grammars into ES module parsers. * - * Each parser is written next to its package's TypeScript source under - * `src/generated/`, with a hand-written `.d.ts`, and committed: toolkit-core - * has no build step, and the CLI imports its parser like any other module. - * `pnpm run check:generated` regenerates them and fails when the committed - * output is out of date; the pre-commit hook regenerates them for staged - * grammar changes. + * Every `grammar/.pegjs` in the package becomes + * `src/generated/-grammar.js`. Its types live in the hand-written + * `grammar/.d.ts` next to the grammar, a real TypeScript file that + * editors and linters check in place. The generator copies it to + * `src/generated/-grammar.d.ts`. Relative module specifiers, found + * with the TypeScript parser, are rebased so they resolve to the same + * modules from there; nothing else in the file changes. + * + * toolkit-core has no build step, so its parser is committed and + * `pnpm run check:generated` fails when it is out of date. The CLI generates + * its parser at build time (`pnpm run generate:grammar` in packages/cli). * * `packages/toolkit-core/grammar/search-syntax.pegjs` is a byte-for-byte copy * of the Sentry frontend grammar at @@ -15,107 +20,98 @@ * so the two can converge on one source. * * Usage: - * pnpm run generate:parsers + * node scripts/generate-parsers.mjs */ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { join, relative, resolve, sep } from "node:path"; import peggy from "peggy"; +import ts from "typescript"; -const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const HEADER = "// Auto-generated by scripts/generate-parsers.mjs — DO NOT EDIT\n"; -const SYNTAX_ERROR_DECLARATION = `export declare class SyntaxError extends Error { - expected: unknown; - found: string | null; - location: unknown; +const packageDir = process.argv[2]; +if (!packageDir) { + console.error("Usage: node scripts/generate-parsers.mjs "); + process.exit(1); } -`; +const grammarDir = resolve(packageDir, "grammar"); +const outputDir = resolve(packageDir, "src", "generated"); -/** - * @typedef {object} GrammarSpec - * @property {string} grammar - Grammar path, relative to the workspace root - * @property {string} output - Output path without extension - * @property {string[]} allowedStartRules - Rules callers may start from - * @property {string} declarations - Body of the generated `.d.ts` - */ +/** Point a relative specifier written from `grammarDir` at `outputDir`. */ +function rebaseSpecifier(specifier) { + if (!specifier.startsWith(".")) { + return specifier; + } + const path = relative(outputDir, resolve(grammarDir, specifier)) + .split(sep) + .join("/"); + return path.startsWith(".") ? path : `./${path}`; +} -/** @type {GrammarSpec[]} */ -const GRAMMARS = [ - { - grammar: "packages/toolkit-core/grammar/search-syntax.pegjs", - output: "packages/toolkit-core/src/generated/search-syntax-grammar", - allowedStartRules: ["search"], - declarations: ` -/** - * Options the search grammar reads from \`options\`. The grammar builds every - * token through \`TokenConverter\`, so callers decide the token shapes and the - * key-type predicates. See \`../search-syntax.ts\` for the default converter. - */ -export type SearchGrammarOptions = { - /** Constructed with \`{ text, location, config }\` once per parse. */ - TokenConverter: new (opts: never) => unknown; - TermOperator: { DEFAULT: string }; - FilterType: Record; - config: unknown; - startRule?: "search"; -}; +/** Module specifier literals of import/export declarations and `import()` types. */ +function moduleSpecifiers(file) { + const found = []; + const visit = (node) => { + const specifier = + ts.isImportDeclaration(node) || ts.isExportDeclaration(node) + ? node.moduleSpecifier + : ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) + ? node.argument.literal + : undefined; + if (specifier && ts.isStringLiteral(specifier)) { + found.push(specifier); + } + ts.forEachChild(node, visit); + }; + visit(file); + return found; +} -${SYNTAX_ERROR_DECLARATION} -/** Parse a search query. Throws {@link SyntaxError} when it does not match. */ -export declare function parse( - input: string, - options: SearchGrammarOptions, -): unknown[]; -`, - }, - { - grammar: "packages/cli/grammar/org-project-target.pegjs", - output: "packages/cli/src/generated/org-project-target-grammar", - allowedStartRules: ["target"], - declarations: ` -import type { ParsedOrgProject } from "../lib/arg-parsing.js"; +/** Rebase every relative module specifier, located with the TypeScript parser. */ +function rebaseDeclarations(source, fileName) { + const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest); + let result = source; + // Splice from the end so earlier offsets stay valid. Each literal spans + // its quotes; only the text between them is replaced. + for (const literal of moduleSpecifiers(file).toReversed()) { + const start = literal.getStart(file) + 1; + const end = literal.end - 1; + result = + result.slice(0, start) + + rebaseSpecifier(literal.text) + + result.slice(end); + } + return result; +} -/** - * Hooks the target grammar calls for character-level validation, so its - * errors match the rest of the CLI. See \`parseOrgProjectArg()\`. - */ -export type OrgProjectTargetGrammarOptions = { - /** Accept \`/,\` lists. */ - multi?: boolean; - /** Throw when \`value\` is not a valid slug. */ - checkSlug: (value: string, label: string) => void; - /** Throw when \`value\` starts with \`@\`. */ - rejectAt: (value: string, label: string) => void; - /** Throw a validation error. */ - invalid: (message: string, field?: string) => never; - startRule?: "target"; -}; +const grammars = (await readdir(grammarDir)).filter((file) => + file.endsWith(".pegjs"), +); +await mkdir(outputDir, { recursive: true }); -${SYNTAX_ERROR_DECLARATION} -/** Parse a non-empty target that is not a URL. */ -export declare function parse( - input: string, - options: OrgProjectTargetGrammarOptions, -): Exclude; -`, - }, -]; +for (const file of grammars) { + const name = file.slice(0, -".pegjs".length); + const grammarPath = join(grammarDir, file); + const declarationsPath = join(grammarDir, `${name}.d.ts`); + if (!existsSync(declarationsPath)) { + throw new Error(`${declarationsPath} is missing`); + } -for (const spec of GRAMMARS) { - const grammarSource = await readFile(resolve(ROOT, spec.grammar), "utf-8"); - const parserSource = peggy.generate(grammarSource, { + const parserSource = peggy.generate(await readFile(grammarPath, "utf-8"), { output: "source", format: "es", - allowedStartRules: spec.allowedStartRules, - grammarSource: spec.grammar, + grammarSource: relative(packageDir, grammarPath).split(sep).join("/"), }); + const declarations = rebaseDeclarations( + await readFile(declarationsPath, "utf-8"), + declarationsPath, + ); - const outputBase = resolve(ROOT, spec.output); - await mkdir(dirname(outputBase), { recursive: true }); + const outputBase = join(outputDir, `${name}-grammar`); await writeFile(`${outputBase}.js`, `${HEADER}\n${parserSource}\n`); - await writeFile(`${outputBase}.d.ts`, `${HEADER}${spec.declarations}`); - console.log(`Generated ${spec.output}.js and ${spec.output}.d.ts`); + await writeFile(`${outputBase}.d.ts`, `${HEADER}\n${declarations}`); + console.log(`Generated ${relative(process.cwd(), outputBase)}.{js,d.ts}`); } diff --git a/scripts/pre-commit-generated.mjs b/scripts/pre-commit-generated.mjs index 0221a13f6..98aed210b 100644 --- a/scripts/pre-commit-generated.mjs +++ b/scripts/pre-commit-generated.mjs @@ -27,17 +27,15 @@ const GENERATED_TASKS = [ ], }, { - name: "grammar parsers", + name: "toolkit-core grammar parsers", command: ["pnpm", "run", "generate:parsers"], inputs: [ - /^packages\/(toolkit-core|cli)\/grammar\/.*\.pegjs$/, + /^packages\/toolkit-core\/grammar\//, /^scripts\/generate-parsers\.mjs$/, ], outputs: [ "packages/toolkit-core/src/generated/search-syntax-grammar.js", "packages/toolkit-core/src/generated/search-syntax-grammar.d.ts", - "packages/cli/src/generated/org-project-target-grammar.js", - "packages/cli/src/generated/org-project-target-grammar.d.ts", ], }, ];