diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md index 24d0ca35c..b5196c385 100644 --- a/apps/cli-docs/src/content/docs/agent-guidance.md +++ b/apps/cli-docs/src/content/docs/agent-guidance.md @@ -10,7 +10,7 @@ Best practices and operational guidance for AI coding agents using the Sentry CL - **Just run the command** — the CLI handles authentication and org/project detection automatically. Don't pre-authenticate or look up org/project before running commands. The CLI prompts for login if needed. - **Prefer CLI commands over raw API calls** — the CLI has dedicated commands for most tasks. Reach for `sentry issue view`, `sentry issue list`, `sentry trace view`, etc. before constructing API calls manually or fetching external documentation. - **Use `sentry docs` for setup questions** — if you need to know how to configure a Sentry SDK or feature, run `sentry docs "your question"` to query the documentation directly. This is faster and more accurate than fetching docs externally. -- **Use `sentry schema` to explore the API** — if you need to discover API endpoints, run `sentry schema` to browse interactively or `sentry schema ` to search. This is faster than fetching OpenAPI specs externally. +- **Use `sentry schema` to explore the API** — run `sentry schema` to list API resources or `sentry schema ` to search by API concepts or CLI command paths. Use `sentry help ` for CLI flags and usage examples. - **Use `sentry issue view` to investigate issues** — when asked about a specific issue (e.g., `CLI-G5`, `PROJECT-123`), use `sentry issue view` directly. Multiple IDs can be passed in one invocation: `sentry issue view A B C --json` returns an array of the same objects. - **Use `--json` for machine-readable output** — pipe through `jq` for filtering. Human-readable output includes formatting that is hard to parse. - **The CLI auto-detects org/project — don't discover it yourself** — most commands work without explicit targets by checking `.sentryclirc` config files, scanning for DSNs in `.env` files and source code, and matching directory names. Do **not** run `sentry org list` and then `sentry project list` to figure out which project this checkout belongs to — that manual fan-out just replicates the detection the CLI already runs on every command. Only specify `/` when the CLI reports it can't detect the target or detects the wrong one. diff --git a/apps/cli-docs/src/fragments/commands/schema.md b/apps/cli-docs/src/fragments/commands/schema.md index e69de29bb..0980db9c9 100644 --- a/apps/cli-docs/src/fragments/commands/schema.md +++ b/apps/cli-docs/src/fragments/commands/schema.md @@ -0,0 +1,19 @@ +Search the bundled API schema using product concepts or CLI command paths. +Discovery includes summaries, paths, tags, and query parameter values: `logs` +can find Explore's `dataset=logs` as well as feature-flag audit logs. Results +explain their matches and show related CLI commands discovered from command +implementations. A related API does not necessarily reproduce the command's filters, +pagination, or transformations. + +Use `sentry help ` for CLI flags and usage examples. Schema describes +API operations and parameters for constructing requests with `sentry api`. + +Use an exact operation ID, SDK function name, or HTTP method and path for a +precise lookup. Concept searches return all matching candidates; deprecated +operations are marked and sorted after active ones. No endpoint is chosen just +because it appears first in the catalog. + +With `--json`, discovery returns an array of endpoints with additive `match` +metadata. Exact single-endpoint lookups remain endpoint objects, `--search` +always returns an array, and `--all` returns the flat catalog without match +metadata. The schema is bundled from the CLI's SDK version and works offline. diff --git a/packages/cli/DEVELOPMENT.md b/packages/cli/DEVELOPMENT.md index 54102fc0e..12ffb6e2f 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -102,6 +102,32 @@ pnpm run build ## Architecture +### API discovery references + +`sentry schema` reads the index generated by `pnpm run generate:schema` from +the installed `@sentry/api` version. New operations and query enum values become +searchable when this index is regenerated; do not add a separate vocabulary map. +The generator reads `@sentry/api/operations` directly, without fetching a schema +or parsing the SDK bundle. The SDK owns operation metadata; the CLI preserves +its resource grouping and JSON output format when projecting the catalog. + +`pnpm run generate:command-api` uses `@sentry/api/source-analysis` to follow command +implementations and their helpers, generating API relationships from SDK calls +and recognized raw request paths. The CLI supplies its command builders, request +wrappers, and query context; it also reuses this analysis for DELETE checks. +Keep dataset and filter values in the requests themselves; do not add a +parallel list of operation names or command vocabulary. Build diagnostics report +raw or unpublished calls that the generated catalog cannot identify, so the +relationships describe discovered usage rather than exhaustive coverage. + +Introspection derives canonical command paths, registered aliases, and group +membership from the route tree. Groups collect their public commands' generated +references, so adding a command requires no separate group mapping. A related API +does not reproduce the command's complete request orchestration, pagination, or +transformations. + +### Authentication + The CLI uses the OAuth 2.0 Device Authorization Grant ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) for authentication. This flow is designed for CLI tools and other devices that can't easily handle browser redirects: 1. CLI requests a device code from Sentry diff --git a/packages/cli/package.json b/packages/cli/package.json index 9e5f41a5c..0ae7c1743 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -72,11 +72,12 @@ "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:grammar && 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:command-api && 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", + "generate:command-api": "pnpm tsx script/generate-command-api.ts", "generate:command-docs": "pnpm tsx script/generate-command-docs.ts", "eval:skill": "pnpm tsx script/eval-skill.ts", "bench": "pnpm tsx script/bench.ts", 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 3b6f0a793..daef04b2d 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -22,7 +22,7 @@ Best practices and operational guidance for AI coding agents using the Sentry CL - **Just run the command** — the CLI handles authentication and org/project detection automatically. Don't pre-authenticate or look up org/project before running commands. The CLI prompts for login if needed. - **Prefer CLI commands over raw API calls** — the CLI has dedicated commands for most tasks. Reach for `sentry issue view`, `sentry issue list`, `sentry trace view`, etc. before constructing API calls manually or fetching external documentation. - **Use `sentry docs` for setup questions** — if you need to know how to configure a Sentry SDK or feature, run `sentry docs "your question"` to query the documentation directly. This is faster and more accurate than fetching docs externally. -- **Use `sentry schema` to explore the API** — if you need to discover API endpoints, run `sentry schema` to browse interactively or `sentry schema ` to search. This is faster than fetching OpenAPI specs externally. +- **Use `sentry schema` to explore the API** — run `sentry schema` to list API resources or `sentry schema ` to search by API concepts or CLI command paths. Use `sentry help ` for CLI flags and usage examples. - **Use `sentry issue view` to investigate issues** — when asked about a specific issue (e.g., `CLI-G5`, `PROJECT-123`), use `sentry issue view` directly. Multiple IDs can be passed in one invocation: `sentry issue view A B C --json` returns an array of the same objects. - **Use `--json` for machine-readable output** — pipe through `jq` for filtering. Human-readable output includes formatting that is hard to parse. - **The CLI auto-detects org/project — don't discover it yourself** — most commands work without explicit targets by checking `.sentryclirc` config files, scanning for DSNs in `.env` files and source code, and matching directory names. Do **not** run `sentry org list` and then `sentry project list` to figure out which project this checkout belongs to — that manual fan-out just replicates the detection the CLI already runs on every command. Only specify `/` when the CLI reports it can't detect the target or detects the wrong one. @@ -806,7 +806,7 @@ Sentry for local development Browse the Sentry API schema -- `sentry schema ` — Browse the Sentry API schema +- `sentry schema ` — Browse the Sentry API schema → Full flags and examples: `references/schema.md` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md index 79724f8f2..ee36dd47b 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md @@ -11,7 +11,7 @@ requires: Browse the Sentry API schema -### `sentry schema ` +### `sentry schema ` Browse the Sentry API schema @@ -19,7 +19,7 @@ Browse the Sentry API schema | Argument | Description | |----------|-------------| -| `` | Resource, exact operation ID, or METHOD /path | +| `` | API concept, CLI command path, exact operation ID, or METHOD /path | **Flags:** - `--all - Show all endpoints in a flat list` @@ -34,9 +34,21 @@ sentry schema # Browse issue endpoints sentry schema issues -# View details for a specific operation +# Find list operations (there may be multiple candidates) sentry schema issues list +# Find APIs used by log listing +sentry schema log list + +# Find APIs used by trace listing, including command aliases +sentry schema trace ls + +# Find APIs used by a command group +sentry schema alert metrics + +# Find APIs used by a nested command +sentry schema alert metrics view + # Look up an endpoint by its exact operation ID sentry schema listOrganizationEvents @@ -46,6 +58,9 @@ sentry schema "GET /api/0/organizations/{organization_id_or_slug}/issues/" # Search for monitoring-related endpoints sentry schema --search monitor +# Browse resources matching a glob +sentry schema 'monitor*' + # Flat list of every endpoint sentry schema --all ``` diff --git a/packages/cli/script/check-destructive-commands.ts b/packages/cli/script/check-destructive-commands.ts index 12d61a5a4..a094324eb 100644 --- a/packages/cli/script/check-destructive-commands.ts +++ b/packages/cli/script/check-destructive-commands.ts @@ -1,324 +1,58 @@ #!/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 + * Verify commands that can issue DELETE retain destructive-command safeguards. + * Uses the same source analysis as schema discovery, including private/dynamic + * raw requests that cannot be linked to a public API operation. */ 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"; +import { getAllEndpoints } from "../src/lib/api-schema.js"; +import { analyzeSourceApiUsage } from "./lib/source-api-usage.js"; -/** `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", { +const files = await glob("src/**/*.{ts,tsx}", { 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}`); - } +const sources = new Map( + await Promise.all( + files.map(async (file) => [file, await readFile(file, "utf-8")] as const), + ), +); +const operations = getAllEndpoints().flatMap((endpoint) => + endpoint.fn + ? [ + { + functionName: endpoint.fn, + method: endpoint.method, + path: endpoint.path, + }, + ] + : [], +); +if (!operations.some(({ method }) => method === "DELETE")) { + throw new Error( + "API operation catalog contains no DELETE operations; regenerate the API schema before checking command safety.", + ); } +const analysis = analyzeSourceApiUsage(sources, operations); +const deleting = analysis.commands.filter( + ({ file, name }) => + file.startsWith("src/commands/") && + analysis.exports.get(file)?.get(name)?.deletes, +); +const problems = deleting.filter(({ destructive }) => !destructive); if (problems.length === 0) { console.log( - `✓ All ${deletingCommands} command module(s) that send a DELETE are marked destructive`, + `✓ All ${deleting.length} command export(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}`); +} else { + console.error( + `✗ ${problems.length} command(s) send a DELETE but are not marked destructive:\n`, + ); + for (const { file, name } of problems) console.error(` ${file}#${name}`); + console.error( + "\n Build them with buildDeleteCommand, or pass `destructive: true` to buildCommand.", + ); + process.exitCode = 1; } -console.error( - "\n Build them with buildDeleteCommand, or pass `destructive: true` to buildCommand.", -); -process.exit(1); diff --git a/packages/cli/script/ensure-generated.mjs b/packages/cli/script/ensure-generated.mjs index 59a9275cf..29adccd49 100644 --- a/packages/cli/script/ensure-generated.mjs +++ b/packages/cli/script/ensure-generated.mjs @@ -32,6 +32,7 @@ const GRAMMAR_OUTPUTS = [ ]; /** Produced by `generate:docs`; imported at runtime by `src/`. */ const DOCS_OUTPUTS = [ + "src/generated/command-api.json", "src/generated/skill-content.ts", "src/generated/completion-targets.json", ]; diff --git a/packages/cli/script/generate-api-schema.ts b/packages/cli/script/generate-api-schema.ts index 49ec12ecc..67762debf 100644 --- a/packages/cli/script/generate-api-schema.ts +++ b/packages/cli/script/generate-api-schema.ts @@ -1,238 +1,21 @@ #!/usr/bin/env tsx /** - * Generate API Schema Index from Sentry's OpenAPI Specification + * Generate the CLI discovery index from the installed SDK's operation catalog. * - * Fetches the dereferenced OpenAPI spec from the sentry-api-schema repository - * and extracts a lightweight JSON index of all API endpoints. This index is - * bundled into the CLI for runtime introspection via `sentry schema`. - * - * Data source: https://github.com/getsentry/sentry-api-schema - * - openapi-derefed.json — full dereferenced OpenAPI 3.0 spec - * - * Also reads SDK function names from the installed @sentry/api package to - * map operationIds to their TypeScript SDK function names. - * - * Also reads the `@sentry/api/operations` catalog to list the non-safe - * operations Sentry marks idempotent (`x-sentry-idempotent`), which the - * authenticated fetch retries on transient failures. - * - * Usage: - * tsx script/generate-api-schema.ts - * - * Output: - * src/generated/api-schema.json - * src/generated/idempotent-operations.json + * Usage: tsx script/generate-api-schema.ts + * Outputs: src/generated/api-schema.json and src/generated/idempotent-operations.json */ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; import { operations } from "@sentry/api/operations"; - -/** Resolve the root directory of @sentry/api by finding its main entry point. */ -function sentryApiDir(): string { - // import.meta.resolve returns a file:// URL for the exported entry point - const entryUrl = import.meta.resolve("@sentry/api"); - // Entry is at dist/index.js → go up two levels to get the package root - return resolve(dirname(fileURLToPath(entryUrl)), ".."); -} +import { isIdempotentRequest } from "@sentry/toolkit-core/api-retry"; +import type { IdempotentOperation } from "../src/lib/idempotent-operations.js"; +import { projectApiOperations } from "./lib/api-schema.js"; const OUTPUT_PATH = "src/generated/api-schema.json"; const IDEMPOTENT_OUTPUT_PATH = "src/generated/idempotent-operations.json"; - -/** - * Build the OpenAPI spec URL from the installed @sentry/api version. - * The sentry-api-schema repo tags match the @sentry/api npm version. - */ -async function getOpenApiUrl(): Promise { - const pkgPath = resolve(sentryApiDir(), "package.json"); - const pkg = JSON.parse(await readFile(pkgPath, "utf-8")) as { - version: string; - }; - return `https://raw.githubusercontent.com/getsentry/sentry-api-schema/${pkg.version}/openapi-derefed.json`; -} - -/** Regex to extract path parameters from URL templates */ -const PATH_PARAM_PATTERN = /\{(\w+)\}/g; - -// Single source of truth for the ApiEndpoint type lives in src/lib/api-schema.ts. -// Re-export so this file remains a module (required for top-level await). -export type { ApiEndpoint } from "../src/lib/api-schema.js"; - -import type { ApiEndpoint } from "../src/lib/api-schema.js"; -import type { IdempotentOperation } from "../src/lib/idempotent-operations.js"; -import { isIdempotentRequest } from "@sentry/toolkit-core/api-retry"; - -// --------------------------------------------------------------------------- -// OpenAPI Types (minimal subset we need) -// --------------------------------------------------------------------------- - -type OpenApiSpec = { - paths: Record>; -}; - -type OpenApiOperation = { - operationId?: string; - description?: string; - deprecated?: boolean; - parameters?: OpenApiParameter[]; -}; - -type OpenApiParameter = { - in: "path" | "query" | "header" | "cookie"; - name: string; - required?: boolean; - description?: string; - schema?: { type?: string }; -}; - -// --------------------------------------------------------------------------- -// SDK Function Name Mapping -// --------------------------------------------------------------------------- - -/** - * Build a map from URL+method to SDK function name by parsing - * the @sentry/api index.js bundle. - */ -async function buildSdkFunctionMap(): Promise> { - const pkgDir = resolve(sentryApiDir(), "dist"); - const js = await readFile(`${pkgDir}/index.js`, "utf-8"); - const results = new Map(); - - // Optional options use a rest tuple in newer generated SDKs. - const funcPattern = - /var (\w+) = \((?:\.\.\.\[)?options\S*\) => \(options\S*client \?\? client\)\.(\w+)\(/g; - // Match: url: "..." - const urlPattern = /url: "([^"]+)"/g; - - // Extract all function declarations with their positions - const funcs: { name: string; method: string; index: number }[] = []; - let match = funcPattern.exec(js); - while (match !== null) { - funcs.push({ - name: match[1], - method: match[2].toUpperCase(), - index: match.index, - }); - match = funcPattern.exec(js); - } - - // Extract all URLs with their positions - const urls: { url: string; index: number }[] = []; - match = urlPattern.exec(js); - while (match !== null) { - urls.push({ url: match[1], index: match.index }); - match = urlPattern.exec(js); - } - - // Match each function to its nearest following URL - for (const func of funcs) { - const nextUrl = urls.find((u) => u.index > func.index); - if (nextUrl) { - const key = `${func.method}:${nextUrl.url}`; - results.set(key, func.name); - } - } - - return results; -} - -// --------------------------------------------------------------------------- -// Resource Derivation -// --------------------------------------------------------------------------- - -/** - * Derive the resource name from a URL template. - * Uses the last non-parameter path segment. - * - * @example "/api/0/organizations/{org}/issues/" → "issues" - * @example "/api/0/issues/{issue_id}/" → "issues" - */ -function deriveResource(url: string): string { - const segments = url - .split("/") - .filter((s) => s.length > 0 && !s.startsWith("{")); - const meaningful = segments.filter((s) => s !== "api" && s !== "0"); - return meaningful.at(-1) ?? "unknown"; -} - -/** - * Extract path parameter names from a URL template. - */ -function extractPathParams(url: string): string[] { - const params: string[] = []; - const pattern = new RegExp( - PATH_PARAM_PATTERN.source, - PATH_PARAM_PATTERN.flags, - ); - let match = pattern.exec(url); - while (match !== null) { - params.push(match[1]); - match = pattern.exec(url); - } - return params; -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -const openApiUrl = await getOpenApiUrl(); -console.log(`Fetching OpenAPI spec from ${openApiUrl}...`); -const response = await fetch(openApiUrl); -if (!response.ok) { - throw new Error( - `Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`, - ); -} -const spec = (await response.json()) as OpenApiSpec; - -console.log("Building SDK function name map from @sentry/api..."); -const sdkMap = await buildSdkFunctionMap(); - -const endpoints: ApiEndpoint[] = []; -const HTTP_METHODS = ["get", "post", "put", "delete", "patch"]; - -for (const [urlPath, pathItem] of Object.entries(spec.paths)) { - for (const method of HTTP_METHODS) { - const operation = pathItem[method] as OpenApiOperation | undefined; - if (!operation) { - continue; - } - - const methodUpper = method.toUpperCase(); - const sdkKey = `${methodUpper}:${urlPath}`; - const fn = sdkMap.get(sdkKey) ?? ""; - - const queryParams = (operation.parameters ?? []) - .filter((p) => p.in === "query") - .map((p) => p.name); - - endpoints.push({ - fn, - method: methodUpper, - path: urlPath, - description: (operation.description ?? "").trim(), - pathParams: extractPathParams(urlPath), - queryParams, - deprecated: - operation.deprecated === true || - fn.startsWith("deprecated") || - (operation.operationId ?? "").toLowerCase().includes("deprecated"), - resource: deriveResource(urlPath), - operationId: operation.operationId ?? "", - }); - } -} - -// Sort by resource, then method for stable output -endpoints.sort((a, b) => { - const resourceCmp = a.resource.localeCompare(b.resource); - if (resourceCmp !== 0) { - return resourceCmp; - } - return a.operationId.localeCompare(b.operationId); -}); +const endpoints = projectApiOperations(operations); await mkdir(dirname(OUTPUT_PATH), { recursive: true }); await writeFile(OUTPUT_PATH, JSON.stringify(endpoints, null, 2)); diff --git a/packages/cli/script/generate-command-api.ts b/packages/cli/script/generate-command-api.ts new file mode 100644 index 000000000..8ae5497ae --- /dev/null +++ b/packages/cli/script/generate-command-api.ts @@ -0,0 +1,136 @@ +#!/usr/bin/env tsx +/** Generate command/API relationships from source calls and the public route tree. */ + +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { glob } from "tinyglobby"; +import type { ApiEndpoint, ApiReference } from "../src/lib/api-schema.js"; +import type { RouteMap, RouteTarget } from "../src/lib/introspect.js"; +import { analyzeSourceApiUsage } from "./lib/source-api-usage.js"; + +const OUTPUT_PATH = "src/generated/command-api.json"; + +// The route tree imports these generated files. Bootstrap before importing it; +// generate:skill replaces its stub later in the same generate:docs pipeline. +await mkdir("src/generated", { recursive: true }); +for (const [path, stub] of [ + [OUTPUT_PATH, "{}\n"], + [ + "src/generated/skill-content.ts", + "export const SKILL_FILES: [string, string][] = [];\n", + ], +] as const) { + if ( + !(await access(path).then( + () => true, + () => false, + )) + ) { + await writeFile(path, stub); + } +} + +const files = await glob(["src/**/*.{ts,tsx}"], { + ignore: ["src/generated/**"], +}); +const sources = new Map( + await Promise.all( + files + .sort() + .map(async (file) => [file, await readFile(file, "utf8")] as const), + ), +); +const endpoints = JSON.parse( + await readFile("src/generated/api-schema.json", "utf8"), +) as ApiEndpoint[]; +const analysis = analyzeSourceApiUsage( + sources, + endpoints + .filter((entry) => entry.fn) + .map((entry) => ({ + functionName: entry.fn, + method: entry.method, + path: entry.path, + })), +); + +const { routes } = await import("../src/app.js"); +const { isCommand, publicTargetPaths } = + await import("../src/lib/introspect.js"); +const paths = publicTargetPaths(routes as unknown as RouteMap); +const matched = new Set(); +const references: Record = {}; +const unresolved = new Map; reason: string }>(); + +for (const { file, name } of analysis.commands) { + const module = await import(pathToFileURL(resolve(file)).href); + const target = module[name] as RouteTarget | undefined; + const path = target && paths.get(target); + if (!target || !path || !isCommand(target)) continue; + matched.add(target); + const command = `sentry ${path.join(" ")}`; + const usage = analysis.exports.get(file)?.get(name); + if (!usage) continue; + const entries = (references[command] ??= []); + for (const operation of usage.operations) { + const context = Object.entries(operation.query ?? {}) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`) + .join(", "); + const reference: ApiReference = { + fn: operation.functionName, + ...(context + ? { + note: `Known query context: ${context}. The command may add filters or requests.`, + } + : {}), + }; + if ( + !entries.some( + (entry) => entry.fn === reference.fn && entry.note === reference.note, + ) + ) + entries.push(reference); + } + for (const request of usage.unresolvedRequests) { + const key = `${request.file}:${request.line}`; + const diagnostic = unresolved.get(key) ?? { + commands: new Set(), + reason: request.reason, + }; + diagnostic.commands.add(command); + unresolved.set(key, diagnostic); + } +} + +for (const [target, path] of paths) { + if (isCommand(target) && !matched.has(target)) { + console.warn( + `API discovery: no source command export found for sentry ${path.join(" ")}`, + ); + } +} +for (const [location, { commands, reason }] of unresolved) { + console.warn( + `API discovery: ${location}: ${reason} (${[...commands].sort().join(", ")})`, + ); +} + +const catalog = Object.fromEntries( + Object.entries(references) + .filter(([, entries]) => entries.length) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([command, entries]) => [ + command, + entries.sort( + (a, b) => + a.fn.localeCompare(b.fn) || + (a.note ?? "").localeCompare(b.note ?? ""), + ), + ]), +); +await writeFile(OUTPUT_PATH, `${JSON.stringify(catalog, null, 2)}\n`); +console.log( + `Generated ${OUTPUT_PATH} (${Object.keys(catalog).length} commands; ${unresolved.size} unresolved requests)`, +); diff --git a/packages/cli/script/lib/api-schema.ts b/packages/cli/script/lib/api-schema.ts new file mode 100644 index 000000000..56ea95f38 --- /dev/null +++ b/packages/cli/script/lib/api-schema.ts @@ -0,0 +1,44 @@ +/** Project SDK operation metadata into the CLI's bundled discovery index. */ + +import type { ApiOperation } from "@sentry/api/operations"; +import type { ApiEndpoint } from "../../src/lib/api-schema.js"; + +/** Keep CLI grouping, output names and legacy deprecation detection local. */ +export function projectApiOperations( + operations: readonly ApiOperation[], +): ApiEndpoint[] { + return operations + .map((operation) => ({ + fn: operation.functionName, + method: operation.method, + path: operation.path, + summary: operation.summary, + description: operation.description, + tags: operation.tags ? [...operation.tags] : undefined, + pathParams: [...operation.pathParameters], + queryParams: operation.queryParameters.map((parameter) => parameter.name), + queryParameters: operation.queryParameters.map((parameter) => ({ + ...parameter, + enum: parameter.enum ? [...parameter.enum] : undefined, + })), + deprecated: + operation.deprecated || + operation.functionName.startsWith("deprecated") || + operation.operationId.toLowerCase().includes("deprecated"), + resource: deriveResource(operation.path), + operationId: operation.operationId, + })) + .sort((a, b) => { + const resourceCmp = a.resource.localeCompare(b.resource); + return resourceCmp || a.operationId.localeCompare(b.operationId); + }); +} + +/** Keep the existing resource grouping: the last literal URL segment. */ +function deriveResource(url: string): string { + const segments = url + .split("/") + .filter((s) => s.length > 0 && !s.startsWith("{")); + const meaningful = segments.filter((s) => s !== "api" && s !== "0"); + return meaningful.at(-1) ?? "unknown"; +} diff --git a/packages/cli/script/lib/source-api-usage.ts b/packages/cli/script/lib/source-api-usage.ts new file mode 100644 index 000000000..cd2687cc6 --- /dev/null +++ b/packages/cli/script/lib/source-api-usage.ts @@ -0,0 +1,70 @@ +/** CLI conventions for the SDK's build-time API usage analyzer. */ + +import { + analyzeSourceApiUsage as analyze, + type SourceApiOperation, +} from "@sentry/api/source-analysis"; + +export type { SourceApiOperation } from "@sentry/api/source-analysis"; + +/** Discover command API usage and DELETE declarations without executing commands. */ +export function analyzeSourceApiUsage( + sources: ReadonlyMap, + operations: readonly SourceApiOperation[], +) { + const result = analyze(sources, operations, { + boundaries: [ + "buildCommand", + "buildListCommand", + "buildOrgListCommand", + "buildDeleteCommand", + ], + requests: [ + { + functionName: "apiRequest", + pathArgument: 0, + optionsArgument: 1, + queryProperty: "params", + }, + { + functionName: "apiRequestToRegion", + pathArgument: 1, + optionsArgument: 2, + queryProperty: "params", + }, + { + functionName: "apiRequestToRegionNoContent", + pathArgument: 1, + optionsArgument: 2, + queryProperty: "params", + }, + ], + queryParameters: ["dataset", "query"], + declarationFlags: ["destructive"], + }); + + return { + exports: new Map( + [...result.exports].map(([file, exports]) => [ + file, + new Map( + [...exports].map(([name, usage]) => [ + name, + { + operations: usage.operations, + unresolvedRequests: usage.unresolvedRequests, + deletes: usage.methods.includes("DELETE"), + }, + ]), + ), + ]), + ), + commands: result.boundaries.map(({ file, name, factories, flags }) => ({ + file, + name, + destructive: + flags.includes("destructive") || + factories.includes("buildDeleteCommand"), + })), + }; +} diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index 8b5a2463a..1266a69b0 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -3,29 +3,20 @@ * * Browse and search the Sentry API schema. Shows available resources, * operations, and endpoint details from the generated API index. - * - * Usage: - * sentry schema → list all resources - * sentry schema --all → flat list of all endpoints - * sentry schema --search → search endpoints by keyword - * sentry schema → show endpoints for a resource - * sentry schema → show detailed endpoint info - * sentry schema → show details by exact operation ID - * sentry schema "GET /api/0/..." → show details for one endpoint - * sentry schema monitor* → glob search for resources */ import type { SentryContext } from "../context.js"; import { type ApiEndpoint, + type CommandApiReference, findEndpointsByIdentifier, findEndpointsByPath, getAllEndpoints, - getEndpoint, getEndpointsByResource, getResourceSummaries, parseEndpointQuery, type ResourceSummary, + type SchemaEndpoint, searchEndpoints, } from "../lib/api-schema.js"; import { buildCommand } from "../lib/command.js"; @@ -39,6 +30,8 @@ import { } from "../lib/formatters/markdown.js"; import { CommandOutput } from "../lib/formatters/output.js"; import { fuzzyMatch } from "../lib/fuzzy.js"; +import { getCommandApiReferences, type RouteMap } from "../lib/introspect.js"; +import { filterByName, isGlobFilter } from "../lib/list-filter.js"; // --------------------------------------------------------------------------- // Output data types @@ -47,7 +40,7 @@ import { fuzzyMatch } from "../lib/fuzzy.js"; /** Discriminated union of all possible schema command outputs */ type SchemaResult = | { kind: "resources"; resources: ResourceSummary[] } - | { kind: "endpoints"; endpoints: readonly ApiEndpoint[] } + | { kind: "endpoints"; endpoints: readonly SchemaEndpoint[] } | { kind: "endpoint"; endpoint: ApiEndpoint }; // --------------------------------------------------------------------------- @@ -95,7 +88,7 @@ function formatResourceList(resources: ResourceSummary[]): string { } /** Format a flat list of endpoints */ -function formatEndpointList(endpoints: readonly ApiEndpoint[]): string { +function formatEndpointList(endpoints: readonly SchemaEndpoint[]): string { if (endpoints.length === 0) { return muted("No endpoints found."); } @@ -108,9 +101,22 @@ function formatEndpointList(endpoints: readonly ApiEndpoint[]): string { const colored = formatMethod(e.method); const padded = `${colored}${" ".repeat(maxMethod + padding - e.method.length)}`; const path = e.path; - const label = muted(e.operationId || e.fn); + const label = muted(e.summary || e.operationId || e.fn); const deprecated = e.deprecated ? yellow(" [deprecated]") : ""; - return `${padded}${path} ${label}${deprecated}`; + const evidence = e.match + ? renderMarkdown( + [ + ...e.match.reasons + .slice(0, 2) + .map((reason) => `- Matches: ${reason}`), + ...e.match.relatedCommands.map( + ({ command, note }) => + `- Used by: \`${command}\`${note ? ` — ${note}` : ""}`, + ), + ].join("\n"), + ) + : ""; + return `${padded}${path} ${label}${deprecated}${evidence ? `\n${evidence}` : ""}`; }) .join("\n"); } @@ -118,6 +124,9 @@ function formatEndpointList(endpoints: readonly ApiEndpoint[]): string { /** Format a single endpoint in detail using mdKvTable + renderMarkdown */ function formatEndpointDetail(endpoint: ApiEndpoint): string { const kvRows: [string, string][] = [ + ...(endpoint.summary + ? [["Summary", endpoint.summary] as [string, string]] + : []), ["Resource", endpoint.resource], ["Operation", endpoint.operationId], ...(endpoint.fn @@ -134,7 +143,15 @@ function formatEndpointDetail(endpoint: ApiEndpoint): string { } if (endpoint.queryParams.length > 0) { - kvRows.push(["Query Params", endpoint.queryParams.join(", ")]); + const parameters = endpoint.queryParameters?.map( + (parameter) => + `${parameter.name}${parameter.required ? " (required)" : ""}` + + (parameter.enum?.length ? `: ${parameter.enum.join(", ")}` : ""), + ); + kvRows.push([ + "Query Params", + (parameters ?? endpoint.queryParams).join("; "), + ]); } const heading = `## ${endpoint.method} \`${endpoint.path}\``; @@ -226,15 +243,6 @@ function noResourceMatchError(resource: string): ResolutionError { ); } -/** Join leftover positionals so `--search GET /api/0/...` (unquoted) still parses. */ -function resolveSearchQuery(search: string, args: string[]): string { - if (args.length === 0) { - return search; - } - const combined = [search, ...args].join(" "); - return parseEndpointQuery(combined).path ? combined : search; -} - function queryLabel(resource: string, operation?: string): string { return operation ? `${resource} ${operation}` : resource; } @@ -324,8 +332,13 @@ function resolvePathLookup( } /** Resolve one exact SDK function name or OpenAPI operation ID. */ -function resolveIdentifierLookup(identifier: string): SchemaResult | undefined { - const matches = findEndpointsByIdentifier(identifier); +function resolveIdentifierLookup( + identifier: string, + method?: string, +): SchemaResult | undefined { + const matches = findEndpointsByIdentifier(identifier).filter( + (endpoint) => !method || endpoint.method === method, + ); const [single] = matches; if (matches.length === 1 && single) { return { kind: "endpoint", endpoint: single }; @@ -336,6 +349,25 @@ function resolveIdentifierLookup(identifier: string): SchemaResult | undefined { return; } +const QUERY_WORD_SEPARATOR = /\s+/; + +/** Explicit resource globs retain their resource-only behavior. */ +function resolveGlobQuery(resource: string): SchemaResult { + const matched = filterByName(getResourceSummaries(), resource, (entry) => [ + entry.name, + ]); + if (matched.length === 0) { + throw new ResolutionError( + `Pattern "${resource}"`, + "matched no resources", + "sentry schema", + ["sentry schema --all List every endpoint in a flat table"], + ); + } + const endpoints = matched.flatMap((r) => getEndpointsByResource(r.name)); + return { kind: "endpoints", endpoints }; +} + /** * Resolve a resource + optional operation into a SchemaResult. * Throws ResolutionError for no-match cases; throws OutputError with the @@ -346,66 +378,52 @@ function resolveIdentifierLookup(identifier: string): SchemaResult | undefined { export function resolveResourceQuery( resource: string, operation?: string, + references: readonly CommandApiReference[] = [], ): SchemaResult { + const query = queryLabel(resource, operation); const pathQuery = resolvePathQuery(resource, operation); if (pathQuery) { return resolvePathLookup(pathQuery, resource, operation); } - // Glob-style search: if the resource arg contains * or ?, match resources - if (resource.includes("*") || resource.includes("?")) { - // Convert glob pattern to regex: * → .*, ? → ., escape other special chars - const escaped = resource - .toLowerCase() - .replace(/[.+^${}()|[\]\\]/g, "\\$&") - .replace(/\*/g, ".*") - .replace(/\?/g, "."); - const pattern = new RegExp(`^${escaped}$`); - const allResources = getResourceSummaries(); - const matched = allResources.filter((r) => - pattern.test(r.name.toLowerCase()), - ); - if (matched.length === 0) { - throw new ResolutionError( - `Pattern "${resource}"`, - "matched no resources", - "sentry schema", - ["sentry schema --all List every endpoint in a flat table"], - ); - } - const endpoints = matched.flatMap((r) => getEndpointsByResource(r.name)); - return { kind: "endpoints", endpoints }; + const parsed = parseEndpointQuery(query); + const identifierResult = resolveIdentifierLookup(parsed.text, parsed.method); + if (identifierResult) { + return identifierResult; } - // Resource + operation: show single endpoint detail - if (operation) { - const endpoint = getEndpoint(resource, operation); - if (endpoint) { - return { kind: "endpoint", endpoint }; - } - // Resource exists but the operation didn't match: show its endpoints so - // the user can pick a valid operation. This is scoped to the resource, - // not the full list, so it isn't misleading. - const resourceEndpoints = getEndpointsByResource(resource); - if (resourceEndpoints.length > 0) { - throw new OutputError({ - kind: "endpoints", - endpoints: resourceEndpoints, - } satisfies SchemaResult); - } - throw noResourceMatchError(resource); + // Globs remain an explicit resource filter, separate from concept discovery. + if (!(operation || query.includes(" ")) && isGlobFilter(resource)) { + return resolveGlobQuery(resource); } - // Resource only: show all endpoints for that resource - const endpoints = getEndpointsByResource(resource); - if (endpoints.length === 0) { - const identifierResult = resolveIdentifierLookup(resource); - if (identifierResult) { - return identifierResult; + const [resourceName, ...operationWords] = parsed.text + .trim() + .split(QUERY_WORD_SEPARATOR); + const resourceEndpoints = getEndpointsByResource(resourceName ?? "").filter( + (endpoint) => !parsed.method || endpoint.method === parsed.method, + ); + const operationQuery = operationWords.join(" "); + if (operationQuery) { + const exact = findEndpointsByIdentifier(operationQuery).filter((endpoint) => + resourceEndpoints.includes(endpoint), + ); + if (exact.length === 1 && exact[0]) { + return { kind: "endpoint", endpoint: exact[0] }; } - throw noResourceMatchError(resource); } - return { kind: "endpoints", endpoints }; + + const matches = searchEndpoints(query, undefined, references); + if (matches.length > 0) { + return { kind: "endpoints", endpoints: matches }; + } + if (operationQuery && resourceEndpoints.length > 0) { + throw new OutputError({ + kind: "endpoints", + endpoints: resourceEndpoints, + } satisfies SchemaResult); + } + throw noResourceMatchError(query); } // --------------------------------------------------------------------------- @@ -423,7 +441,8 @@ export const schemaCommand = buildCommand({ brief: "Browse the Sentry API schema", fullDescription: "Browse and search the Sentry API schema. Shows available resources, " + - "operations, and endpoint details. Use with --json for machine-readable output.", + "operations, and endpoint details. Search by API concepts or CLI command paths; " + + "discovery results explain matches and related commands. Use with --json for machine-readable output.", examples: [ { description: "List all API resources", command: "sentry schema" }, { @@ -431,9 +450,26 @@ export const schemaCommand = buildCommand({ command: "sentry schema issues", }, { - description: "View details for a specific operation", + description: "Find list operations (there may be multiple candidates)", command: "sentry schema issues list", }, + { + description: "Find APIs used by log listing", + command: "sentry schema log list", + }, + { + description: + "Find APIs used by trace listing, including command aliases", + command: "sentry schema trace ls", + }, + { + description: "Find APIs used by a command group", + command: "sentry schema alert metrics", + }, + { + description: "Find APIs used by a nested command", + command: "sentry schema alert metrics view", + }, { description: "Look up an endpoint by its exact operation ID", command: "sentry schema listOrganizationEvents", @@ -447,6 +483,10 @@ export const schemaCommand = buildCommand({ description: "Search for monitoring-related endpoints", command: "sentry schema --search monitor", }, + { + description: "Browse resources matching a glob", + command: "sentry schema 'monitor*'", + }, { description: "Flat list of every endpoint", command: "sentry schema --all", @@ -475,50 +515,55 @@ export const schemaCommand = buildCommand({ positional: { kind: "array", parameter: { - brief: "Resource, exact operation ID, or METHOD /path", + brief: + "API concept, CLI command path, exact operation ID, or METHOD /path", parse: String, - placeholder: "resource", + placeholder: "query", }, }, }, - // Stricli requires AsyncGenerator but schema queries are synchronous (in-memory JSON) async *func(this: SentryContext, flags: SchemaFlags, ...args: string[]) { - const [resource, operation] = args; + const query = flags.search + ? [flags.search, ...args].join(" ") + : args.join(" "); - // --search takes priority - if (flags.search) { - const query = resolveSearchQuery(flags.search, args); - const results = searchEndpoints(query); - if (results.length === 0) { - throw new OutputError({ - kind: "endpoints", - endpoints: [], - } satisfies SchemaResult); - } + // --search takes priority over --all. + if (flags.all && !flags.search) { return yield new CommandOutput({ kind: "endpoints", - endpoints: results, + endpoints: getAllEndpoints(), }); } - // --all: flat endpoint list - if (flags.all) { + if (!query) { return yield new CommandOutput({ - kind: "endpoints", - endpoints: getAllEndpoints(), + kind: "resources", + resources: getResourceSummaries(), }); } - // No positional args: show resource summary - if (!resource) { - const resources = getResourceSummaries(); + const { routes } = await import("../app.js"); + const references = getCommandApiReferences( + routes as unknown as RouteMap, + parseEndpointQuery(query).text, + ); + + if (flags.search) { + const results = searchEndpoints(query, undefined, references); + if (results.length === 0) { + throw new OutputError({ + kind: "endpoints", + endpoints: [], + } satisfies SchemaResult); + } return yield new CommandOutput({ - kind: "resources", - resources, + kind: "endpoints", + endpoints: results, }); } - // Resolve resource (with optional operation or glob pattern) - return yield new CommandOutput(resolveResourceQuery(resource, operation)); + return yield new CommandOutput( + resolveResourceQuery(query, undefined, references), + ); }, }); diff --git a/packages/cli/src/lib/api-schema.ts b/packages/cli/src/lib/api-schema.ts index 8b807356d..2cd5f5c0b 100644 --- a/packages/cli/src/lib/api-schema.ts +++ b/packages/cli/src/lib/api-schema.ts @@ -6,22 +6,69 @@ * `script/generate-api-schema.ts` from Sentry's OpenAPI specification. */ +import type { ApiOperation } from "@sentry/api/operations"; import endpoints from "../generated/api-schema.json"; +import { fuzzyMatch } from "./fuzzy.js"; + +/** Searchable query parameter metadata from the pinned OpenAPI specification. */ +export type ApiQueryParameter = { + /** Parameter name accepted by the endpoint. */ + name: string; + /** Usage description from OpenAPI. */ + description?: string; + /** OpenAPI schema type, when declared. */ + type?: string; + /** Whether the request must include this parameter. */ + required: boolean; + /** Allowed scalar values, or item values for an array parameter. */ + enum?: (string | number | boolean | null)[]; +}; + +/** An API used by a command, with context rather than a claim of equivalence. */ +export type ApiReference = { + /** Exact SDK function name identifying the generated endpoint. */ + fn: string; + /** Command-specific selectors or context, not a complete API recipe. */ + note?: string; +}; + +/** An API reference attributed to its public CLI command. */ +export type CommandApiReference = ApiReference & { + /** Canonical command path, including the `sentry` prefix. */ + command: string; +}; + +/** Discovery evidence is additive; exact lookups return plain ApiEndpoints. */ +export type SchemaEndpoint = ApiEndpoint & { + /** Present only for discovery results, never exact endpoint lookups. */ + match?: { + /** Metadata or command relationships that matched the query. */ + reasons: string[]; + /** Commands matched by this CLI query, not every caller of the endpoint. */ + relatedCommands: { command: string; note?: string }[]; + }; +}; /** A single API endpoint entry from the generated schema */ export type ApiEndpoint = { /** SDK function name from @sentry/api (empty string if unmapped) */ fn: string; /** HTTP method (GET, POST, PUT, DELETE, PATCH) */ - method: string; + method: ApiOperation["method"]; /** URL path template with {param} placeholders */ path: string; /** Human-readable description of the endpoint */ description: string; + /** Short endpoint description from OpenAPI. */ + summary?: string; + /** OpenAPI categories available for discovery. */ + tags?: string[]; /** Path parameter names extracted from the URL template */ pathParams: string[]; /** Query parameter names from the OpenAPI spec */ queryParams: string[]; + /** Searchable parameter details, alongside the compatible name-only list. */ + queryParameters?: ApiQueryParameter[]; /** Whether the endpoint is deprecated */ deprecated: boolean; /** Resource group name derived from URL (e.g., "projects", "issues") */ @@ -88,10 +135,11 @@ export function parseEndpointQuery(query: string): ParsedEndpointQuery { export function findEndpointsByPath( path: string, method?: string, + catalog: readonly ApiEndpoint[] = schema, ): ApiEndpoint[] { const methodUpper = method?.toUpperCase(); const normalized = normalizeApiPath(path); - const candidates = schema.filter((endpoint) => { + const candidates = catalog.filter((endpoint) => { if (methodUpper && endpoint.method !== methodUpper) { return false; } @@ -127,14 +175,17 @@ export function findEndpointsByPath( * Find endpoints by an exact SDK function name or OpenAPI operation ID. * * Matching is case-insensitive but deliberately not fuzzy: partial operation - * names can match many unrelated endpoints and should use `--search` instead. + * names can match many unrelated endpoints and are handled by discovery. */ -export function findEndpointsByIdentifier(identifier: string): ApiEndpoint[] { +export function findEndpointsByIdentifier( + identifier: string, + catalog: readonly ApiEndpoint[] = schema, +): ApiEndpoint[] { const normalized = identifier.trim().toLowerCase(); if (!normalized) { return []; } - return schema.filter( + return catalog.filter( (endpoint) => endpoint.fn.toLowerCase() === normalized || endpoint.operationId.toLowerCase() === normalized, @@ -153,69 +204,159 @@ export function getEndpointsByResource(resource: string): ApiEndpoint[] { return schema.filter((e) => e.resource.toLowerCase() === lower); } -/** - * Get a specific endpoint by resource + operationId substring (case-insensitive). - * Prefers operationIds that start with the query over substring matches. - */ -export function getEndpoint( - resource: string, - operationQuery: string, -): ApiEndpoint | undefined { - const lowerResource = resource.toLowerCase(); - const lowerQuery = operationQuery.toLowerCase(); - - const candidates = schema.filter( - (e) => e.resource.toLowerCase() === lowerResource, +const CAMEL_CASE_BOUNDARY = /([a-z0-9])([A-Z])/g; +const WORD_SEPARATOR = /[^a-z0-9]+/; +const PARAM_PLACEHOLDER = /\{[^}]+\}/g; + +function words(text: string): Set { + return new Set( + text + .replace(CAMEL_CASE_BOUNDARY, "$1 $2") + .toLowerCase() + .split(WORD_SEPARATOR) + .filter(Boolean), ); +} - // Priority 1: operationId starts with the query (e.g., "list" → "List an Org's Issues") - const startMatch = candidates.find( - (e) => - e.operationId.toLowerCase().startsWith(lowerQuery) || - e.fn.toLowerCase().startsWith(lowerQuery), - ); - if (startMatch) { - return startMatch; +type SearchField = { text: string; reason: string; weight: number }; + +function searchFields(endpoint: ApiEndpoint): SearchField[] { + const fields: SearchField[] = [ + { + text: endpoint.resource, + reason: `API resource: ${endpoint.resource}`, + weight: 80, + }, + { text: endpoint.summary ?? "", reason: "Operation summary", weight: 60 }, + { + text: `${endpoint.fn} ${endpoint.operationId}`, + reason: "Operation name", + weight: 40, + }, + { + text: endpoint.path.replace(PARAM_PLACEHOLDER, ""), + reason: "API path", + weight: 30, + }, + { text: endpoint.tags?.join(" ") ?? "", reason: "API tags", weight: 20 }, + { text: endpoint.description, reason: "Operation description", weight: 10 }, + ]; + for (const parameter of endpoint.queryParameters ?? []) { + fields.push({ + text: parameter.name, + reason: `Query parameter: ${parameter.name}`, + weight: 20, + }); + for (const value of parameter.enum ?? []) { + fields.push({ + text: String(value), + reason: `Query parameter: ${parameter.name}=${String(value)}`, + weight: 100, + }); + } + if (parameter.description) { + fields.push({ + text: parameter.description, + reason: `Query parameter description: ${parameter.name}`, + weight: 10, + }); + } } + return fields.sort((a, b) => b.weight - a.weight); +} - // Priority 2: substring match anywhere in operationId or fn - return candidates.find( - (e) => - e.operationId.toLowerCase().includes(lowerQuery) || - e.fn.toLowerCase().includes(lowerQuery), - ); +function matchEndpoint( + endpoint: ApiEndpoint, + tokens: Set, + references: readonly CommandApiReference[], + allowTypos: boolean, +): { endpoint: SchemaEndpoint; score: number } | undefined { + const relatedCommands = references + .filter((ref) => ref.fn === endpoint.fn) + .map(({ command, note }) => ({ command, ...(note ? { note } : {}) })); + const scores = new Map(); + const reasons: string[] = []; + for (const field of searchFields(endpoint)) { + const text = field.text.toLowerCase(); + const fieldWords = allowTypos ? [...words(field.text)] : []; + const matches = [...tokens].filter((token) => + allowTypos + ? fuzzyMatch(token, fieldWords, { mode: "search" }).length > 0 + : text.includes(token), + ); + if (matches.length === 0) { + continue; + } + for (const token of matches) { + scores.set(token, Math.max(scores.get(token) ?? 0, field.weight)); + } + reasons.push(field.reason); + } + if (relatedCommands.length === 0 && scores.size !== tokens.size) { + return; + } + return { + endpoint: { ...endpoint, match: { reasons, relatedCommands } }, + score: [...scores.values()].reduce( + (sum, value) => sum + value, + relatedCommands.length > 0 ? 1000 : 0, + ), + }; } /** - * Search endpoints by query string. - * Path-shaped input (`GET /api/0/...`, `/api/0/...`, or a Sentry API URL) - * uses path matching. Anything else is a case-insensitive substring of - * fn, path, description, resource, or operationId. + * Discover API capabilities, retaining why each candidate matched. All query + * words must match, unless a command uses a related operation. + * Literal partial matches take precedence; typos are considered only when + * no literal or command-related candidates exist. + * References must be scoped to this query by getCommandApiReferences; they + * establish a semantic match even when the API uses different vocabulary. + * Exact identifiers and paths keep their existing, unannotated JSON shape. */ -export function searchEndpoints(query: string): ApiEndpoint[] { +export function searchEndpoints( + query: string, + catalog: readonly ApiEndpoint[] = schema, + references: readonly CommandApiReference[] = [], +): SchemaEndpoint[] { const parsed = parseEndpointQuery(query); if (parsed.path) { - return findEndpointsByPath(parsed.path, parsed.method); + return findEndpointsByPath(parsed.path, parsed.method, catalog); } - - const lower = parsed.text.toLowerCase(); - if (!lower) { + const exact = findEndpointsByIdentifier(parsed.text, catalog); + if (exact.length > 0) { + return exact.filter( + (endpoint) => !parsed.method || endpoint.method === parsed.method, + ); + } + const tokens = words(parsed.text); + if (tokens.size === 0) { return []; } - - const matches = schema.filter( - (e) => - e.fn.toLowerCase().includes(lower) || - e.path.toLowerCase().includes(lower) || - e.description.toLowerCase().includes(lower) || - e.resource.toLowerCase().includes(lower) || - e.operationId.toLowerCase().includes(lower), - ); - if (!parsed.method) { - return matches; + const matches: { endpoint: SchemaEndpoint; score: number }[] = []; + for (const allowTypos of [false, true]) { + for (const endpoint of catalog) { + if (parsed.method && endpoint.method !== parsed.method) { + continue; + } + const match = matchEndpoint(endpoint, tokens, references, allowTypos); + if (match) { + matches.push(match); + } + } + if (matches.length > 0) { + break; + } } - const methodMatches = matches.filter((e) => e.method === parsed.method); - return methodMatches.length > 0 ? methodMatches : matches; + // A stable tie-breaker prevents catalog insertion order from choosing an API. + matches.sort( + (a, b) => + Number(a.endpoint.deprecated) - Number(b.endpoint.deprecated) || + b.score - a.score || + a.endpoint.path.localeCompare(b.endpoint.path) || + a.endpoint.method.localeCompare(b.endpoint.method) || + a.endpoint.fn.localeCompare(b.endpoint.fn), + ); + return matches.map((match) => match.endpoint); } function extractApiPath(input: string): string | undefined { diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index bacbe52cf..a64c37216 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -10,11 +10,13 @@ * for introspection and documentation generation. */ +import generatedCommandApi from "../generated/command-api.json"; import { type CommandExample, exampleSteps } from "./command-example.js"; import { type CompletionTarget, getCompletionTarget, } from "./completion-target.js"; +import type { ApiReference, CommandApiReference } from "./api-schema.js"; import { extractSchemaFields, type SchemaFieldInfo, @@ -30,6 +32,7 @@ export type RouteMapEntry = { name: { original: string }; target: RouteTarget; hidden: boolean; + aliases?: readonly string[]; }; /** A routing target is either a route map (group) or a command */ @@ -490,26 +493,33 @@ export function extractCommandCompletions( ); } -/** Matches the "sentry " prefix at the start of a command path. */ -const SENTRY_PREFIX_RE = /^sentry /; - /** Maximum number of fuzzy suggestions to include in an UnresolvedPath. */ const MAX_SUGGESTIONS = 3; +const COMMAND_PATH_SEPARATOR = /\s+/; + +/** Find canonical public paths so hidden aliases cannot expose internal commands. */ +export function publicTargetPaths( + routeMap: RouteMap, + prefix: readonly string[] = [], + paths = new Map(), +): Map { + for (const entry of routeMap.getAllEntries()) { + if (entry.hidden || paths.has(entry.target)) { + continue; + } + const path = [...prefix, entry.name.original]; + paths.set(entry.target, path); + if (isRouteMap(entry.target)) { + publicTargetPaths(entry.target, path, paths); + } + } + return paths; +} /** - * Resolve a command path through the route tree. - * - * Navigates the tree using the provided path segments: - * - Single segment (e.g. ["issue"]) → returns the route group if it's a RouteMap, - * or the command if it's a standalone command - * - Two segments (e.g. ["issue", "list"]) → returns the specific subcommand - * - * When a segment doesn't match, returns an {@link UnresolvedPath} with - * fuzzy-matched suggestions from the available routes at that level. - * - * @param routeMap - Top-level Stricli route map - * @param path - Command path segments (e.g. ["issue", "list"]) - * @returns Resolved command/group, unresolved with suggestions, or null for empty paths + * Resolve a full command path using the registered route names and aliases. + * Hidden aliases are accepted only when their target also has a public path. + * Results always use the canonical public spelling; no command is executed. */ export function resolveCommandPath( routeMap: RouteMap, @@ -519,85 +529,80 @@ export function resolveCommandPath( return null; } - const first = path[0]; - const rest = path.slice(1); - - // length === 0 is handled above; this guard helps TS narrow the type - if (first === undefined) { - return null; - } - - // Collect visible entries once — used for both exact match and fuzzy fallback - const visibleEntries = routeMap.getAllEntries().filter((e) => !e.hidden); - const entry = visibleEntries.find((e) => e.name.original === first); + const publicPaths = publicTargetPaths(routeMap); + let target: RouteTarget = routeMap; + let canonicalPath: string[] = []; - if (!entry) { - const names = visibleEntries.map((e) => e.name.original); - return { - kind: "unresolved", - input: first, - suggestions: fuzzyMatch(first, names, { maxResults: MAX_SUGGESTIONS }), - }; - } - - const target = entry.target; - - // No more path segments — return what we found - if (rest.length === 0) { - if (isRouteMap(target)) { - return { - kind: "group", - info: { - name: entry.name.original, - brief: target.brief, - commands: extractRouteGroupCommands(target, entry.name.original), - }, - }; + for (const segment of path) { + if (!isRouteMap(target)) { + return null; } - if (isCommand(target)) { + const entries: RouteMapEntry[] = target + .getAllEntries() + .filter( + (candidate) => !candidate.hidden || publicPaths.has(candidate.target), + ); + const entry: RouteMapEntry | undefined = + entries.find((candidate) => candidate.name.original === segment) ?? + entries.find((candidate) => candidate.aliases?.includes(segment)); + if (!entry) { return { - kind: "command", - info: buildCommandInfo(target, `sentry ${entry.name.original}`), + kind: "unresolved", + input: segment, + suggestions: fuzzyMatch( + segment, + entries + .filter((candidate) => !candidate.hidden) + .map((candidate) => candidate.name.original), + { maxResults: MAX_SUGGESTIONS }, + ), }; } - return null; - } - - // More path segments — must be a route map to navigate deeper - if (!isRouteMap(target)) { - return null; + canonicalPath = entry.hidden + ? [...(publicPaths.get(entry.target) ?? [])] + : [...canonicalPath, entry.name.original]; + target = entry.target; } - // Recurse into the sub-route map with remaining path segments - const subResult = resolveCommandPath(target, rest); - if (!subResult) { - return null; - } - - // Prepend the parent route segment to all paths in the result - const parentPrefix = entry.name.original; - const prependPrefix = (p: string) => - p.replace(SENTRY_PREFIX_RE, `sentry ${parentPrefix} `); - - if (subResult.kind === "command") { - return { - kind: "command", - info: { ...subResult.info, path: prependPrefix(subResult.info.path) }, - }; - } - if (subResult.kind === "group") { + const name = canonicalPath.join(" "); + if (isRouteMap(target)) { return { kind: "group", info: { - ...subResult.info, - name: `${parentPrefix} ${subResult.info.name}`, - commands: subResult.info.commands.map((cmd) => ({ - ...cmd, - path: prependPrefix(cmd.path), - })), + name, + brief: target.brief, + commands: extractRouteGroupCommands(target, name), }, }; } + if (isCommand(target)) { + return { + kind: "command", + info: buildCommandInfo(target, `sentry ${name}`), + }; + } + return null; +} - return subResult; +/** Collect related APIs for one exact command path or all commands in a group. */ +export function getCommandApiReferences( + routeMap: RouteMap, + query: string, + apiByCommand: Readonly< + Record + > = generatedCommandApi, +): CommandApiReference[] { + const path = query.trim().split(COMMAND_PATH_SEPARATOR).filter(Boolean); + const resolved = resolveCommandPath(routeMap, path); + if (!resolved || resolved.kind === "unresolved") { + return []; + } + const commands = + resolved.kind === "command" ? [resolved.info] : resolved.info.commands; + return commands.flatMap((command) => + (apiByCommand[command.path] ?? []).map((reference) => ({ + ...reference, + command: command.path, + })), + ); } diff --git a/packages/cli/test/commands/schema-path.test.ts b/packages/cli/test/commands/schema-path.test.ts index 4b118d817..965d235e1 100644 --- a/packages/cli/test/commands/schema-path.test.ts +++ b/packages/cli/test/commands/schema-path.test.ts @@ -5,7 +5,8 @@ import { describe, expect, test, vi } from "vitest"; import { schemaCommand } from "../../src/commands/schema.js"; import type { SentryContext } from "../../src/context.js"; -import { ResolutionError } from "../../src/lib/errors.js"; +import { getAllEndpoints } from "../../src/lib/api-schema.js"; +import { OutputError, ResolutionError } from "../../src/lib/errors.js"; const ORG_ISSUES = "/api/0/organizations/{organization_id_or_slug}/issues/"; const SKILL_QUERY = `GET ${ORG_ISSUES}`; @@ -35,24 +36,202 @@ function stdoutOf(stdoutWrite: ReturnType): string { } describe("sentry schema path lookup", () => { - test("METHOD + path returns that endpoint", async () => { + test.each([ + ["listOrganizationIssues"], + ["issues", "listOrganizationIssues"], + ["GET", "listOrganizationIssues"], + ["GET", "issues", "listOrganizationIssues"], + ["GET listOrganizationIssues"], + [SKILL_QUERY], + ["GET", ORG_ISSUES], + ])("exact lookup preserves the endpoint object for %j", async (...args) => { const { context, stdoutWrite } = createMockContext(); const func = await schemaCommand.loader(); - await func.call(context, { json: true, all: false }, SKILL_QUERY); + await func.call(context, { json: true, all: false }, ...args); + expect(JSON.parse(stdoutOf(stdoutWrite))).toEqual( + getAllEndpoints().find( + (endpoint) => endpoint.fn === "listOrganizationIssues", + ), + ); + }); - const parsed = JSON.parse(stdoutOf(stdoutWrite)); - expect(parsed.fn).toBe("listOrganizationIssues"); - expect(parsed.method).toBe("GET"); - expect(parsed.path).toBe(ORG_ISSUES); + test("--search keeps exact HTTP-qualified identifiers in an array and rejects a different method", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await schemaCommand.loader(); + await func.call( + context, + { json: true, all: false, search: "GET" }, + "listOrganizationEvents", + ); + expect(JSON.parse(stdoutOf(stdoutWrite))).toEqual([ + getAllEndpoints().find( + (endpoint) => endpoint.fn === "listOrganizationEvents", + ), + ]); + + const incompatible = createMockContext(); + await expect( + func.call( + incompatible.context, + { json: true, all: false }, + "POST", + "listOrganizationEvents", + ), + ).rejects.toBeInstanceOf(ResolutionError); + expect(stdoutOf(incompatible.stdoutWrite)).toBe(""); + }); + + test("--all remains an unannotated flat array", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await schemaCommand.loader(); + await func.call(context, { json: true, all: true }); + expect(JSON.parse(stdoutOf(stdoutWrite))).toEqual(getAllEndpoints()); }); - test("unquoted METHOD and path as two positionals also match", async () => { + test.each([ + { + args: ["log", "list"], + fn: "listOrganizationEvents", + command: "sentry log list", + note: "dataset=logs", + }, + { + args: ["logs"], + fn: "listOrganizationEvents", + command: "sentry log list", + note: "dataset=logs", + }, + { + args: ["trace", "ls"], + fn: "listOrganizationEvents", + command: "sentry trace list", + note: "is_transaction:true", + }, + { + args: ["trace", "view"], + fn: "getOrganizationTrace", + command: "sentry trace view", + }, + { + args: ["alert", "metrics", "view"], + fn: "getOrganizationDetector", + command: "sentry alert metrics view", + }, + ])("discovers the API used by $args", async ({ args, fn, command, note }) => { const { context, stdoutWrite } = createMockContext(); const func = await schemaCommand.loader(); - await func.call(context, { json: true, all: false }, "GET", ORG_ISSUES); + await func.call(context, { json: true, all: false }, ...args); + const result = JSON.parse(stdoutOf(stdoutWrite)); + expect(Array.isArray(result)).toBe(true); + const endpoint = result.find( + (candidate: { fn: string }) => candidate.fn === fn, + ); + expect(endpoint?.match.relatedCommands).toContainEqual( + expect.objectContaining({ command }), + ); + if (note) { + expect( + endpoint.match.relatedCommands.find( + (candidate: { command: string }) => candidate.command === command, + ).note, + ).toContain(note); + } + }); - const parsed = JSON.parse(stdoutOf(stdoutWrite)); - expect(parsed.fn).toBe("listOrganizationIssues"); + test("--search and positionals share discovery, including a complete nested path", async () => { + const positional = createMockContext(); + const search = createMockContext(); + const func = await schemaCommand.loader(); + await func.call( + positional.context, + { json: true, all: false }, + "alert", + "metrics", + "view", + ); + await func.call( + search.context, + { json: true, all: false, search: "alert" }, + "metrics", + "view", + ); + expect(JSON.parse(stdoutOf(search.stdoutWrite))).toEqual( + JSON.parse(stdoutOf(positional.stdoutWrite)), + ); + }); + + test.each([ + { + args: ["trace", "ls"], + search: false, + fn: "listOrganizationEvents", + command: "sentry trace list", + }, + { + args: ["alert", "metrics", "view"], + search: true, + fn: "getOrganizationDetector", + command: "sentry alert metrics view", + }, + ])( + "HTTP filters preserve CLI discovery for $args", + async ({ args, search, fn, command }) => { + const { context, stdoutWrite } = createMockContext(); + const func = await schemaCommand.loader(); + await func.call( + context, + { json: true, all: false, search: search ? "GET" : undefined }, + ...(search ? args : ["GET", ...args]), + ); + expect(JSON.parse(stdoutOf(stdoutWrite))).toContainEqual( + expect.objectContaining({ + fn, + method: "GET", + match: expect.objectContaining({ + relatedCommands: expect.arrayContaining([ + expect.objectContaining({ command }), + ]), + }), + }), + ); + + const incompatible = createMockContext(); + await expect( + func.call( + incompatible.context, + { json: true, all: false, search: search ? "POST" : undefined }, + ...(search ? args : ["POST", ...args]), + ), + ).rejects.toBeInstanceOf(search ? OutputError : ResolutionError); + expect(stdoutOf(incompatible.stdoutWrite).trim()).toBe( + search ? "[]" : "", + ); + }, + ); + + test("renders the selector and related CLI command in human output", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await schemaCommand.loader(); + await func.call(context, { json: false, all: false }, "logs"); + const output = stdoutOf(stdoutWrite); + expect(output).toContain("dataset=logs"); + expect(output).toContain("sentry log list"); + }); + + test("discovery supports existing --fields filtering", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await schemaCommand.loader(); + await func.call( + context, + { json: true, all: false, fields: ["fn"] }, + "log", + "list", + ); + const result = JSON.parse(stdoutOf(stdoutWrite)); + expect(result).toContainEqual({ fn: "listOrganizationEvents" }); + expect( + result.every((endpoint: object) => Object.keys(endpoint).join() === "fn"), + ).toBe(true); }); test("path without METHOD lists methods on that URL", async () => { @@ -126,7 +305,7 @@ describe("sentry schema path lookup", () => { expect(message).toContain(`sentry schema "GET ${ORG_ISSUES}"`); }); - test("resource lookup is unchanged", async () => { + test("resource discovery preserves the endpoint array JSON shape", async () => { const { context, stdoutWrite } = createMockContext(); const func = await schemaCommand.loader(); await func.call(context, { json: true, all: false }, "issues"); @@ -135,7 +314,7 @@ describe("sentry schema path lookup", () => { expect(Array.isArray(parsed)).toBe(true); expect(parsed.length).toBeGreaterThan(3); expect( - parsed.every((e: { resource: string }) => e.resource === "issues"), + parsed.some((e: { fn: string }) => e.fn === "listOrganizationIssues"), ).toBe(true); }); }); diff --git a/packages/cli/test/commands/schema.test.ts b/packages/cli/test/commands/schema.test.ts index bdb1cdb44..45cc76905 100644 --- a/packages/cli/test/commands/schema.test.ts +++ b/packages/cli/test/commands/schema.test.ts @@ -11,14 +11,15 @@ import { resolveResourceQuery } from "../../src/commands/schema.js"; import { OutputError, ResolutionError } from "../../src/lib/errors.js"; describe("resolveResourceQuery", () => { - test("returns endpoints for a known resource", () => { + test("discovers endpoints for a known resource", () => { const result = resolveResourceQuery("issues"); expect(result.kind).toBe("endpoints"); if (result.kind === "endpoints") { expect(result.endpoints.length).toBeGreaterThan(0); - for (const ep of result.endpoints) { - expect(ep.resource).toBe("issues"); - } + expect( + result.endpoints.some((ep) => ep.fn === "listOrganizationIssues"), + ).toBe(true); + expect(result.endpoints.every((ep) => ep.match)).toBe(true); } }); @@ -31,15 +32,35 @@ describe("resolveResourceQuery", () => { } }); - test("does not interpret a partial operation identifier as exact", () => { - expect(() => resolveResourceQuery("OrganizationEvents")).toThrow( - ResolutionError, - ); + test("treats partial operation names as discovery, never exact selection", () => { + const result = resolveResourceQuery("OrganizationEvents"); + expect(result.kind).toBe("endpoints"); + if (result.kind === "endpoints") { + expect( + result.endpoints.some((ep) => ep.fn === "listOrganizationEvents"), + ).toBe(true); + } + }); + + test("returns every list candidate instead of selecting a deprecated endpoint", () => { + const result = resolveResourceQuery("issues", "list"); + expect(result.kind).toBe("endpoints"); + if (result.kind === "endpoints") { + expect( + result.endpoints.find((ep) => ep.fn === "listOrganizationIssues"), + ).toBeDefined(); + expect( + result.endpoints.find( + (ep) => ep.deprecated && ep.resource === "issues", + ), + ).toBeDefined(); + expect(result.endpoints[0]?.deprecated).toBe(false); + } }); test("no-match resource throws ResolutionError instead of listing everything", () => { let thrown: unknown; try { - resolveResourceQuery("committers"); + resolveResourceQuery("zzzznomatch"); } catch (error) { thrown = error; } @@ -47,44 +68,58 @@ describe("resolveResourceQuery", () => { // Must not be the full-list OutputError dump this replaced. expect(thrown).not.toBeInstanceOf(OutputError); const err = thrown as ResolutionError; - expect(err.message).toContain('Resource "committers"'); + expect(err.message).toContain('Resource "zzzznomatch"'); expect(err.message).toContain("does not exist"); // Non-zero exit so scripts can branch on it. expect(err.exitCode).toBeGreaterThan(0); }); - test("suggests a close resource name for a typo", () => { - let thrown: unknown; - try { - resolveResourceQuery("committers"); - } catch (error) { - thrown = error; + test("a resource typo returns candidates rather than selecting an endpoint", () => { + const result = resolveResourceQuery("committers"); + expect(result.kind).toBe("endpoints"); + if (result.kind === "endpoints") { + expect( + result.endpoints.some((endpoint) => endpoint.resource === "commits"), + ).toBe(true); } - const err = thrown as ResolutionError; - // "committers" is one edit from the real "commits" resource. - expect(err.message).toContain("commits"); }); - test("garbage term also throws ResolutionError", () => { - expect(() => resolveResourceQuery("zzzznomatch")).toThrow(ResolutionError); - }); + test.each([undefined, "GET", "DELETE"])( + "unknown operation keeps resource and method scope (%s)", + (method) => { + let thrown: unknown; + try { + resolveResourceQuery( + method ? `${method} issues` : "issues", + "xyznonexistent123", + ); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(OutputError); + const data = (thrown as OutputError).data as { + kind: string; + endpoints: { resource: string; method: string }[]; + }; + expect(data.kind).toBe("endpoints"); + expect(data.endpoints.length).toBeGreaterThan(0); + for (const ep of data.endpoints) { + expect(ep.resource).toBe("issues"); + if (method) { + expect(ep.method).toBe(method); + } + } + }, + ); - test("known resource with unknown operation shows that resource's endpoints", () => { - let thrown: unknown; - try { - resolveResourceQuery("issues", "xyznonexistent123"); - } catch (error) { - thrown = error; - } - expect(thrown).toBeInstanceOf(OutputError); - const data = (thrown as OutputError).data as { - kind: string; - endpoints: { resource: string }[]; - }; - expect(data.kind).toBe("endpoints"); - expect(data.endpoints.length).toBeGreaterThan(0); - for (const ep of data.endpoints) { - expect(ep.resource).toBe("issues"); + test("scoped exact identifiers cannot override the requested HTTP method", () => { + const result = resolveResourceQuery("DELETE issues listOrganizationIssues"); + expect(result.kind).toBe("endpoints"); + if (result.kind === "endpoints") { + expect(result.endpoints.length).toBeGreaterThan(0); + expect( + result.endpoints.every((endpoint) => endpoint.method === "DELETE"), + ).toBe(true); } }); @@ -104,6 +139,21 @@ describe("resolveResourceQuery", () => { } }); + test.each([ + { query: "[i]ssues", resources: ["issues"] }, + { query: "{issues,projects}", resources: ["issues", "projects"] }, + ])("keeps $query scoped to matching resources", ({ query, resources }) => { + const result = resolveResourceQuery(query); + expect(result.kind).toBe("endpoints"); + if (result.kind === "endpoints") { + expect( + [ + ...new Set(result.endpoints.map((endpoint) => endpoint.resource)), + ].sort(), + ).toEqual(resources); + } + }); + test("glob pattern matching nothing throws ResolutionError", () => { expect(() => resolveResourceQuery("zzzz*")).toThrow(ResolutionError); }); diff --git a/packages/cli/test/lib/api-schema-discovery.test.ts b/packages/cli/test/lib/api-schema-discovery.test.ts new file mode 100644 index 000000000..2424ce42a --- /dev/null +++ b/packages/cli/test/lib/api-schema-discovery.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, test } from "vitest"; +import { + type ApiEndpoint, + findEndpointsByIdentifier, + findEndpointsByPath, + searchEndpoints, +} from "../../src/lib/api-schema.js"; + +// Representative OpenAPI operations: logs are both an Explore dataset and a +// feature-flag resource. A lexical resource hit must not hide the dataset. +const events: ApiEndpoint = { + fn: "listOrganizationEvents", + operationId: "listOrganizationEvents", + summary: "Query Explore Events in Table Format", + method: "GET", + path: "/api/0/organizations/{org}/events/", + resource: "events", + description: "Retrieves explore data for a given organization.", + tags: ["Explore"], + pathParams: ["org"], + queryParams: ["dataset"], + queryParameters: [ + { + name: "dataset", + required: true, + type: "string", + enum: ["logs", "spans"], + }, + ], + deprecated: false, +}; +const flagLogs: ApiEndpoint = { + fn: "listOrganizationFlagLogs", + operationId: "listOrganizationFlagLogs", + method: "GET", + path: "/api/0/organizations/{org}/flags/logs/", + resource: "logs", + description: "List an organization's flag logs.", + pathParams: ["org"], + queryParams: [], + deprecated: false, +}; + +describe("schema discovery", () => { + test.each(["expl", "xplor", "exlpore"])( + "discovers partial names and typos without changing exact identity: %s", + (query) => { + const result = searchEndpoints(query, [events, flagLogs]); + expect(result.map((endpoint) => endpoint.fn)).toEqual([events.fn]); + expect(result[0]?.match?.reasons).toContain("Operation summary"); + expect(findEndpointsByIdentifier(query, [events, flagLogs])).toEqual([]); + }, + ); + + test("literal matches do not acquire unrelated fuzzy candidates", () => { + const jobs: ApiEndpoint = { + ...flagLogs, + fn: "listOrganizationJobs", + operationId: "listOrganizationJobs", + resource: "jobs", + path: "/api/0/organizations/{org}/jobs/", + description: "List organization jobs.", + }; + expect(searchEndpoints("logs", [jobs, events, flagLogs])).toEqual( + searchEndpoints("logs", [events, flagLogs]), + ); + }); + + test("finds log capabilities alongside the exact flags resource and explains the selector", () => { + const result = searchEndpoints("logs", [flagLogs, events]); + expect(result.map((endpoint) => endpoint.fn)).toEqual([ + "listOrganizationEvents", + "listOrganizationFlagLogs", + ]); + expect(result[0]?.match?.reasons).toContain( + "Query parameter: dataset=logs", + ); + expect(result[1]?.match?.reasons).toContain("API resource: logs"); + expect(searchEndpoints("log", [flagLogs, events])).toEqual(result); + }); + + test("a new API dataset becomes searchable without a vocabulary mapping", () => { + expect(searchEndpoints("uptime_results", [events])).toEqual([]); + const grown: ApiEndpoint = { + ...events, + queryParameters: [ + { + name: "dataset", + required: true, + enum: ["logs", "spans", "uptime_results"], + }, + ], + }; + expect( + searchEndpoints("uptime_results", [grown])[0]?.match?.reasons, + ).toContain("Query parameter: dataset=uptime_results"); + }); + + test("adding or reordering list operations keeps all candidates and exact lookups stable", () => { + const issues: ApiEndpoint = { + ...events, + fn: "listOrganizationIssues", + operationId: "listOrganizationIssues", + resource: "issues", + path: "/api/0/organizations/{org}/issues/", + }; + const projectIssues: ApiEndpoint = { + ...issues, + fn: "deprecatedListProjectIssues", + operationId: "List a Project's Issues", + path: "/api/0/projects/{org}/{project}/issues/", + deprecated: true, + }; + const added: ApiEndpoint = { + ...issues, + fn: "listTeamIssues", + operationId: "listTeamIssues", + path: "/api/0/teams/{team}/issues/", + }; + const original = searchEndpoints("issues list", [projectIssues, issues]); + expect(original.map((endpoint) => endpoint.fn)).toEqual([ + issues.fn, + projectIssues.fn, + ]); + const grown = [projectIssues, added, issues]; + const result = searchEndpoints("issue list", grown); + expect(result.map((endpoint) => endpoint.fn)).toEqual([ + issues.fn, + added.fn, + projectIssues.fn, + ]); + expect(searchEndpoints("issues list", [...grown].reverse())).toEqual( + result, + ); + expect(findEndpointsByIdentifier(issues.fn, grown)).toEqual([issues]); + expect(findEndpointsByPath(issues.path, "GET", grown)).toEqual([issues]); + }); + + test("preserves both command contexts when they use the same API", () => { + const result = searchEndpoints( + "observability", + [events], + [ + { + command: "sentry log list", + fn: events.fn, + note: "Queries dataset=logs.", + }, + { + command: "sentry trace list", + fn: events.fn, + note: "Queries dataset=spans with is_transaction:true.", + }, + ], + ); + expect(result).toHaveLength(1); + expect(result[0]?.match?.relatedCommands).toEqual([ + { command: "sentry log list", note: "Queries dataset=logs." }, + { + command: "sentry trace list", + note: "Queries dataset=spans with is_transaction:true.", + }, + ]); + }); + + test("requires every search token and honors an explicit HTTP method", () => { + expect(searchEndpoints("logs nonexistent", [events, flagLogs])).toEqual([]); + expect(searchEndpoints("POST logs", [events, flagLogs])).toEqual([]); + expect(searchEndpoints("GET logs", [events, flagLogs])).toHaveLength(2); + }); + + test("exact path and operation lookup do not acquire discovery metadata", () => { + expect(searchEndpoints(events.fn, [events, flagLogs])).toEqual([events]); + expect(searchEndpoints(`GET ${events.path}`, [events, flagLogs])).toEqual([ + events, + ]); + }); + + test("an exact identifier with a different method cannot become a fuzzy discovery", () => { + const alternate: ApiEndpoint = { + ...events, + fn: "listOrganizationEventsAsync", + operationId: "listOrganizationEventsAsync", + method: "POST", + }; + expect(searchEndpoints(`POST ${events.fn}`, [events, alternate])).toEqual( + [], + ); + }); +}); diff --git a/packages/cli/test/lib/api-schema.test.ts b/packages/cli/test/lib/api-schema.test.ts index 25ff58084..f64a49b9e 100644 --- a/packages/cli/test/lib/api-schema.test.ts +++ b/packages/cli/test/lib/api-schema.test.ts @@ -8,7 +8,6 @@ import { findEndpointsByPath, getAllEndpoints, getAllResources, - getEndpoint, getEndpointsByResource, getResourceSummaries, parseEndpointQuery, @@ -84,27 +83,6 @@ describe("getEndpointsByResource", () => { }); }); -describe("getEndpoint", () => { - test("finds specific endpoint by operationId substring", () => { - const ep = getEndpoint("issues", "getOrganizationIssue"); - expect(ep).toBeDefined(); - expect(ep?.resource).toBe("issues"); - expect(ep?.method).toBe("GET"); - expect(ep?.fn).toBe("getOrganizationIssue"); - }); - - test("is case-insensitive", () => { - const ep1 = getEndpoint("issues", "list"); - const ep2 = getEndpoint("Issues", "List"); - expect(ep1).toEqual(ep2); - }); - - test("returns undefined for unknown endpoint", () => { - expect(getEndpoint("issues", "xyznonexistent123")).toBeUndefined(); - expect(getEndpoint("nonexistent", "list")).toBeUndefined(); - }); -}); - describe("parseEndpointQuery", () => { test("parses METHOD + OpenAPI path", () => { const parsed = parseEndpointQuery( diff --git a/packages/cli/test/lib/command-api-references.test.ts b/packages/cli/test/lib/command-api-references.test.ts new file mode 100644 index 000000000..f92a3a8e1 --- /dev/null +++ b/packages/cli/test/lib/command-api-references.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from "vitest"; +import { routes } from "../../src/app.js"; +import type { ApiReference } from "../../src/lib/api-schema.js"; +import { buildCommand } from "../../src/lib/command.js"; +import { + getCommandApiReferences, + type RouteMap, +} from "../../src/lib/introspect.js"; +import { buildRouteMap } from "../../src/lib/route-map.js"; + +function command() { + return buildCommand({ + docs: { brief: "Example command" }, + auth: false, + async *func() { + yield; + }, + }); +} + +const exampleApi: Readonly> = { + "sentry log list": [{ fn: "queryEvents", note: "Uses the logs dataset." }], + "sentry log view": [{ fn: "queryEvents", note: "Filters by log item ID." }], + "sentry alert metrics list": [{ fn: "listDetectors" }], + "sentry future": [{ fn: "newOperation" }], + "sentry internal": [{ fn: "notPublished" }], +}; + +function exampleRoutes() { + const list = command(); + const log = buildRouteMap({ + routes: { list, view: command() }, + aliases: { details: "view" }, + docs: { brief: "Logs" }, + }); + const metrics = buildRouteMap({ + routes: { list: command() }, + docs: { brief: "Metric alerts" }, + }); + return buildRouteMap({ + routes: { + log, + logs: list, + internal: command(), + alert: buildRouteMap({ + routes: { metrics }, + docs: { brief: "Alerts" }, + }), + future: command(), + local: command(), + }, + docs: { brief: "CLI", hideRoute: { logs: true, internal: true } }, + }) as unknown as RouteMap; +} + +describe("command API discovery", () => { + test("reads generated references for new public commands and nested paths", () => { + const tree = exampleRoutes(); + expect(getCommandApiReferences(tree, "future", exampleApi)).toEqual([ + { command: "sentry future", fn: "newOperation" }, + ]); + expect( + getCommandApiReferences(tree, "alert metrics list", exampleApi), + ).toEqual([{ command: "sentry alert metrics list", fn: "listDetectors" }]); + expect(getCommandApiReferences(tree, "alert metrics", exampleApi)).toEqual( + getCommandApiReferences(tree, "alert", exampleApi), + ); + }); + + test("resolves registered and hidden public aliases to canonical command paths", () => { + const tree = exampleRoutes(); + const list = getCommandApiReferences(tree, "log list", exampleApi); + expect(getCommandApiReferences(tree, "log ls", exampleApi)).toEqual(list); + expect(getCommandApiReferences(tree, "logs", exampleApi)).toEqual(list); + expect(getCommandApiReferences(tree, "log details", exampleApi)).toEqual( + getCommandApiReferences(tree, "log show", exampleApi), + ); + expect( + getCommandApiReferences(tree, "log show", exampleApi)[0]?.command, + ).toBe("sentry log view"); + }); + + test("groups keep each command's context even when they share an API", () => { + expect(getCommandApiReferences(exampleRoutes(), "log", exampleApi)).toEqual( + [ + { + command: "sentry log list", + fn: "queryEvents", + note: "Uses the logs dataset.", + }, + { + command: "sentry log view", + fn: "queryEvents", + note: "Filters by log item ID.", + }, + ], + ); + }); + + test("does not expose internal commands or discard unmatched path segments", () => { + const tree = exampleRoutes(); + for (const query of [ + "internal", + "unknown", + "log view extra", + "local", + "", + ]) { + expect(getCommandApiReferences(tree, query, exampleApi)).toEqual([]); + } + }); + + test("published command references include their direct and transitive API calls", () => { + const tree = routes as unknown as RouteMap; + // Inspecting the route tree must not inherit commands' execution bodies. + expect(getCommandApiReferences(tree, "help")).toEqual([]); + expect(getCommandApiReferences(tree, "logs")).toEqual( + getCommandApiReferences(tree, "log list"), + ); + expect(getCommandApiReferences(tree, "trace list")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fn: "listOrganizationEvents", + note: expect.stringContaining("is_transaction:true"), + }), + ]), + ); + expect(getCommandApiReferences(tree, "log view")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fn: "listOrganizationEvents", + note: expect.stringContaining("dataset=logs"), + }), + ]), + ); + expect(getCommandApiReferences(tree, "trace show")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ fn: "getOrganizationTrace" }), + ]), + ); + expect( + getCommandApiReferences(tree, "alert metrics").map((reference) => ({ + command: reference.command, + fn: reference.fn, + })), + ).toEqual( + expect.arrayContaining([ + { + command: "sentry alert metrics list", + fn: "listOrganizationDetectors", + }, + { command: "sentry alert metrics view", fn: "getOrganizationDetector" }, + { command: "sentry alert metrics view", fn: "getOrganizationWorkflow" }, + { + command: "sentry alert metrics create", + fn: "createOrganizationProjectDetector", + }, + { + command: "sentry alert metrics delete", + fn: "deleteOrganizationDetector", + }, + { command: "sentry alert metrics edit", fn: "getOrganizationDetector" }, + { + command: "sentry alert metrics edit", + fn: "updateOrganizationDetector", + }, + ]), + ); + }); +}); diff --git a/packages/cli/test/script/generate-api-schema.test.ts b/packages/cli/test/script/generate-api-schema.test.ts new file mode 100644 index 000000000..033819d71 --- /dev/null +++ b/packages/cli/test/script/generate-api-schema.test.ts @@ -0,0 +1,105 @@ +import { operations, type ApiOperation } from "@sentry/api/operations"; +import { describe, expect, test } from "vitest"; +import { projectApiOperations } from "../../script/lib/api-schema.js"; +import { searchEndpoints } from "../../src/lib/api-schema.js"; + +const events = { + functionName: "listOrganizationEvents", + operationId: "List an Organization's Events", + method: "GET", + path: "/api/0/organizations/{organization_id_or_slug}/events/", + summary: "Query events", + description: "Select the dataset to query.", + tags: ["Explore"], + pathParameters: ["organization_id_or_slug"], + queryParameters: [ + { + name: "dataset", + required: true, + type: "string", + enum: ["logs", "spans"], + }, + { + name: "field", + required: false, + type: "array", + enum: ["id", "timestamp"], + }, + ], + deprecated: false, + idempotent: true, +} as const satisfies ApiOperation; + +describe("SDK catalog projection", () => { + test("preserves the CLI endpoint JSON shape alongside SDK metadata", () => { + expect(projectApiOperations([events])).toEqual([ + { + fn: "listOrganizationEvents", + operationId: "List an Organization's Events", + method: "GET", + path: "/api/0/organizations/{organization_id_or_slug}/events/", + summary: "Query events", + description: "Select the dataset to query.", + tags: ["Explore"], + pathParams: ["organization_id_or_slug"], + queryParams: ["dataset", "field"], + queryParameters: events.queryParameters, + resource: "events", + deprecated: false, + }, + ]); + }); + + test("keeps resource ordering and legacy deprecation detection local", () => { + const endpoints = projectApiOperations([ + { ...events, operationId: "Z", deprecated: true }, + { ...events, operationId: "Deprecated operation" }, + { ...events, operationId: "A", functionName: "deprecatedListEvents" }, + { + ...events, + path: "/api/0/projects/{org}/{project}/alerts/{alert_id}/", + operationId: "Get a project alert", + }, + ]); + + expect( + endpoints.map(({ resource, operationId, deprecated }) => ({ + resource, + operationId, + deprecated, + })), + ).toEqual([ + { + resource: "alerts", + operationId: "Get a project alert", + deprecated: false, + }, + { resource: "events", operationId: "A", deprecated: true }, + { + resource: "events", + operationId: "Deprecated operation", + deprecated: true, + }, + { resource: "events", operationId: "Z", deprecated: true }, + ]); + }); + + test("discovers log and span datasets from the installed SDK catalog", () => { + const catalog = projectApiOperations(operations); + for (const dataset of ["logs", "spans"]) { + expect(searchEndpoints(dataset, catalog)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fn: "listOrganizationEvents", + queryParameters: expect.arrayContaining([ + expect.objectContaining({ + name: "dataset", + enum: expect.arrayContaining([dataset]), + }), + ]), + }), + ]), + ); + } + }); +}); diff --git a/packages/cli/test/script/source-api-usage.test.ts b/packages/cli/test/script/source-api-usage.test.ts new file mode 100644 index 000000000..c601d4605 --- /dev/null +++ b/packages/cli/test/script/source-api-usage.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeSourceApiUsage, + type SourceApiOperation, +} from "../../script/lib/source-api-usage.js"; + +const operations: SourceApiOperation[] = [ + { + functionName: "listOrganizationEvents", + method: "GET", + path: "/api/0/organizations/{organization_id_or_slug}/events/", + }, + { + functionName: "getOrganizationTrace", + method: "GET", + path: "/api/0/organizations/{organization_id_or_slug}/trace/{trace_id}/", + }, + { + functionName: "deleteProject", + method: "DELETE", + path: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/", + }, + { + functionName: "deleteOrganizationWorkflow", + method: "DELETE", + path: "/api/0/organizations/{organization_id_or_slug}/workflows/{workflow_id}/", + }, +]; + +describe("source API usage", () => { + it("does not treat route-tree inspection as execution of every command", () => { + const sources = new Map([ + [ + "src/commands/help.ts", + ` + import { routes } from "../routes.js"; + export const help = buildCommand({ func: () => inspect(routes) }); + `, + ], + [ + "src/routes.ts", + ` + import { remove } from "./commands/project.js"; + export const routes = buildRouteMap({ routes: { remove } }); + `, + ], + [ + "src/commands/project.ts", + ` + import { deleteProject } from "@sentry/api"; + function customWrapper(config) { return buildCommand(config); } + export const remove = customWrapper({ func: () => deleteProject({}) }); + export const alias = remove; + `, + ], + ]); + const analysis = analyzeSourceApiUsage(sources, operations); + expect(analysis.exports.get("src/commands/help.ts")?.get("help")).toEqual({ + operations: [], + unresolvedRequests: [], + deletes: false, + }); + expect(analysis.exports.get("src/routes.ts")?.get("routes")?.deletes).toBe( + false, + ); + expect( + analysis.exports.get("src/commands/project.ts")?.get("remove")?.deletes, + ).toBe(true); + expect( + analysis.exports.get("src/commands/project.ts")?.get("alias")?.deletes, + ).toBe(true); + }); + + it("follows actual helper calls through aliases and barrels, and grows with new command exports", () => { + const sources = new Map([ + [ + "src/api.tsx", + ` + import { listOrganizationEvents as events, getOrganizationTrace } from "@sentry/api"; + export const query = () => events({ query: { dataset: "logs" } }); + `, + ], + ["src/barrel.ts", `export { query as read } from "./api.js";`], + [ + "src/commands/list.ts", + ` + import { read } from "../barrel.js"; + const customWrapper = (config) => buildCommand(config); + export const command = customWrapper({ async func() { return await read(); } }); + `, + ], + ]); + const first = analyzeSourceApiUsage(sources, operations); + expect( + first.exports.get("src/commands/list.ts")?.get("command")?.operations, + ).toEqual([{ ...operations[0], query: { dataset: "logs" } }]); + + sources.set( + "src/api.tsx", + ` + import { getOrganizationTrace as trace, listOrganizationEvents } from "@sentry/api"; + export const query = () => trace({ path: {} }); + `, + ); + sources.set("src/commands/new.ts", `export * from "./list.js";`); + const changed = analyzeSourceApiUsage(sources, operations); + expect( + changed.exports.get("src/commands/new.ts")?.get("command")?.operations, + ).toEqual([operations[1]]); + expect(changed.commands).toContainEqual({ + file: "src/commands/new.ts", + name: "command", + destructive: false, + }); + }); + + it("links regional raw request templates and preserves only known query context", () => { + const sources = new Map([ + [ + "src/commands/trace.ts", + ` + import { apiRequestToRegionNoContent as deleteRequest } from "../api.js"; + const DATASET = "spans"; + const TRANSACTIONS = "is_transaction:true"; + async function page(org, options) { + const fullQuery = [TRANSACTIONS, options.query].filter(Boolean).join(" "); + return apiRequestToRegion(options.region, \`/organizations/\${org}/events/\`, { + params: { dataset: DATASET, query: fullQuery || undefined }, + }); + } + export const list = buildListCommand({ fetchPage: page }); + export const view = buildCommand({ func: (org, id) => apiRequestToRegion("region", \`/organizations/\${org}/trace/\${id}/\`) }); + export const remove = buildDeleteCommand({ func: (org, id) => deleteRequest("region", \`/organizations/\${org}/workflows/\${id}/\`, { method: "DELETE" }) }); + `, + ], + ]); + const result = analyzeSourceApiUsage(sources, operations).exports.get( + "src/commands/trace.ts", + )!; + expect(result.get("list")?.operations).toEqual([ + { + ...operations[0], + query: { dataset: "spans", query: "is_transaction:true" }, + }, + ]); + expect(result.get("view")?.operations).toEqual([operations[1]]); + expect(result.get("view")?.unresolvedRequests).toEqual([]); + expect(result.get("remove")).toMatchObject({ + operations: [operations[3]], + unresolvedRequests: [], + deletes: true, + }); + }); + + it("does not invent links for dynamic methods, unknown paths or ambiguous catalog templates", () => { + const sources = new Map([ + [ + "src/commands/raw.ts", + ` + export const dynamic = buildCommand({ func: (path) => apiRequest(path, { method: "DELETE" }) }); + export const privateApi = buildDeleteCommand({ func: () => apiRequest("/private/", { method: "DELETE" }) }); + export const unknownMethod = buildCommand({ func: (options) => apiRequest("/organizations/any/events/", { ...options }) }); + export const ambiguous = buildCommand({ func: (org) => apiRequest(\`/organizations/\${org}/events/\`) }); + `, + ], + ]); + const analysis = analyzeSourceApiUsage(sources, [ + ...operations, + { + ...operations[0]!, + functionName: "anotherEvents", + path: "/api/0/organizations/{org}/events/", + }, + ]); + const result = analysis.exports.get("src/commands/raw.ts")!; + expect(result.get("dynamic")).toMatchObject({ + deletes: true, + operations: [], + unresolvedRequests: [ + { method: "DELETE", reason: "Dynamic request identity" }, + ], + }); + expect(result.get("privateApi")).toMatchObject({ + deletes: true, + operations: [], + unresolvedRequests: [{ reason: "Request is not in the public catalog" }], + }); + expect(result.get("unknownMethod")).toMatchObject({ + operations: [], + unresolvedRequests: [ + { method: undefined, reason: "Dynamic request identity" }, + ], + }); + expect(result.get("ambiguous")).toMatchObject({ + operations: [], + unresolvedRequests: [{ reason: "Ambiguous catalog path" }], + }); + expect(analysis.commands.filter(({ destructive }) => destructive)).toEqual([ + { file: "src/commands/raw.ts", name: "privateApi", destructive: true }, + ]); + }); + + it("keeps SDK DELETE callbacks conservative without following unused imports or shared builder internals", () => { + const sources = new Map([ + [ + "src/lib/command.ts", + `import { deleteProject } from "@sentry/api"; export const buildCommand = (config) => { deleteProject({}); return config; };`, + ], + [ + "src/commands/project.ts", + ` + import { buildCommand } from "../lib/command.js"; + import * as sdk from "@sentry/api"; + import { deleteProject } from "@sentry/api"; + export const remove = buildCommand({ destructive: true, func: () => [].map(sdk.deleteProject) }); + export const safe = buildCommand({ func: (deleteProject) => deleteProject() }); + `, + ], + ]); + const analysis = analyzeSourceApiUsage(sources, operations); + expect( + analysis.exports.get("src/commands/project.ts")?.get("remove"), + ).toMatchObject({ operations: [operations[2]], deletes: true }); + expect( + analysis.exports.get("src/commands/project.ts")?.get("safe"), + ).toEqual({ operations: [], unresolvedRequests: [], deletes: false }); + }); +});