From 22cbe8f58278a52e3378f7bdfbe920d9a9e6341d Mon Sep 17 00:00:00 2001 From: betegon Date: Sun, 25 Oct 2026 22:23:25 +0100 Subject: [PATCH 1/7] fix(cli): improve schema discovery --- .../cli-docs/src/fragments/commands/schema.md | 16 ++ packages/cli/DEVELOPMENT.md | 20 ++ .../skills/sentry-cli/references/schema.md | 16 +- packages/cli/script/generate-api-schema.ts | 113 +-------- packages/cli/script/generate-command-docs.ts | 5 +- packages/cli/script/lib/api-schema.ts | 139 +++++++++++ .../cli/src/commands/alert/metrics/create.ts | 2 + .../cli/src/commands/alert/metrics/delete.ts | 2 + .../cli/src/commands/alert/metrics/edit.ts | 2 + .../cli/src/commands/alert/metrics/list.ts | 6 +- .../cli/src/commands/alert/metrics/view.ts | 2 + packages/cli/src/commands/log/list.ts | 2 + packages/cli/src/commands/log/view.ts | 4 +- packages/cli/src/commands/schema.ts | 200 ++++++++++----- packages/cli/src/commands/trace/list.ts | 2 + packages/cli/src/commands/trace/view.ts | 2 + packages/cli/src/lib/api-schema.ts | 231 ++++++++++++++---- packages/cli/src/lib/api/alerts.ts | 33 ++- packages/cli/src/lib/api/logs.ts | 13 +- packages/cli/src/lib/api/traces.ts | 20 +- packages/cli/src/lib/command.ts | 7 + packages/cli/src/lib/introspect.ts | 198 ++++++++------- .../cli/test/commands/schema-path.test.ts | 124 +++++++++- packages/cli/test/commands/schema.test.ts | 37 ++- .../cli/test/lib/api-schema-discovery.test.ts | 154 ++++++++++++ packages/cli/test/lib/api-schema.test.ts | 22 -- .../test/lib/command-api-references.test.ts | 176 +++++++++++++ .../test/script/generate-api-schema.test.ts | 221 +++++++++++++++++ 28 files changed, 1412 insertions(+), 357 deletions(-) create mode 100644 packages/cli/script/lib/api-schema.ts create mode 100644 packages/cli/test/lib/api-schema-discovery.test.ts create mode 100644 packages/cli/test/lib/command-api-references.test.ts create mode 100644 packages/cli/test/script/generate-api-schema.test.ts diff --git a/apps/cli-docs/src/fragments/commands/schema.md b/apps/cli-docs/src/fragments/commands/schema.md index e69de29bb..34b4bc2db 100644 --- a/apps/cli-docs/src/fragments/commands/schema.md +++ b/apps/cli-docs/src/fragments/commands/schema.md @@ -0,0 +1,16 @@ +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 when a relationship is +declared. A related API does not necessarily reproduce the command's filters, +pagination, or transformations. + +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..2ad0007a2 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -102,6 +102,26 @@ 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. + +To associate a CLI command with its API, add optional `docs.relatedApi` metadata +to its command builder. Reuse a descriptor exported by the API helper, such as +`LOGS_API` in `src/lib/api/logs.ts`. Each reference contains the exact generated +SDK function name (`fn`) and an optional `note` describing the dataset or filter +used. Share selector constants with the request where practical. A reference +documents related functionality, not an equivalent request. + +Introspection derives command paths, registered aliases, and group membership +from the route tree. Groups collect their public commands' references, so adding +a command requires no separate group mapping. Documentation generation checks +that every reference matches exactly one operation in the generated catalog. + +### 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/plugins/sentry-cli/skills/sentry-cli/references/schema.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md index 79724f8f2..f7987673f 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 @@ -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 diff --git a/packages/cli/script/generate-api-schema.ts b/packages/cli/script/generate-api-schema.ts index 7c86e6644..af9c24092 100644 --- a/packages/cli/script/generate-api-schema.ts +++ b/packages/cli/script/generate-api-schema.ts @@ -22,6 +22,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { extractApiEndpoints, type OpenApiSpec } from "./lib/api-schema.js"; /** Resolve the root directory of @sentry/api by finding its main entry point. */ function sentryApiDir(): string { @@ -45,38 +46,6 @@ async function getOpenApiUrl(): Promise { 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"; - -// --------------------------------------------------------------------------- -// 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 // --------------------------------------------------------------------------- @@ -128,42 +97,6 @@ async function buildSdkFunctionMap(): Promise> { 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 // --------------------------------------------------------------------------- @@ -181,49 +114,7 @@ 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 = extractApiEndpoints(spec, sdkMap); await mkdir(dirname(OUTPUT_PATH), { recursive: true }); await writeFile(OUTPUT_PATH, JSON.stringify(endpoints, null, 2)); diff --git a/packages/cli/script/generate-command-docs.ts b/packages/cli/script/generate-command-docs.ts index 36e735a5a..cdd3c7b5e 100644 --- a/packages/cli/script/generate-command-docs.ts +++ b/packages/cli/script/generate-command-docs.ts @@ -60,7 +60,9 @@ import type { } from "../src/lib/introspect.js"; const { routes } = await import("../src/app.js"); -const { extractAllRoutes } = await import("../src/lib/introspect.js"); +const { extractAllRoutes, validateCommandApiReferences } = + await import("../src/lib/introspect.js"); +const { getAllEndpoints } = await import("../src/lib/api-schema.js"); const { ENV_VAR_REGISTRY } = await import("../src/lib/env-registry.js"); const DOCS_DIR = `${DOCS_CONTENT}/commands`; @@ -386,6 +388,7 @@ async function readTopLevelFragment(fragmentName: string): Promise { // --------------------------------------------------------------------------- const routeMap = routes as unknown as RouteMap; +validateCommandApiReferences(routeMap, getAllEndpoints()); const routeInfos = extractAllRoutes(routeMap).filter( (r) => !SKIP_ROUTES.has(r.name), ); diff --git a/packages/cli/script/lib/api-schema.ts b/packages/cli/script/lib/api-schema.ts new file mode 100644 index 000000000..e00140397 --- /dev/null +++ b/packages/cli/script/lib/api-schema.ts @@ -0,0 +1,139 @@ +/** Extract the bundled discovery index from a dereferenced OpenAPI document. */ + +import type { + ApiEndpoint, + ApiQueryParameter, +} from "../../src/lib/api-schema.js"; + +const HTTP_METHODS = ["get", "post", "put", "delete", "patch"] as const; +const PATH_PARAM_PATTERN = /\{(\w+)\}/g; + +/** Only the OpenAPI fields used by the discovery index. */ +export type OpenApiSpec = { + paths: Record; +}; + +type OpenApiPathItem = Partial< + Record<(typeof HTTP_METHODS)[number], OpenApiOperation> +> & { + parameters?: OpenApiParameter[]; +}; + +type OpenApiOperation = { + operationId?: string; + summary?: string; + description?: string; + tags?: string[]; + deprecated?: boolean; + parameters?: OpenApiParameter[]; +}; + +type OpenApiParameter = { + in: "path" | "query" | "header" | "cookie"; + name: string; + required?: boolean; + description?: string; + schema?: { + type?: string; + enum?: unknown[]; + items?: { enum?: unknown[] }; + }; +}; + +/** 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"; +} + +function isEnumValue( + value: unknown, +): value is string | number | boolean | null { + return ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +/** Operation parameters override inherited parameters with the same name/in. */ +function extractQueryParameters( + pathItem: OpenApiPathItem, + operation: OpenApiOperation, +): ApiQueryParameter[] { + const parameters = new Map(); + for (const parameter of [ + ...(pathItem.parameters ?? []), + ...(operation.parameters ?? []), + ]) { + if (parameter.in === "query") { + parameters.set(parameter.name, parameter); + } + } + + return [...parameters.values()].map((parameter) => { + const schema = parameter.schema; + const values = schema?.type === "array" ? schema.items?.enum : schema?.enum; + return { + name: parameter.name, + description: parameter.description?.trim(), + type: schema?.type, + required: parameter.required === true, + enum: values?.filter(isEnumValue), + }; + }); +} + +/** + * Generate entries directly from API metadata so new operations and enum values + * become discoverable on the next schema generation, without a vocabulary map. + */ +export function extractApiEndpoints( + spec: OpenApiSpec, + sdkMap: ReadonlyMap, +): ApiEndpoint[] { + const endpoints: ApiEndpoint[] = []; + + for (const [urlPath, pathItem] of Object.entries(spec.paths)) { + for (const method of HTTP_METHODS) { + const operation = pathItem[method]; + if (!operation) { + continue; + } + + const methodUpper = method.toUpperCase(); + const fn = sdkMap.get(`${methodUpper}:${urlPath}`) ?? ""; + const queryParameters = extractQueryParameters(pathItem, operation); + + endpoints.push({ + fn, + method: methodUpper, + path: urlPath, + summary: operation.summary?.trim(), + description: (operation.description ?? "").trim(), + tags: operation.tags, + pathParams: [...urlPath.matchAll(PATH_PARAM_PATTERN)].map((match) => + match[0].slice(1, -1), + ), + queryParams: queryParameters.map((parameter) => parameter.name), + queryParameters, + deprecated: + operation.deprecated === true || + fn.startsWith("deprecated") || + (operation.operationId ?? "").toLowerCase().includes("deprecated"), + resource: deriveResource(urlPath), + operationId: operation.operationId ?? "", + }); + } + } + + // Keep stable ordering and the existing resource/operation ID contract. + return endpoints.sort((a, b) => { + const resourceCmp = a.resource.localeCompare(b.resource); + return resourceCmp || a.operationId.localeCompare(b.operationId); + }); +} diff --git a/packages/cli/src/commands/alert/metrics/create.ts b/packages/cli/src/commands/alert/metrics/create.ts index 9516ba514..b55a701d6 100644 --- a/packages/cli/src/commands/alert/metrics/create.ts +++ b/packages/cli/src/commands/alert/metrics/create.ts @@ -5,6 +5,7 @@ */ import type { SentryContext } from "../../../context.js"; +import { METRIC_ALERT_API } from "../../../lib/api/alerts.js"; import { createMetricAlertRule } from "../../../lib/api-client.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; import { stringifyUnknown, ValidationError } from "../../../lib/errors.js"; @@ -72,6 +73,7 @@ function resolveMetricCreateOrg( export const createCommand = buildCommand({ docs: { + relatedApi: [METRIC_ALERT_API.create], brief: "Create a metric alert rule", fullDescription: "Create a metric alert rule (a metric monitor in the detectors API).\n\n" + diff --git a/packages/cli/src/commands/alert/metrics/delete.ts b/packages/cli/src/commands/alert/metrics/delete.ts index 42b9b93ec..351bfa54d 100644 --- a/packages/cli/src/commands/alert/metrics/delete.ts +++ b/packages/cli/src/commands/alert/metrics/delete.ts @@ -5,6 +5,7 @@ */ import type { SentryContext } from "../../../context.js"; +import { METRIC_ALERT_API } from "../../../lib/api/alerts.js"; import { deleteMetricAlertRule } from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; @@ -44,6 +45,7 @@ function formatDeleted(r: DeleteResult): string { export const deleteCommand = buildDeleteCommand({ docs: { + relatedApi: [METRIC_ALERT_API.delete], brief: "Delete a metric alert rule", fullDescription: "Permanently remove a metric alert rule from an organization. " + diff --git a/packages/cli/src/commands/alert/metrics/edit.ts b/packages/cli/src/commands/alert/metrics/edit.ts index 798727707..47efd764e 100644 --- a/packages/cli/src/commands/alert/metrics/edit.ts +++ b/packages/cli/src/commands/alert/metrics/edit.ts @@ -5,6 +5,7 @@ */ import type { SentryContext } from "../../../context.js"; +import { METRIC_ALERT_API } from "../../../lib/api/alerts.js"; import { getMetricAlertRuleDocument, putMetricAlertRule, @@ -174,6 +175,7 @@ function formatEdited(r: EditResult): string { export const editCommand = buildCommand({ docs: { + relatedApi: [METRIC_ALERT_API.get, METRIC_ALERT_API.update], brief: "Edit a metric alert rule", fullDescription: "Update a metric alert rule. Pass at least one of --name or --status. " + diff --git a/packages/cli/src/commands/alert/metrics/list.ts b/packages/cli/src/commands/alert/metrics/list.ts index a8e90a208..d0eb6c2e4 100644 --- a/packages/cli/src/commands/alert/metrics/list.ts +++ b/packages/cli/src/commands/alert/metrics/list.ts @@ -11,7 +11,10 @@ */ import type { SentryContext } from "../../../context.js"; -import type { MetricAlertRule } from "../../../lib/api/alerts.js"; +import { + METRIC_ALERT_API, + type MetricAlertRule, +} from "../../../lib/api/alerts.js"; import { paginate } from "../../../lib/api/infrastructure.js"; import { listMetricAlertsPaginated } from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; @@ -493,6 +496,7 @@ const jsonTransformMetricAlertList = jsonTransformListResult; export const listCommand = buildListCommand("alert metrics", { docs: { + relatedApi: [METRIC_ALERT_API.list], brief: "List metric alert rules", fullDescription: "List metric alert rules for one or more Sentry organizations.\n\n" + diff --git a/packages/cli/src/commands/alert/metrics/view.ts b/packages/cli/src/commands/alert/metrics/view.ts index 2f5da8155..1cb1c6d74 100644 --- a/packages/cli/src/commands/alert/metrics/view.ts +++ b/packages/cli/src/commands/alert/metrics/view.ts @@ -3,6 +3,7 @@ import { getIssueAlertWorkflowDocument, getMetricAlertRule, } from "../../../lib/api-client.js"; +import { METRIC_ALERT_API } from "../../../lib/api/alerts.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; import { buildCommand } from "../../../lib/command.js"; @@ -28,6 +29,7 @@ type ViewFlags = { export const viewCommand = buildCommand({ docs: { + relatedApi: [METRIC_ALERT_API.get], brief: "View a metric alert rule", fullDescription: "View a metric alert rule by ID or name, including detection settings, " + diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index 1735e7e81..76aea7954 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -9,6 +9,7 @@ // oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; import type { SentryContext } from "../../context.js"; +import { LOGS_API } from "../../lib/api/logs.js"; import { type LogSortDirection, listLogs, @@ -701,6 +702,7 @@ export const listCommand = buildListCommand( "log", { docs: { + relatedApi: [LOGS_API], brief: "List logs from a project", fullDescription: "List and stream logs from Sentry projects.\n\n" + diff --git a/packages/cli/src/commands/log/view.ts b/packages/cli/src/commands/log/view.ts index 38cab4a19..2cdaa102f 100644 --- a/packages/cli/src/commands/log/view.ts +++ b/packages/cli/src/commands/log/view.ts @@ -5,10 +5,9 @@ */ import { isatty } from "node:tty"; - import pLimit from "p-limit"; - import type { SentryContext } from "../../context.js"; +import { LOGS_API } from "../../lib/api/logs.js"; import { getLogItemDetail, getLogs } from "../../lib/api-client.js"; import { detectSwappedViewArgs, @@ -560,6 +559,7 @@ function formatLogViewHuman(data: LogViewData): string { export const viewCommand = buildCommand({ docs: { + relatedApi: [LOGS_API], brief: "View details of one or more log entries", fullDescription: "View detailed information about Sentry log entries by their IDs.\n\n" + diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index 8b5a2463a..4b5649e9c 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -8,24 +8,27 @@ * 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 → discover related API endpoints + * sentry schema → list matching operation candidates + * sentry schema alert metrics view → find APIs used by this CLI command * 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 { routes } from "../app.js"; 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 +42,7 @@ 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"; // --------------------------------------------------------------------------- // Output data types @@ -47,7 +51,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 +99,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 +112,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 +135,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 +154,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}\``; @@ -231,8 +259,7 @@ function resolveSearchQuery(search: string, args: string[]): string { if (args.length === 0) { return search; } - const combined = [search, ...args].join(" "); - return parseEndpointQuery(combined).path ? combined : search; + return [search, ...args].join(" "); } function queryLabel(resource: string, operation?: string): string { @@ -336,6 +363,33 @@ 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 { + // 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 }; +} + /** * Resolve a resource + optional operation into a SchemaResult. * Throws ResolutionError for no-match cases; throws OutputError with the @@ -346,66 +400,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 identifierResult = resolveIdentifierLookup(query); + 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(" ")) && + (resource.includes("*") || resource.includes("?")) + ) { + 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] = query + .trim() + .split(QUERY_WORD_SEPARATOR); + const resourceEndpoints = getEndpointsByResource(resourceName ?? ""); + 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 +463,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 +472,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", @@ -475,7 +533,8 @@ 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", }, @@ -483,12 +542,16 @@ export const schemaCommand = buildCommand({ }, // Stricli requires AsyncGenerator but schema queries are synchronous (in-memory JSON) async *func(this: SentryContext, flags: SchemaFlags, ...args: string[]) { - const [resource, operation] = args; + const positionalQuery = args.join(" "); // --search takes priority if (flags.search) { const query = resolveSearchQuery(flags.search, args); - const results = searchEndpoints(query); + const references = getCommandApiReferences( + routes as unknown as RouteMap, + query, + ); + const results = searchEndpoints(query, undefined, references); if (results.length === 0) { throw new OutputError({ kind: "endpoints", @@ -510,7 +573,7 @@ export const schemaCommand = buildCommand({ } // No positional args: show resource summary - if (!resource) { + if (!positionalQuery) { const resources = getResourceSummaries(); return yield new CommandOutput({ kind: "resources", @@ -518,7 +581,12 @@ export const schemaCommand = buildCommand({ }); } - // Resolve resource (with optional operation or glob pattern) - return yield new CommandOutput(resolveResourceQuery(resource, operation)); + const references = getCommandApiReferences( + routes as unknown as RouteMap, + positionalQuery, + ); + return yield new CommandOutput( + resolveResourceQuery(positionalQuery, undefined, references), + ); }, }); diff --git a/packages/cli/src/commands/trace/list.ts b/packages/cli/src/commands/trace/list.ts index 16998ea52..54751a26f 100644 --- a/packages/cli/src/commands/trace/list.ts +++ b/packages/cli/src/commands/trace/list.ts @@ -5,6 +5,7 @@ */ import type { SentryContext } from "../../context.js"; +import { LIST_TRANSACTIONS_API } from "../../lib/api/traces.js"; import { listTransactions } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { @@ -195,6 +196,7 @@ function jsonTransformTraceList( export const listCommand = buildListCommand("trace", { docs: { + relatedApi: [LIST_TRANSACTIONS_API], brief: "List recent traces in a project", fullDescription: "List recent traces from Sentry projects.\n\n" + diff --git a/packages/cli/src/commands/trace/view.ts b/packages/cli/src/commands/trace/view.ts index 3aa370eff..4c5a836e8 100644 --- a/packages/cli/src/commands/trace/view.ts +++ b/packages/cli/src/commands/trace/view.ts @@ -5,6 +5,7 @@ */ import type { SentryContext } from "../../context.js"; +import { TRACE_DETAILS_API } from "../../lib/api/traces.js"; import { attributesToDict, fetchMultiSpanDetails, @@ -432,6 +433,7 @@ function fetchTraceSpanDetails( export const viewCommand = buildCommand({ docs: { + relatedApi: [TRACE_DETAILS_API], brief: "View details of a specific trace", fullDescription: "View detailed information about a distributed trace by its ID.\n\n" + diff --git a/packages/cli/src/lib/api-schema.ts b/packages/cli/src/lib/api-schema.ts index 8b807356d..afe5128b0 100644 --- a/packages/cli/src/lib/api-schema.ts +++ b/packages/cli/src/lib/api-schema.ts @@ -8,6 +8,31 @@ import endpoints from "../generated/api-schema.json"; +/** Searchable query parameter metadata from the pinned OpenAPI specification. */ +export type ApiQueryParameter = { + name: string; + description?: string; + type?: string; + required: boolean; + enum?: (string | number | boolean | null)[]; +}; + +/** An API used by a command, with context rather than a claim of equivalence. */ +export type ApiReference = { + fn: string; + note?: string; +}; + +export type CommandApiReference = ApiReference & { command: string }; + +/** Discovery evidence is additive; exact lookups return plain ApiEndpoints. */ +export type SchemaEndpoint = ApiEndpoint & { + match?: { + reasons: string[]; + 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) */ @@ -18,10 +43,13 @@ export type ApiEndpoint = { path: string; /** Human-readable description of the endpoint */ description: string; + summary?: string; + tags?: string[]; /** Path parameter names extracted from the URL template */ pathParams: string[]; /** Query parameter names from the OpenAPI spec */ queryParams: string[]; + queryParameters?: ApiQueryParameter[]; /** Whether the endpoint is deprecated */ deprecated: boolean; /** Resource group name derived from URL (e.g., "projects", "issues") */ @@ -88,10 +116,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; } @@ -129,12 +158,15 @@ export function findEndpointsByPath( * Matching is case-insensitive but deliberately not fuzzy: partial operation * names can match many unrelated endpoints and should use `--search` instead. */ -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 +185,164 @@ 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; - // 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; +/** Conservative English plurals for discovery only, never endpoint identity. */ +function normalizeWord(word: string): string { + if (word.length > 4 && word.endsWith("ies")) { + return `${word.slice(0, -3)}y`; + } + if ( + word.length > 3 && + word.endsWith("s") && + !word.endsWith("ss") && + !word.endsWith("us") && + !word.endsWith("is") + ) { + return word.slice(0, -1); } + return word; +} - // Priority 2: substring match anywhere in operationId or fn - return candidates.find( - (e) => - e.operationId.toLowerCase().includes(lowerQuery) || - e.fn.toLowerCase().includes(lowerQuery), +function words(text: string): Set { + return new Set( + text + .replace(CAMEL_CASE_BOUNDARY, "$1 $2") + .toLowerCase() + .split(WORD_SEPARATOR) + .filter(Boolean) + .map(normalizeWord), ); } +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); +} + +function matchEndpoint( + endpoint: ApiEndpoint, + tokens: Set, + references: readonly CommandApiReference[], +): { 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 fieldWords = words(field.text); + const matches = [...tokens].filter((token) => fieldWords.has(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 the CLI explicitly declares a related operation. + * 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).filter( + (endpoint) => !parsed.method || endpoint.method === parsed.method, + ); + if (exact.length > 0) { + return exact; + } + 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 endpoint of catalog) { + if (parsed.method && endpoint.method !== parsed.method) { + continue; + } + const match = matchEndpoint(endpoint, tokens, references); + if (match) { + matches.push(match); + } } - 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/api/alerts.ts b/packages/cli/src/lib/api/alerts.ts index 26591fc4b..2e8cb5d85 100644 --- a/packages/cli/src/lib/api/alerts.ts +++ b/packages/cli/src/lib/api/alerts.ts @@ -29,6 +29,7 @@ import { metricAlertDatasetName, metricAlertSource, } from "../../types/metric-alert.js"; +import type { ApiReference } from "../api-schema.js"; import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; import { @@ -393,8 +394,34 @@ export async function getIssueAlertRule( // (create/update/delete) also target the detectors write API — the flat CLI // input is translated into the nested detector body by `buildDetectorBody`. +/** Detector kind managed by the metric alert commands. */ +const METRIC_DETECTOR_TYPE = "metric_issue"; /** Search query that limits the detectors endpoint to metric alert rules. */ -const METRIC_DETECTOR_QUERY = "type:metric_issue"; +const METRIC_DETECTOR_QUERY = `type:${METRIC_DETECTOR_TYPE}`; + +/** API references shared by metric alert commands; detectors also serve other products. */ +export const METRIC_ALERT_API = { + list: { + fn: "listOrganizationDetectors", + note: `Filters detectors with query=${METRIC_DETECTOR_QUERY}.`, + }, + get: { + fn: "getOrganizationDetector", + note: `The CLI accepts ${METRIC_DETECTOR_TYPE} detectors and presents them as metric alert rules.`, + }, + create: { + fn: "createOrganizationProjectDetector", + note: `Creates a ${METRIC_DETECTOR_TYPE} detector from the CLI metric alert configuration.`, + }, + update: { + fn: "updateOrganizationDetector", + note: "The CLI merges changes into the existing metric alert configuration before updating the detector.", + }, + delete: { + fn: "deleteOrganizationDetector", + note: `The CLI resolves a ${METRIC_DETECTOR_TYPE} detector before deleting it.`, + }, +} satisfies Record; /** * List metric alert rules for an organization with cursor-based pagination. @@ -485,7 +512,7 @@ async function getMetricDetector( result, `Failed to get metric alert rule '${ruleId}'`, ); - if (data.type !== undefined && data.type !== "metric_issue") { + if (data.type !== undefined && data.type !== METRIC_DETECTOR_TYPE) { throw new ApiError( `Metric alert rule '${ruleId}' not found`, 404, @@ -705,7 +732,7 @@ function buildDetectorBody( !(triggers && !isDetectorConditionList(triggers)); const detectorBody: Record = { name: typeof body.name === "string" ? body.name : "", - type: "metric_issue", + type: METRIC_DETECTOR_TYPE, dataSources: [buildDetectorDataSource(body)], config: keepStoredConfig ? body.detectorConfig diff --git a/packages/cli/src/lib/api/logs.ts b/packages/cli/src/lib/api/logs.ts index 2d192387e..b1f0c22ce 100644 --- a/packages/cli/src/lib/api/logs.ts +++ b/packages/cli/src/lib/api/logs.ts @@ -19,6 +19,7 @@ import { type TraceLog, TraceLogsResponseSchema, } from "../../types/index.js"; +import type { ApiReference } from "../api-schema.js"; import { ApiError } from "../errors.js"; import { decodeUuidV7Timestamp } from "../hex-id.js"; import { resolveOrgRegion } from "../region.js"; @@ -35,6 +36,14 @@ import { } from "./infrastructure.js"; import { getTraceItemDetail } from "./traces.js"; +const LOG_DATASET = "logs" as const; + +/** Shared API discovery context for log listing and item lookup. */ +export const LOGS_API = { + fn: "listOrganizationEvents", + note: `Uses dataset=${LOG_DATASET}; the CLI adds fields and filters to list or look up log entries.`, +} satisfies ApiReference; + /** Sort direction for log queries: newest-first or oldest-first. */ export type LogSortDirection = "newest" | "oldest"; @@ -206,7 +215,7 @@ export async function listLogs( ...config, path: { organization_id_or_slug: orgSlug }, query: { - dataset: "logs", + dataset: LOG_DATASET, field: fields, project: numericProjectId === undefined ? undefined : [numericProjectId], @@ -336,7 +345,7 @@ async function getLogsBatch( ) { // `sampling` is supported by the backend but absent from the SDK's query type. const queryParams = { - dataset: "logs" as const, + dataset: LOG_DATASET, field: fields, project: numericProjectId === undefined ? undefined : [numericProjectId], query: `${projectFilter}sentry.item_id:[${ids.join(",")}]`, diff --git a/packages/cli/src/lib/api/traces.ts b/packages/cli/src/lib/api/traces.ts index 97da21964..6c6cb2963 100644 --- a/packages/cli/src/lib/api/traces.ts +++ b/packages/cli/src/lib/api/traces.ts @@ -23,6 +23,7 @@ import { // Re-export so existing callers (api-client.ts, formatters/trace.ts) don't need to change. export type { TraceItemAttribute, TraceItemDetail } from "../../types/index.js"; +import type { ApiReference } from "../api-schema.js"; import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; import { isAllDigits } from "../utils.js"; @@ -37,6 +38,21 @@ import { const log = logger.withTag("api.traces"); +const TRANSACTION_DATASET = "spans"; +const TRANSACTION_QUERY = "is_transaction:true"; + +/** Trace list queries root spans rather than a dedicated traces collection. */ +export const LIST_TRANSACTIONS_API = { + fn: "listOrganizationEvents", + note: `Uses dataset=${TRANSACTION_DATASET} with ${TRANSACTION_QUERY}; the CLI resolves projects and paginates.`, +} satisfies ApiReference; + +/** Main trace view API; full span attributes may require additional requests. */ +export const TRACE_DETAILS_API = { + fn: "getOrganizationTrace", + note: "Retrieves the trace tree; the CLI resolves context and may fetch additional span attributes.", +} satisfies ApiReference; + // --------------------------------------------------------------------------- // Trace item (span) detail types // --------------------------------------------------------------------------- @@ -454,7 +470,7 @@ async function fetchTransactionsPage( ); const projectFilter = numericProjectId === undefined ? `project:${projectSlug}` : ""; - const fullQuery = ["is_transaction:true", projectFilter, options.query] + const fullQuery = [TRANSACTION_QUERY, projectFilter, options.query] .filter(Boolean) .join(" "); @@ -464,7 +480,7 @@ async function fetchTransactionsPage( `/organizations/${orgSlug}/events/`, { params: { - dataset: "spans", + dataset: TRANSACTION_DATASET, field: TRANSACTION_FIELDS, project: numericProjectId === undefined diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index bc073dd4b..35225c61b 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -37,6 +37,7 @@ import { numberParser as stricliNumberParser, } from "@stricli/core"; import type { Writer } from "../types/index.js"; +import type { ApiReference } from "./api-schema.js"; import { appendCacheHint } from "./cache-hint.js"; import { type CommandExample, exampleSteps } from "./command-example.js"; import { getAuthConfig, hasUsableStoredAccount } from "./db/auth.js"; @@ -107,6 +108,8 @@ type StricliBuilderArgs = export type CommandDocumentation = StricliBuilderArgs["docs"] & { readonly examples?: readonly CommandExample[]; + /** Related API operations, with context rather than equivalence claims. */ + readonly relatedApi?: readonly ApiReference[]; }; /** @@ -926,6 +929,10 @@ export function buildCommand< if (builderArgs.destructive) { (cmd as unknown as Record).__destructive = true; } + if (builderArgs.docs.relatedApi?.length) { + (cmd as unknown as Record).__relatedApi = + builderArgs.docs.relatedApi; + } // Attach the JSON schema to the built command as a non-standard property. // introspect.ts reads this to populate CommandInfo.jsonFields for help diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index c77a380c9..c7d6d4c28 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -18,6 +18,11 @@ import { ORG_PROJECT_TARGET, ORG_TARGET, } from "./completion-target.js"; +import type { + ApiEndpoint, + ApiReference, + CommandApiReference, +} from "./api-schema.js"; import { extractSchemaFields, type SchemaFieldInfo, @@ -33,6 +38,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 */ @@ -70,6 +76,8 @@ export type Command = { __examples?: readonly CommandExample[]; /** Set by `buildCommand({ destructive: true })` and `buildDeleteCommand`. */ __destructive?: boolean; + /** Related API metadata retained by the command builder. */ + __relatedApi?: readonly ApiReference[]; }; /** Positional parameter definitions — either fixed-length tuple or variadic array */ @@ -128,6 +136,7 @@ export type CommandInfo = { jsonFields?: SchemaFieldInfo[]; /** Present (and `true`) when the command is destructive or irreversible */ destructive?: true; + relatedApi?: readonly ApiReference[]; }; /** Extracted metadata for a single flag */ @@ -326,6 +335,7 @@ export function buildCommandInfo(cmd: Command, path: string): CommandInfo { ), jsonFields: jsonFields?.length ? jsonFields : undefined, ...(cmd.__destructive ? { destructive: true as const } : {}), + ...(cmd.__relatedApi?.length ? { relatedApi: cmd.__relatedApi } : {}), }; } @@ -483,26 +493,33 @@ export function extractCompletionTargets( return targets; } -/** 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. */ +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, @@ -512,85 +529,100 @@ export function resolveCommandPath( return null; } - const first = path[0]; - const rest = path.slice(1); + const publicPaths = publicTargetPaths(routeMap); + let target: RouteTarget = routeMap; + let canonicalPath: string[] = []; - // 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); - - 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; + canonicalPath = entry.hidden + ? [...(publicPaths.get(entry.target) ?? [])] + : [...canonicalPath, entry.name.original]; + target = entry.target; } - // More path segments — must be a route map to navigate deeper - if (!isRouteMap(target)) { - return null; - } - - // 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, +): 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) => + (command.relatedApi ?? []).map((reference) => ({ + ...reference, + command: command.path, + })), + ); +} + +/** Reject stale or ambiguous references before generating command documentation. */ +export function validateCommandApiReferences( + routeMap: RouteMap, + endpoints: readonly Pick[], +): void { + const counts = new Map(); + for (const endpoint of endpoints) { + counts.set(endpoint.fn, (counts.get(endpoint.fn) ?? 0) + 1); + } + for (const route of extractAllRoutes(routeMap)) { + for (const command of route.commands) { + for (const reference of command.relatedApi ?? []) { + const count = counts.get(reference.fn) ?? 0; + if (!reference.fn || count !== 1) { + throw new Error( + `${command.path}: related API "${reference.fn}" matches ${count} schema operations; expected exactly one`, + ); + } + } + } + } } diff --git a/packages/cli/test/commands/schema-path.test.ts b/packages/cli/test/commands/schema-path.test.ts index 4b118d817..6fd6089de 100644 --- a/packages/cli/test/commands/schema-path.test.ts +++ b/packages/cli/test/commands/schema-path.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test, vi } from "vitest"; import { schemaCommand } from "../../src/commands/schema.js"; import type { SentryContext } from "../../src/context.js"; +import { getAllEndpoints } from "../../src/lib/api-schema.js"; import { ResolutionError } from "../../src/lib/errors.js"; const ORG_ISSUES = "/api/0/organizations/{organization_id_or_slug}/issues/"; @@ -35,6 +36,125 @@ function stdoutOf(stdoutWrite: ReturnType): string { } describe("sentry schema path lookup", () => { + test.each([ + ["listOrganizationIssues"], + ["issues", "listOrganizationIssues"], + ["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 }, ...args); + expect(JSON.parse(stdoutOf(stdoutWrite))).toEqual( + getAllEndpoints().find( + (endpoint) => endpoint.fn === "listOrganizationIssues", + ), + ); + }); + + 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.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 }, ...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); + } + }); + + 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("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("METHOD + path returns that endpoint", async () => { const { context, stdoutWrite } = createMockContext(); const func = await schemaCommand.loader(); @@ -126,7 +246,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 +255,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..c46d2cc66 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,10 +32,30 @@ 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; 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..2bed60e48 --- /dev/null +++ b/packages/cli/test/lib/api-schema-discovery.test.ts @@ -0,0 +1,154 @@ +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("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, + ]); + }); +}); 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..5908e1f51 --- /dev/null +++ b/packages/cli/test/lib/command-api-references.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "vitest"; +import { routes } from "../../src/app.js"; +import { + type ApiReference, + getAllEndpoints, +} from "../../src/lib/api-schema.js"; +import { buildCommand } from "../../src/lib/command.js"; +import { + getCommandApiReferences, + type RouteMap, + validateCommandApiReferences, +} from "../../src/lib/introspect.js"; +import { buildRouteMap } from "../../src/lib/route-map.js"; + +function command(relatedApi?: readonly ApiReference[]) { + return buildCommand({ + docs: { brief: "Example command", relatedApi }, + auth: false, + async *func() { + yield; + }, + }); +} + +function exampleRoutes() { + const list = command([{ fn: "queryEvents", note: "Uses the logs dataset." }]); + const view = command([ + { fn: "queryEvents", note: "Filters by log item ID." }, + ]); + const log = buildRouteMap({ + routes: { list, view }, + aliases: { details: "view" }, + docs: { brief: "Logs" }, + }); + const metrics = buildRouteMap({ + routes: { list: command([{ fn: "listDetectors" }]) }, + docs: { brief: "Metric alerts" }, + }); + return buildRouteMap({ + routes: { + log, + logs: list, + internal: command([{ fn: "notPublished" }]), + alert: buildRouteMap({ + routes: { metrics }, + docs: { brief: "Alerts" }, + }), + future: command([{ fn: "newOperation" }]), + local: command(), + }, + docs: { brief: "CLI", hideRoute: { logs: true, internal: true } }, + }) as unknown as RouteMap; +} + +describe("command API discovery", () => { + test("reads metadata from new public commands and full nested paths", () => { + const tree = exampleRoutes(); + expect(getCommandApiReferences(tree, "future")).toEqual([ + { command: "sentry future", fn: "newOperation" }, + ]); + expect(getCommandApiReferences(tree, "alert metrics list")).toEqual([ + { command: "sentry alert metrics list", fn: "listDetectors" }, + ]); + expect(getCommandApiReferences(tree, "alert metrics")).toEqual( + getCommandApiReferences(tree, "alert"), + ); + }); + + test("resolves registered and hidden public aliases to canonical command paths", () => { + const tree = exampleRoutes(); + const list = getCommandApiReferences(tree, "log list"); + expect(getCommandApiReferences(tree, "log ls")).toEqual(list); + expect(getCommandApiReferences(tree, "logs")).toEqual(list); + expect(getCommandApiReferences(tree, "log details")).toEqual( + getCommandApiReferences(tree, "log show"), + ); + expect(getCommandApiReferences(tree, "log show")[0]?.command).toBe( + "sentry log view", + ); + }); + + test("groups keep each command's context even when they share an API", () => { + expect(getCommandApiReferences(exampleRoutes(), "log")).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)).toEqual([]); + } + }); + + test("rejects missing and ambiguous API references as the catalog changes", () => { + const tree = exampleRoutes(); + const endpoints = [ + { fn: "queryEvents" }, + { fn: "listDetectors" }, + { fn: "newOperation" }, + ]; + expect(() => validateCommandApiReferences(tree, endpoints)).not.toThrow(); + expect(() => + validateCommandApiReferences(tree, endpoints.slice(1)), + ).toThrow( + 'sentry log list: related API "queryEvents" matches 0 schema operations', + ); + expect(() => + validateCommandApiReferences(tree, [...endpoints, { fn: "queryEvents" }]), + ).toThrow( + 'sentry log list: related API "queryEvents" matches 2 schema operations', + ); + }); + + test("published command references resolve against the bundled API catalog", () => { + const tree = routes as unknown as RouteMap; + expect(() => + validateCommandApiReferences(tree, getAllEndpoints()), + ).not.toThrow(); + expect(getCommandApiReferences(tree, "logs")).toEqual( + getCommandApiReferences(tree, "log list"), + ); + expect(getCommandApiReferences(tree, "trace list")).toEqual([ + expect.objectContaining({ + fn: "listOrganizationEvents", + note: expect.stringContaining("is_transaction:true"), + }), + ]); + expect(getCommandApiReferences(tree, "log view")).toEqual([ + expect.objectContaining({ + fn: "listOrganizationEvents", + note: expect.stringContaining("dataset=logs"), + }), + ]); + expect(getCommandApiReferences(tree, "trace show")).toEqual([ + expect.objectContaining({ fn: "getOrganizationTrace" }), + ]); + expect( + getCommandApiReferences(tree, "alert metrics").map((reference) => ({ + command: reference.command, + fn: reference.fn, + })), + ).toEqual([ + { command: "sentry alert metrics list", fn: "listOrganizationDetectors" }, + { command: "sentry alert metrics view", fn: "getOrganizationDetector" }, + { + 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..893e76b1f --- /dev/null +++ b/packages/cli/test/script/generate-api-schema.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from "vitest"; +import { + extractApiEndpoints, + type OpenApiSpec, +} from "../../script/lib/api-schema.js"; + +const EVENTS_PATH = "/api/0/organizations/{organization_id_or_slug}/events/"; + +/** Reduced public OpenAPI Explore operation; no account or event data. */ +function exploreSpec(datasets = ["errors", "logs", "spans"]): OpenApiSpec { + return { + paths: { + [EVENTS_PATH]: { + get: { + operationId: "listOrganizationEvents", + summary: "Query Explore Events in Table Format", + description: "Retrieves explore data for a given organization.", + tags: ["Explore"], + parameters: [ + { + in: "query", + name: "dataset", + required: true, + description: + "Which dataset to query. The chosen dataset determines which fields are queryable.", + schema: { type: "string", enum: datasets }, + }, + { + in: "query", + name: "field", + required: true, + schema: { type: "array" }, + }, + { in: "query", name: "query", schema: { type: "string" } }, + ], + }, + }, + }, + }; +} + +describe("extractApiEndpoints", () => { + test("preserves Explore dataset vocabulary and the existing endpoint fields", () => { + const endpoints = extractApiEndpoints( + exploreSpec(), + new Map([[`GET:${EVENTS_PATH}`, "listOrganizationEvents"]]), + ); + + expect(endpoints).toEqual([ + { + fn: "listOrganizationEvents", + method: "GET", + path: EVENTS_PATH, + resource: "events", + operationId: "listOrganizationEvents", + summary: "Query Explore Events in Table Format", + description: "Retrieves explore data for a given organization.", + tags: ["Explore"], + deprecated: false, + pathParams: ["organization_id_or_slug"], + queryParams: ["dataset", "field", "query"], + queryParameters: [ + { + name: "dataset", + required: true, + description: + "Which dataset to query. The chosen dataset determines which fields are queryable.", + type: "string", + enum: ["errors", "logs", "spans"], + }, + { name: "field", required: true, type: "array" }, + { name: "query", required: false, type: "string" }, + ], + }, + ]); + }); + + test("includes new operations and datasets without adding vocabulary mappings", () => { + const spec = exploreSpec(["errors", "logs", "spans", "uptime_results"]); + // Based on the separate Explore timeseries operation: the API can add it + // before the installed SDK has a mapped function name. + const timeseriesPath = + "/api/0/organizations/{organization_id_or_slug}/events-timeseries/"; + spec.paths[timeseriesPath] = { + get: { + ...spec.paths[EVENTS_PATH]?.get, + operationId: "listOrganizationEventsTimeseries", + summary: "Query Explore Events in Timeseries Format", + }, + }; + + const endpoints = extractApiEndpoints(spec, new Map()); + + expect(endpoints).toHaveLength(2); + for (const endpoint of endpoints) { + expect(endpoint.fn).toBe(""); + expect(endpoint.queryParameters).toContainEqual( + expect.objectContaining({ + name: "dataset", + enum: ["errors", "logs", "spans", "uptime_results"], + }), + ); + } + expect(endpoints).toContainEqual( + expect.objectContaining({ + path: timeseriesPath, + operationId: "listOrganizationEventsTimeseries", + summary: "Query Explore Events in Timeseries Format", + tags: ["Explore"], + }), + ); + }); + + test("inherits path parameters and lets operations replace matching query parameters", () => { + const spec: OpenApiSpec = { + paths: { + [EVENTS_PATH]: { + parameters: [ + { in: "path", name: "organization_id_or_slug", required: true }, + { in: "query", name: "cursor", schema: { type: "string" } }, + { + in: "query", + name: "dataset", + schema: { type: "string", enum: ["errors"] }, + }, + ], + get: { + parameters: [ + { + in: "query", + name: "dataset", + required: true, + schema: { type: "string", enum: ["logs", "spans"] }, + }, + { in: "header", name: "cursor", required: true }, + ], + }, + post: {}, + }, + }, + }; + + const endpoints = extractApiEndpoints(spec, new Map()); + + expect(endpoints.find((endpoint) => endpoint.method === "GET")).toEqual( + expect.objectContaining({ + pathParams: ["organization_id_or_slug"], + queryParams: ["cursor", "dataset"], + queryParameters: [ + { name: "cursor", required: false, type: "string" }, + { + name: "dataset", + required: true, + type: "string", + enum: ["logs", "spans"], + }, + ], + }), + ); + expect(endpoints.find((endpoint) => endpoint.method === "POST")).toEqual( + expect.objectContaining({ + queryParameters: [ + { name: "cursor", required: false, type: "string" }, + { + name: "dataset", + required: false, + type: "string", + enum: ["errors"], + }, + ], + }), + ); + }); + + test("retains scalar and repeated query enum values", () => { + const spec: OpenApiSpec = { + paths: { + "/api/0/organizations/{organization_id_or_slug}/stats_v2/": { + get: { + parameters: [ + { + in: "query", + name: "groupBy", + required: true, + schema: { + type: "array", + items: { enum: ["outcome", "category", "reason", "project"] }, + }, + }, + { + in: "query", + name: "category", + schema: { + type: "string", + enum: ["error", "transaction", "monitor"], + }, + }, + ], + }, + }, + }, + }; + + const [endpoint] = extractApiEndpoints(spec, new Map()); + + expect(endpoint?.queryParameters).toEqual([ + { + name: "groupBy", + required: true, + type: "array", + enum: ["outcome", "category", "reason", "project"], + }, + { + name: "category", + required: false, + type: "string", + enum: ["error", "transaction", "monitor"], + }, + ]); + }); +}); From b04502cc77d3188518a3f49a91ff263bf660d8cc Mon Sep 17 00:00:00 2001 From: betegon Date: Sun, 25 Oct 2026 22:40:55 +0100 Subject: [PATCH 2/7] fix(cli): preserve command discovery with HTTP filters --- packages/cli/src/commands/schema.ts | 64 +++++++---------- packages/cli/src/lib/api-schema.ts | 21 +++++- .../cli/test/commands/schema-path.test.ts | 72 +++++++++++++------ .../test/script/generate-api-schema.test.ts | 16 +---- 4 files changed, 96 insertions(+), 77 deletions(-) diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index 4b5649e9c..d61b8807b 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -16,7 +16,6 @@ * sentry schema monitor* → glob search for resources */ -import { routes } from "../app.js"; import type { SentryContext } from "../context.js"; import { type ApiEndpoint, @@ -254,14 +253,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; - } - return [search, ...args].join(" "); -} - function queryLabel(resource: string, operation?: string): string { return operation ? `${resource} ${operation}` : resource; } @@ -540,53 +531,48 @@ export const schemaCommand = buildCommand({ }, }, }, - // Stricli requires AsyncGenerator but schema queries are synchronous (in-memory JSON) async *func(this: SentryContext, flags: SchemaFlags, ...args: string[]) { - const positionalQuery = args.join(" "); - - // --search takes priority - if (flags.search) { - const query = resolveSearchQuery(flags.search, args); - const references = getCommandApiReferences( - routes as unknown as RouteMap, - query, - ); - const results = searchEndpoints(query, undefined, references); - if (results.length === 0) { - throw new OutputError({ - kind: "endpoints", - endpoints: [], - } satisfies SchemaResult); - } - return yield new CommandOutput({ - kind: "endpoints", - endpoints: results, - }); - } + const query = flags.search + ? [flags.search, ...args].join(" ") + : args.join(" "); - // --all: flat endpoint list - if (flags.all) { + // --search takes priority over --all. + if (flags.all && !flags.search) { return yield new CommandOutput({ kind: "endpoints", endpoints: getAllEndpoints(), }); } - // No positional args: show resource summary - if (!positionalQuery) { - const resources = getResourceSummaries(); + if (!query) { return yield new CommandOutput({ kind: "resources", - resources, + resources: getResourceSummaries(), }); } + const { routes } = await import("../app.js"); const references = getCommandApiReferences( routes as unknown as RouteMap, - positionalQuery, + 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: "endpoints", + endpoints: results, + }); + } + return yield new CommandOutput( - resolveResourceQuery(positionalQuery, undefined, references), + resolveResourceQuery(query, undefined, references), ); }, }); diff --git a/packages/cli/src/lib/api-schema.ts b/packages/cli/src/lib/api-schema.ts index afe5128b0..cba9bb039 100644 --- a/packages/cli/src/lib/api-schema.ts +++ b/packages/cli/src/lib/api-schema.ts @@ -10,25 +10,39 @@ import endpoints from "../generated/api-schema.json"; /** 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; }; -export type CommandApiReference = ApiReference & { command: 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 }[]; }; }; @@ -43,12 +57,15 @@ export type ApiEndpoint = { 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; @@ -156,7 +173,7 @@ 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, diff --git a/packages/cli/test/commands/schema-path.test.ts b/packages/cli/test/commands/schema-path.test.ts index 6fd6089de..5ffa4012e 100644 --- a/packages/cli/test/commands/schema-path.test.ts +++ b/packages/cli/test/commands/schema-path.test.ts @@ -6,7 +6,7 @@ import { describe, expect, test, vi } from "vitest"; import { schemaCommand } from "../../src/commands/schema.js"; import type { SentryContext } from "../../src/context.js"; import { getAllEndpoints } from "../../src/lib/api-schema.js"; -import { ResolutionError } from "../../src/lib/errors.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}`; @@ -39,6 +39,7 @@ describe("sentry schema path lookup", () => { test.each([ ["listOrganizationIssues"], ["issues", "listOrganizationIssues"], + [SKILL_QUERY], ["GET", ORG_ISSUES], ])("exact lookup preserves the endpoint object for %j", async (...args) => { const { context, stdoutWrite } = createMockContext(); @@ -130,6 +131,55 @@ describe("sentry schema path lookup", () => { ); }); + 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(); @@ -155,26 +205,6 @@ describe("sentry schema path lookup", () => { ).toBe(true); }); - test("METHOD + path returns that endpoint", async () => { - const { context, stdoutWrite } = createMockContext(); - const func = await schemaCommand.loader(); - await func.call(context, { json: true, all: false }, SKILL_QUERY); - - const parsed = JSON.parse(stdoutOf(stdoutWrite)); - expect(parsed.fn).toBe("listOrganizationIssues"); - expect(parsed.method).toBe("GET"); - expect(parsed.path).toBe(ORG_ISSUES); - }); - - test("unquoted METHOD and path as two positionals also match", async () => { - const { context, stdoutWrite } = createMockContext(); - const func = await schemaCommand.loader(); - await func.call(context, { json: true, all: false }, "GET", ORG_ISSUES); - - const parsed = JSON.parse(stdoutOf(stdoutWrite)); - expect(parsed.fn).toBe("listOrganizationIssues"); - }); - test("path without METHOD lists methods on that URL", async () => { const { context, stdoutWrite } = createMockContext(); const func = await schemaCommand.loader(); diff --git a/packages/cli/test/script/generate-api-schema.test.ts b/packages/cli/test/script/generate-api-schema.test.ts index 893e76b1f..468500e8c 100644 --- a/packages/cli/test/script/generate-api-schema.test.ts +++ b/packages/cli/test/script/generate-api-schema.test.ts @@ -172,7 +172,7 @@ describe("extractApiEndpoints", () => { ); }); - test("retains scalar and repeated query enum values", () => { + test("retains repeated query enum values", () => { const spec: OpenApiSpec = { paths: { "/api/0/organizations/{organization_id_or_slug}/stats_v2/": { @@ -187,14 +187,6 @@ describe("extractApiEndpoints", () => { items: { enum: ["outcome", "category", "reason", "project"] }, }, }, - { - in: "query", - name: "category", - schema: { - type: "string", - enum: ["error", "transaction", "monitor"], - }, - }, ], }, }, @@ -210,12 +202,6 @@ describe("extractApiEndpoints", () => { type: "array", enum: ["outcome", "category", "reason", "project"], }, - { - name: "category", - required: false, - type: "string", - enum: ["error", "transaction", "monitor"], - }, ]); }); }); From 090085f04e41257e826ac725f3b2d418273b3bd3 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 27 Oct 2026 20:20:35 +0100 Subject: [PATCH 3/7] fix(cli): derive schema relationships from command usage --- .../cli-docs/src/fragments/commands/schema.md | 4 +- packages/cli/DEVELOPMENT.md | 28 +- packages/cli/package.json | 3 +- .../skills/sentry-cli/references/schema.md | 3 + .../cli/script/check-destructive-commands.ts | 352 ++-------- packages/cli/script/ensure-generated.mjs | 1 + packages/cli/script/generate-api-schema.ts | 55 +- packages/cli/script/generate-command-api.ts | 136 ++++ packages/cli/script/generate-command-docs.ts | 5 +- packages/cli/script/lib/api-schema.ts | 105 ++- packages/cli/script/lib/source-api-usage.ts | 639 ++++++++++++++++++ .../cli/src/commands/alert/metrics/create.ts | 2 - .../cli/src/commands/alert/metrics/delete.ts | 2 - .../cli/src/commands/alert/metrics/edit.ts | 2 - .../cli/src/commands/alert/metrics/list.ts | 6 +- .../cli/src/commands/alert/metrics/view.ts | 2 - packages/cli/src/commands/log/list.ts | 2 - packages/cli/src/commands/log/view.ts | 4 +- packages/cli/src/commands/schema.ts | 42 +- packages/cli/src/commands/trace/list.ts | 2 - packages/cli/src/commands/trace/view.ts | 2 - packages/cli/src/lib/api-schema.ts | 60 +- packages/cli/src/lib/api/alerts.ts | 33 +- packages/cli/src/lib/api/logs.ts | 13 +- packages/cli/src/lib/api/traces.ts | 20 +- packages/cli/src/lib/command.ts | 7 - packages/cli/src/lib/introspect.ts | 41 +- .../cli/test/commands/schema-path.test.ts | 28 + packages/cli/test/commands/schema.test.ts | 24 +- .../cli/test/lib/api-schema-discovery.test.ts | 36 + .../test/lib/command-api-references.test.ts | 192 +++--- .../test/script/generate-api-schema.test.ts | 84 +++ .../cli/test/script/source-api-usage.test.ts | 228 +++++++ 33 files changed, 1499 insertions(+), 664 deletions(-) create mode 100644 packages/cli/script/generate-command-api.ts create mode 100644 packages/cli/script/lib/source-api-usage.ts create mode 100644 packages/cli/test/script/source-api-usage.test.ts diff --git a/apps/cli-docs/src/fragments/commands/schema.md b/apps/cli-docs/src/fragments/commands/schema.md index 34b4bc2db..36e366af5 100644 --- a/apps/cli-docs/src/fragments/commands/schema.md +++ b/apps/cli-docs/src/fragments/commands/schema.md @@ -1,8 +1,8 @@ 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 when a relationship is -declared. A related API does not necessarily reproduce the command's filters, +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 an exact operation ID, SDK function name, or HTTP method and path for a diff --git a/packages/cli/DEVELOPMENT.md b/packages/cli/DEVELOPMENT.md index 2ad0007a2..88ab7db01 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -107,18 +107,22 @@ pnpm run build `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. - -To associate a CLI command with its API, add optional `docs.relatedApi` metadata -to its command builder. Reuse a descriptor exported by the API helper, such as -`LOGS_API` in `src/lib/api/logs.ts`. Each reference contains the exact generated -SDK function name (`fn`) and an optional `note` describing the dataset or filter -used. Share selector constants with the request where practical. A reference -documents related functionality, not an equivalent request. - -Introspection derives command paths, registered aliases, and group membership -from the route tree. Groups collect their public commands' references, so adding -a command requires no separate group mapping. Documentation generation checks -that every reference matches exactly one operation in the generated catalog. +The generator prefers `@sentry/api/operations`. Until that export is published +and included in the pinned SDK, older versions retain the version-matched +OpenAPI extraction fallback. Remove that fallback when upgrading the dependency. + +`pnpm run generate:command-api` follows command implementations and their +helpers to generate API relationships from SDK calls and recognized raw request +paths. 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 diff --git a/packages/cli/package.json b/packages/cli/package.json index ba3e9e9d1..30b572a64 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/references/schema.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md index f7987673f..487c7af38 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 @@ -58,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 2a7cd780f..fdd0a0fc1 100644 --- a/packages/cli/script/ensure-generated.mjs +++ b/packages/cli/script/ensure-generated.mjs @@ -26,6 +26,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 af9c24092..9f536fd1b 100644 --- a/packages/cli/script/generate-api-schema.ts +++ b/packages/cli/script/generate-api-schema.ts @@ -1,16 +1,11 @@ #!/usr/bin/env tsx /** - * Generate API Schema Index from Sentry's OpenAPI Specification + * Generate API Schema Index from the installed Sentry 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. + * Bundled into the CLI for runtime introspection via `sentry schema`. + * Older SDKs without @sentry/api/operations temporarily use their versioned + * OpenAPI spec and bundled function names. Remove that compatibility path + * after the pinned SDK dependency includes the operation catalog. * * Usage: * tsx script/generate-api-schema.ts @@ -22,7 +17,11 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { extractApiEndpoints, type OpenApiSpec } from "./lib/api-schema.js"; +import { + extractApiEndpoints, + loadSdkApiEndpoints, + type OpenApiSpec, +} from "./lib/api-schema.js"; /** Resolve the root directory of @sentry/api by finding its main entry point. */ function sentryApiDir(): string { @@ -70,8 +69,8 @@ async function buildSdkFunctionMap(): Promise> { let match = funcPattern.exec(js); while (match !== null) { funcs.push({ - name: match[1], - method: match[2].toUpperCase(), + name: match[1]!, + method: match[2]!.toUpperCase(), index: match.index, }); match = funcPattern.exec(js); @@ -81,7 +80,7 @@ async function buildSdkFunctionMap(): Promise> { const urls: { url: string; index: number }[] = []; match = urlPattern.exec(js); while (match !== null) { - urls.push({ url: match[1], index: match.index }); + urls.push({ url: match[1]!, index: match.index }); match = urlPattern.exec(js); } @@ -101,20 +100,22 @@ async function buildSdkFunctionMap(): Promise> { // 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}`, - ); +let endpoints = await loadSdkApiEndpoints(); +if (endpoints) { + console.log("Using operation catalog from @sentry/api/operations..."); +} else { + const openApiUrl = await getOpenApiUrl(); + console.log(`SDK catalog not yet available; fetching ${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; + const sdkMap = await buildSdkFunctionMap(); + endpoints = extractApiEndpoints(spec, sdkMap); } -const spec = (await response.json()) as OpenApiSpec; - -console.log("Building SDK function name map from @sentry/api..."); -const sdkMap = await buildSdkFunctionMap(); - -const endpoints = extractApiEndpoints(spec, sdkMap); 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/generate-command-docs.ts b/packages/cli/script/generate-command-docs.ts index cdd3c7b5e..36e735a5a 100644 --- a/packages/cli/script/generate-command-docs.ts +++ b/packages/cli/script/generate-command-docs.ts @@ -60,9 +60,7 @@ import type { } from "../src/lib/introspect.js"; const { routes } = await import("../src/app.js"); -const { extractAllRoutes, validateCommandApiReferences } = - await import("../src/lib/introspect.js"); -const { getAllEndpoints } = await import("../src/lib/api-schema.js"); +const { extractAllRoutes } = await import("../src/lib/introspect.js"); const { ENV_VAR_REGISTRY } = await import("../src/lib/env-registry.js"); const DOCS_DIR = `${DOCS_CONTENT}/commands`; @@ -388,7 +386,6 @@ async function readTopLevelFragment(fragmentName: string): Promise { // --------------------------------------------------------------------------- const routeMap = routes as unknown as RouteMap; -validateCommandApiReferences(routeMap, getAllEndpoints()); const routeInfos = extractAllRoutes(routeMap).filter( (r) => !SKIP_ROUTES.has(r.name), ); diff --git a/packages/cli/script/lib/api-schema.ts b/packages/cli/script/lib/api-schema.ts index e00140397..df9a7683b 100644 --- a/packages/cli/script/lib/api-schema.ts +++ b/packages/cli/script/lib/api-schema.ts @@ -1,4 +1,4 @@ -/** Extract the bundled discovery index from a dereferenced OpenAPI document. */ +/** Project SDK operation metadata into the CLI's bundled discovery index. */ import type { ApiEndpoint, @@ -8,7 +8,87 @@ import type { const HTTP_METHODS = ["get", "post", "put", "delete", "patch"] as const; const PATH_PARAM_PATTERN = /\{(\w+)\}/g; -/** Only the OpenAPI fields used by the discovery index. */ +/** + * Structural subset of the forthcoming @sentry/api/operations contract. Import + * its public type directly once the pinned SDK includes that entry point. + */ +type SdkOperation = Pick< + ApiEndpoint, + "method" | "path" | "operationId" | "summary" | "description" | "deprecated" +> & { + /** Actual exported SDK function name after normalization. */ + functionName: string; + /** Categories assigned by the OpenAPI specification. */ + tags?: readonly string[]; + /** Parameter names present in the canonical path template. */ + pathParameters: readonly string[]; + /** Resolved query metadata; inherited parameters are already merged. */ + queryParameters: readonly (Omit & { + /** Allowed scalar values, or allowed item values for an array parameter. */ + enum?: readonly (string | number | boolean | null)[]; + })[]; +}; + +/** Keep CLI grouping, output names and legacy deprecation detection local. */ +export function projectApiOperations( + operations: readonly SdkOperation[], +): 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); + }); +} + +/** An older SDK may lack the entry point; a broken exported catalog must fail. */ +export async function loadSdkApiEndpoints( + resolveModule: (specifier: string) => string = (specifier) => + import.meta.resolve(specifier), +): Promise { + let entry: string; + try { + entry = resolveModule("@sentry/api/operations"); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED" + ) { + return; + } + throw error; + } + + const { operations } = (await import(entry)) as { + operations: readonly SdkOperation[]; + }; + return projectApiOperations(operations); +} + +/** + * OpenAPI fields used by SDK versions without /operations. Remove this + * compatibility extraction when the pinned dependency includes the catalog. + */ export type OpenApiSpec = { paths: Record; }; @@ -96,7 +176,7 @@ export function extractApiEndpoints( spec: OpenApiSpec, sdkMap: ReadonlyMap, ): ApiEndpoint[] { - const endpoints: ApiEndpoint[] = []; + const operations: SdkOperation[] = []; for (const [urlPath, pathItem] of Object.entries(spec.paths)) { for (const method of HTTP_METHODS) { @@ -109,31 +189,22 @@ export function extractApiEndpoints( const fn = sdkMap.get(`${methodUpper}:${urlPath}`) ?? ""; const queryParameters = extractQueryParameters(pathItem, operation); - endpoints.push({ - fn, + operations.push({ + functionName: fn, method: methodUpper, path: urlPath, summary: operation.summary?.trim(), description: (operation.description ?? "").trim(), tags: operation.tags, - pathParams: [...urlPath.matchAll(PATH_PARAM_PATTERN)].map((match) => + pathParameters: [...urlPath.matchAll(PATH_PARAM_PATTERN)].map((match) => match[0].slice(1, -1), ), - queryParams: queryParameters.map((parameter) => parameter.name), queryParameters, - deprecated: - operation.deprecated === true || - fn.startsWith("deprecated") || - (operation.operationId ?? "").toLowerCase().includes("deprecated"), - resource: deriveResource(urlPath), + deprecated: operation.deprecated === true, operationId: operation.operationId ?? "", }); } } - // Keep stable ordering and the existing resource/operation ID contract. - return endpoints.sort((a, b) => { - const resourceCmp = a.resource.localeCompare(b.resource); - return resourceCmp || a.operationId.localeCompare(b.operationId); - }); + return projectApiOperations(operations); } 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..bc516500a --- /dev/null +++ b/packages/cli/script/lib/source-api-usage.ts @@ -0,0 +1,639 @@ +/** Build-time API usage analysis shared by schema discovery and DELETE checks. */ + +import { dirname, posix } from "node:path"; +import ts from "typescript"; + +/** Identity supplied by the generated SDK operation catalog. */ +export type SourceApiOperation = { + /** Exported SDK function. */ + functionName: string; + /** Uppercase HTTP method. */ + method: string; + /** Canonical API path with OpenAPI placeholders. */ + path: string; +}; + +/** An operation reachable from a source declaration. */ +export type SourceOperationUsage = SourceApiOperation & { + /** Known query values or fixed terms; callers may add further filters. */ + query?: Record; +}; + +/** A raw request whose identity cannot be established from the public catalog. */ +export type UnresolvedApiRequest = { + /** Source module, using the caller's source-map keys. */ + file: string; + /** One-based request call line. */ + line: number; + /** Known HTTP method, when statically available. */ + method?: string; + /** Known path template, when statically available. */ + path?: string; + /** Why no operation was selected. */ + reason: string; +}; + +/** Source-derived relationships; no command is executed to obtain them. */ +export type SourceApiUsage = { + /** All statically reachable API operations, preserving distinct contexts. */ + operations: SourceOperationUsage[]; + /** Reachable raw requests that need explicit discovery diagnostics. */ + unresolvedRequests: UnresolvedApiRequest[]; + /** Whether a reachable request can issue DELETE, including unknown routes. */ + deletes: boolean; +}; + +/** Analysis of exports and command builders without a command-name registry. */ +export type SourceApiAnalysis = { + /** Source file → exported symbol → reachable API usage. */ + exports: Map>; + /** Exported command builders and their existing destructive declarations. */ + commands: { file: string; name: string; destructive: boolean }[]; +}; + +type Binding = { file: string; name: string }; +type Declaration = { + node: ts.Node; + refs: Set; + calls: Set; + usage: SourceApiUsage; + command: boolean; + destructive: boolean; +}; +type Module = { + source: ts.SourceFile; + imports: Map; + exports: Map; + stars: string[]; + declarations: Map; +}; + +const SDK = "@sentry/api"; +const COMMAND_BUILDERS = new Set([ + "buildCommand", + "buildListCommand", + "buildOrgListCommand", + "buildDeleteCommand", +]); +const API_PREFIX = /^\/api\/\d+(?=\/)/; +const PATH_PARAMETER = /\{[^}]*\}/g; + +function unwrap(node: ts.Node): ts.Node { + return ts.isAsExpression(node) || + ts.isSatisfiesExpression(node) || + ts.isParenthesizedExpression(node) || + ts.isNonNullExpression(node) + ? unwrap(node.expression) + : node; +} + +function sourceModule( + file: string, + specifier: string, + sources: ReadonlyMap, +): string { + if (!specifier.startsWith(".")) return specifier; + const target = posix.normalize( + posix.join(dirname(file), specifier.replace(/\.js$/, ".ts")), + ); + return !sources.has(target) && sources.has(`${target}x`) + ? `${target}x` + : target; +} + +function parseModule( + file: string, + text: string, + sources: ReadonlyMap, +): Module { + const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + const mod: Module = { + source, + imports: new Map(), + exports: new Map(), + stars: [], + declarations: new Map(), + }; + for (const statement of source.statements) { + if ( + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + if (statement.importClause?.isTypeOnly) continue; + const target = sourceModule( + file, + statement.moduleSpecifier.text, + sources, + ); + const bindings = statement.importClause?.namedBindings; + if (bindings && ts.isNamedImports(bindings)) { + for (const binding of bindings.elements) { + if (!binding.isTypeOnly) + mod.imports.set(binding.name.text, { + file: target, + name: (binding.propertyName ?? binding.name).text, + }); + } + } else if (bindings && ts.isNamespaceImport(bindings)) { + mod.imports.set(bindings.name.text, { file: target, name: "*" }); + } + continue; + } + if (ts.isExportDeclaration(statement) && !statement.isTypeOnly) { + const target = + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) + ? sourceModule(file, statement.moduleSpecifier.text, sources) + : file; + if (!statement.exportClause) mod.stars.push(target); + else if (ts.isNamedExports(statement.exportClause)) { + for (const binding of statement.exportClause.elements) { + if (!binding.isTypeOnly) + mod.exports.set(binding.name.text, { + file: target, + name: (binding.propertyName ?? binding.name).text, + }); + } + } + continue; + } + const nodes: [string, ts.Node][] = []; + if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) + nodes.push([statement.name.text, statement]); + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) && declaration.initializer) + nodes.push([declaration.name.text, declaration.initializer]); + } + } + for (const [name, node] of nodes) { + mod.declarations.set(name, { + node, + refs: new Set(), + calls: new Set(), + usage: { operations: [], unresolvedRequests: [], deletes: false }, + command: false, + destructive: false, + }); + if ( + ts.canHaveModifiers(statement) && + ts + .getModifiers(statement) + ?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) + ) + mod.exports.set(name, { file, name }); + } + } + return mod; +} + +/** + * Follow function references, imports and barrel exports to SDK calls or known + * raw request wrappers. Dynamic paths remain diagnostics rather than guesses. + * Command builders are boundaries: their callbacks are inspected, but shared + * telemetry/auth execution machinery is not treated as the command's API. + */ +export function analyzeSourceApiUsage( + sources: ReadonlyMap, + operations: readonly SourceApiOperation[], +): SourceApiAnalysis { + const modules = new Map( + [...sources].map(([file, text]) => [ + file, + parseModule(file, text, sources), + ]), + ); + const sdk = new Map( + operations.map((operation) => [operation.functionName, operation]), + ); + const identities = new Map(); + const pathKey = (path: string) => + `/${path.replace(API_PREFIX, "").split("/").filter(Boolean).join("/")}/`.replace( + PATH_PARAMETER, + "{}", + ); + for (const operation of operations) { + const key = `${operation.method}:${pathKey(operation.path)}`; + identities.set(key, [...(identities.get(key) ?? []), operation]); + } + + function resolve( + binding: Binding, + seen = new Set(), + ): Binding | undefined { + if (binding.file === SDK) return binding; + const key = `${binding.file}#${binding.name}`; + if (seen.has(key)) return; + seen.add(key); + const mod = modules.get(binding.file); + if (!mod) return; + if (mod.declarations.has(binding.name)) return binding; + const next = mod.exports.get(binding.name) ?? mod.imports.get(binding.name); + if (next) return resolve(next, seen); + for (const target of mod.stars) { + const found = resolve({ file: target, name: binding.name }, seen); + if (found) return found; + } + return; + } + + function bindingFor(file: string, node: ts.Node): Binding | undefined { + const mod = modules.get(file)!; + if (ts.isIdentifier(node)) { + for ( + let parent = node.parent; + parent && !ts.isSourceFile(parent); + parent = parent.parent + ) { + if ( + ts.isFunctionLike(parent) && + parent.parameters.some( + (parameter) => parameter.name.getText() === node.text, + ) + ) + return; + if ( + ts.isBlock(parent) && + parent.statements.some( + (statement) => + ts.isVariableStatement(statement) && + statement.declarationList.declarations.some( + (declaration) => declaration.name.getText() === node.text, + ), + ) + ) + return; + } + const imported = mod.imports.get(node.text); + return resolve(imported ?? { file, name: node.text }) ?? imported; + } + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) + ) { + const namespace = mod.imports.get(node.expression.text); + if (namespace?.name === "*") + return resolve({ file: namespace.file, name: node.name.text }); + } + return; + } + + function initializer(file: string, node: ts.Node): ts.Node | undefined { + if (!ts.isIdentifier(node)) return; + // Only lexical declarations and module constants are evaluated; function + // parameters stay unknown even if they shadow an imported operation. + for ( + let parent: ts.Node | undefined = node.parent; + parent; + parent = parent.parent + ) { + if ( + ts.isFunctionLike(parent) && + parent.parameters.some((p) => p.name.getText() === node.text) + ) + return; + if (ts.isBlock(parent) || ts.isSourceFile(parent)) { + for (const statement of parent.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && + declaration.name.text === node.text + ) + return declaration.initializer; + } + } + } + } + const binding = bindingFor(file, node); + return binding?.file === file + ? modules.get(file)?.declarations.get(binding.name)?.node + : undefined; + } + + function value( + file: string, + node: ts.Node | null | undefined, + template = false, + seen = new Set(), + ): string | undefined { + if (!node || seen.has(node)) return; + seen.add(node); + node = unwrap(node); + if (ts.isStringLiteralLike(node) || ts.isNumericLiteral(node)) + return node.text; + if (node.kind === ts.SyntaxKind.TrueKeyword) return "true"; + if (node.kind === ts.SyntaxKind.FalseKeyword) return "false"; + if (ts.isIdentifier(node)) + return value(file, initializer(file, node), template, seen); + if (ts.isTemplateExpression(node)) { + let result = node.head.text; + for (const span of node.templateSpans) { + const part = value(file, span.expression, false, new Set(seen)); + if (part === undefined && !template) return; + result += (part ?? "{}") + span.literal.text; + } + return result; + } + return; + } + + /** Undefined means absent; null means an unknown object/spread can supply it. */ + function property( + file: string, + node: ts.Node | null | undefined, + name: string, + seen = new Set(), + ): ts.Node | null | undefined { + if (!node || seen.has(node)) return; + seen.add(node); + node = unwrap(node); + if (ts.isIdentifier(node)) { + const initial = initializer(file, node); + return initial ? property(file, initial, name, seen) : null; + } + if (!ts.isObjectLiteralExpression(node)) return null; + for (const member of [...node.properties].reverse()) { + if ( + ts.isPropertyAssignment(member) && + member.name.getText().replace(/["']/g, "") === name + ) + return member.initializer; + if (ts.isShorthandPropertyAssignment(member) && member.name.text === name) + return initializer(file, member.name); + if (ts.isSpreadAssignment(member)) { + const found = property(file, member.expression, name, seen); + if (found !== undefined) return found; + } + } + return; + } + + function queryTerms( + file: string, + node: ts.Node | null | undefined, + seen = new Set(), + ): string | undefined { + if (!node || seen.has(node)) return; + const exact = value(file, node); + if (exact !== undefined) return exact; + seen.add(node); + node = unwrap(node); + if (ts.isIdentifier(node)) + return queryTerms(file, initializer(file, node), seen); + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.BarBarToken + ) + return queryTerms(file, node.left, seen); + if ( + !ts.isCallExpression(node) || + !ts.isPropertyAccessExpression(node.expression) || + node.expression.name.text !== "join" || + value(file, node.arguments[0]) !== " " + ) + return; + let array: ts.Node = node.expression.expression; + if ( + ts.isCallExpression(array) && + ts.isPropertyAccessExpression(array.expression) && + array.expression.name.text === "filter" && + array.arguments[0]?.getText() === "Boolean" + ) + array = array.expression.expression; + if (!ts.isArrayLiteralExpression(array)) return; + const terms = array.elements + .map((item) => value(file, item)) + .filter((term): term is string => !!term); + return terms.length ? terms.join(" ") : undefined; + } + + function queryContext( + file: string, + options: ts.Node | undefined, + key: string, + ): Record | undefined { + const query = property(file, options, key); + const result: Record = {}; + // These discriminate shared Explore/detector APIs. Other request options + // (limits, projections, credentials) are not a command equivalence recipe. + for (const name of ["dataset", "query"]) { + const expression = property(file, query, name); + const text = + name === "query" + ? queryTerms(file, expression) + : value(file, expression); + if (text) result[name] = text; + } + return Object.keys(result).length ? result : undefined; + } + + for (const [file, mod] of modules) { + for (const declaration of mod.declarations.values()) { + function visit(node: ts.Node): void { + if (ts.isTypeNode(node)) return; + if ( + ts.isPropertyAssignment(node) && + node.name.getText() === "destructive" && + node.initializer.kind === ts.SyntaxKind.TrueKeyword + ) + declaration.destructive = true; + if ( + ts.isPropertyAssignment(node) && + node.name.getText() === "method" && + value(file, node.initializer) === "DELETE" + ) + declaration.usage.deletes = true; + if (ts.isCallExpression(node)) { + const binding = bindingFor(file, node.expression); + if (binding) declaration.calls.add(`${binding.file}#${binding.name}`); + const name = + binding?.name ?? + (ts.isIdentifier(node.expression) ? node.expression.text : ""); + if (COMMAND_BUILDERS.has(name)) { + declaration.command = true; + if (name === "buildDeleteCommand") declaration.destructive = true; + for (const argument of node.arguments) visit(argument); + return; + } + const operation = binding?.file === SDK ? sdk.get(name) : undefined; + if (operation) { + declaration.usage.operations.push({ + ...operation, + query: queryContext(file, node.arguments[0], "query"), + }); + declaration.usage.deletes ||= operation.method === "DELETE"; + for (const argument of node.arguments) visit(argument); + return; + } + const regionalRequest = + name === "apiRequestToRegion" || + name === "apiRequestToRegionNoContent"; + if (regionalRequest || name === "apiRequest") { + const index = regionalRequest ? 1 : 0; + const options = node.arguments[index + 1]; + const methodExpression = property(file, options, "method"); + const method = + methodExpression === undefined + ? "GET" + : value(file, methodExpression)?.toUpperCase(); + const path = value(file, node.arguments[index], true); + const matches = + method && path + ? (identities.get(`${method}:${pathKey(path)}`) ?? []) + : []; + declaration.usage.deletes ||= method === "DELETE"; + if (matches.length === 1) + declaration.usage.operations.push({ + ...matches[0]!, + query: queryContext(file, options, "params"), + }); + else + declaration.usage.unresolvedRequests.push({ + file, + line: + mod.source.getLineAndCharacterOfPosition(node.getStart()) + .line + 1, + method, + path, + reason: + !method || !path + ? "Dynamic request identity" + : matches.length + ? "Ambiguous catalog path" + : "Request is not in the public catalog", + }); + for (const argument of node.arguments) visit(argument); + return; + } + } + if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node)) { + const parent = node.parent; + const isName = + (ts.isPropertyAccessExpression(parent) && parent.name === node) || + ((ts.isPropertyAssignment(parent) || + ts.isMethodDeclaration(parent) || + ts.isVariableDeclaration(parent) || + ts.isParameter(parent) || + ts.isFunctionDeclaration(parent)) && + parent.name === node); + if (!isName) { + const binding = bindingFor(file, node); + if (binding && !COMMAND_BUILDERS.has(binding.name)) { + const operation = + binding.file === SDK ? sdk.get(binding.name) : undefined; + if (operation) { + declaration.usage.operations.push(operation); + declaration.usage.deletes ||= operation.method === "DELETE"; + } else declaration.refs.add(`${binding.file}#${binding.name}`); + } + } + } + node.forEachChild(visit); + } + visit(declaration.node); + } + } + + const declarations = new Map( + [...modules].flatMap(([file, mod]) => + [...mod.declarations].map( + ([name, declaration]) => [`${file}#${name}`, declaration] as const, + ), + ), + ); + function unique(items: T[]): T[] { + return [ + ...new Map(items.map((item) => [JSON.stringify(item), item])).values(), + ]; + } + function isAlias(declaration: Declaration, ref: string): boolean { + const node = unwrap(declaration.node); + if (!ts.isIdentifier(node) && !ts.isPropertyAccessExpression(node)) + return false; + const binding = bindingFor(node.getSourceFile().fileName, node); + return !!binding && `${binding.file}#${binding.name}` === ref; + } + // Help and completion inspect command objects as data. Find these boundaries + // before propagating usage so route trees do not inherit command bodies. + let changed = true; + while (changed) { + changed = false; + for (const declaration of declarations.values()) { + for (const ref of declaration.refs) { + const child = declarations.get(ref); + if ( + !child?.command || + (!declaration.calls.has(ref) && !isAlias(declaration, ref)) + ) + continue; + if ( + !declaration.command || + (!declaration.destructive && child.destructive) + ) + changed = true; + declaration.command = true; + declaration.destructive ||= child.destructive; + } + } + } + changed = true; + while (changed) { + changed = false; + for (const declaration of declarations.values()) { + const before = JSON.stringify(declaration.usage); + for (const ref of declaration.refs) { + const child = declarations.get(ref); + if (!child) continue; + if ( + child.command && + !ts.isFunctionLike(unwrap(child.node)) && + !isAlias(declaration, ref) + ) + continue; + declaration.usage.operations.push(...child.usage.operations); + declaration.usage.unresolvedRequests.push( + ...child.usage.unresolvedRequests, + ); + declaration.usage.deletes ||= child.usage.deletes; + } + declaration.usage.operations = unique(declaration.usage.operations); + declaration.usage.unresolvedRequests = unique( + declaration.usage.unresolvedRequests, + ); + changed ||= before !== JSON.stringify(declaration.usage); + } + } + + const result: SourceApiAnalysis = { exports: new Map(), commands: [] }; + function exportNames(file: string, seen = new Set()): string[] { + if (seen.has(file)) return []; + seen.add(file); + const mod = modules.get(file); + return mod + ? [ + ...mod.exports.keys(), + ...mod.stars.flatMap((target) => exportNames(target, seen)), + ] + : []; + } + for (const [file, mod] of modules) { + const exported = new Map(); + for (const name of new Set(exportNames(file))) { + const target = mod.exports.get(name) ?? { file, name }; + const binding = resolve(target); + const declaration = + binding && declarations.get(`${binding.file}#${binding.name}`); + if (!declaration) continue; + exported.set(name, declaration.usage); + if (declaration.command) + result.commands.push({ + file, + name, + destructive: declaration.destructive, + }); + } + result.exports.set(file, exported); + } + return result; +} diff --git a/packages/cli/src/commands/alert/metrics/create.ts b/packages/cli/src/commands/alert/metrics/create.ts index b55a701d6..9516ba514 100644 --- a/packages/cli/src/commands/alert/metrics/create.ts +++ b/packages/cli/src/commands/alert/metrics/create.ts @@ -5,7 +5,6 @@ */ import type { SentryContext } from "../../../context.js"; -import { METRIC_ALERT_API } from "../../../lib/api/alerts.js"; import { createMetricAlertRule } from "../../../lib/api-client.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; import { stringifyUnknown, ValidationError } from "../../../lib/errors.js"; @@ -73,7 +72,6 @@ function resolveMetricCreateOrg( export const createCommand = buildCommand({ docs: { - relatedApi: [METRIC_ALERT_API.create], brief: "Create a metric alert rule", fullDescription: "Create a metric alert rule (a metric monitor in the detectors API).\n\n" + diff --git a/packages/cli/src/commands/alert/metrics/delete.ts b/packages/cli/src/commands/alert/metrics/delete.ts index 351bfa54d..42b9b93ec 100644 --- a/packages/cli/src/commands/alert/metrics/delete.ts +++ b/packages/cli/src/commands/alert/metrics/delete.ts @@ -5,7 +5,6 @@ */ import type { SentryContext } from "../../../context.js"; -import { METRIC_ALERT_API } from "../../../lib/api/alerts.js"; import { deleteMetricAlertRule } from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; @@ -45,7 +44,6 @@ function formatDeleted(r: DeleteResult): string { export const deleteCommand = buildDeleteCommand({ docs: { - relatedApi: [METRIC_ALERT_API.delete], brief: "Delete a metric alert rule", fullDescription: "Permanently remove a metric alert rule from an organization. " + diff --git a/packages/cli/src/commands/alert/metrics/edit.ts b/packages/cli/src/commands/alert/metrics/edit.ts index 47efd764e..798727707 100644 --- a/packages/cli/src/commands/alert/metrics/edit.ts +++ b/packages/cli/src/commands/alert/metrics/edit.ts @@ -5,7 +5,6 @@ */ import type { SentryContext } from "../../../context.js"; -import { METRIC_ALERT_API } from "../../../lib/api/alerts.js"; import { getMetricAlertRuleDocument, putMetricAlertRule, @@ -175,7 +174,6 @@ function formatEdited(r: EditResult): string { export const editCommand = buildCommand({ docs: { - relatedApi: [METRIC_ALERT_API.get, METRIC_ALERT_API.update], brief: "Edit a metric alert rule", fullDescription: "Update a metric alert rule. Pass at least one of --name or --status. " + diff --git a/packages/cli/src/commands/alert/metrics/list.ts b/packages/cli/src/commands/alert/metrics/list.ts index d0eb6c2e4..a8e90a208 100644 --- a/packages/cli/src/commands/alert/metrics/list.ts +++ b/packages/cli/src/commands/alert/metrics/list.ts @@ -11,10 +11,7 @@ */ import type { SentryContext } from "../../../context.js"; -import { - METRIC_ALERT_API, - type MetricAlertRule, -} from "../../../lib/api/alerts.js"; +import type { MetricAlertRule } from "../../../lib/api/alerts.js"; import { paginate } from "../../../lib/api/infrastructure.js"; import { listMetricAlertsPaginated } from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; @@ -496,7 +493,6 @@ const jsonTransformMetricAlertList = jsonTransformListResult; export const listCommand = buildListCommand("alert metrics", { docs: { - relatedApi: [METRIC_ALERT_API.list], brief: "List metric alert rules", fullDescription: "List metric alert rules for one or more Sentry organizations.\n\n" + diff --git a/packages/cli/src/commands/alert/metrics/view.ts b/packages/cli/src/commands/alert/metrics/view.ts index 1cb1c6d74..2f5da8155 100644 --- a/packages/cli/src/commands/alert/metrics/view.ts +++ b/packages/cli/src/commands/alert/metrics/view.ts @@ -3,7 +3,6 @@ import { getIssueAlertWorkflowDocument, getMetricAlertRule, } from "../../../lib/api-client.js"; -import { METRIC_ALERT_API } from "../../../lib/api/alerts.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; import { buildCommand } from "../../../lib/command.js"; @@ -29,7 +28,6 @@ type ViewFlags = { export const viewCommand = buildCommand({ docs: { - relatedApi: [METRIC_ALERT_API.get], brief: "View a metric alert rule", fullDescription: "View a metric alert rule by ID or name, including detection settings, " + diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index 76aea7954..1735e7e81 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -9,7 +9,6 @@ // oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; import type { SentryContext } from "../../context.js"; -import { LOGS_API } from "../../lib/api/logs.js"; import { type LogSortDirection, listLogs, @@ -702,7 +701,6 @@ export const listCommand = buildListCommand( "log", { docs: { - relatedApi: [LOGS_API], brief: "List logs from a project", fullDescription: "List and stream logs from Sentry projects.\n\n" + diff --git a/packages/cli/src/commands/log/view.ts b/packages/cli/src/commands/log/view.ts index 2cdaa102f..38cab4a19 100644 --- a/packages/cli/src/commands/log/view.ts +++ b/packages/cli/src/commands/log/view.ts @@ -5,9 +5,10 @@ */ import { isatty } from "node:tty"; + import pLimit from "p-limit"; + import type { SentryContext } from "../../context.js"; -import { LOGS_API } from "../../lib/api/logs.js"; import { getLogItemDetail, getLogs } from "../../lib/api-client.js"; import { detectSwappedViewArgs, @@ -559,7 +560,6 @@ function formatLogViewHuman(data: LogViewData): string { export const viewCommand = buildCommand({ docs: { - relatedApi: [LOGS_API], brief: "View details of one or more log entries", fullDescription: "View detailed information about Sentry log entries by their IDs.\n\n" + diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index d61b8807b..2c0c7a100 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -3,17 +3,6 @@ * * 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 → discover related API endpoints - * sentry schema → list matching operation candidates - * sentry schema alert metrics view → find APIs used by this CLI command - * 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"; @@ -42,6 +31,7 @@ import { import { CommandOutput } from "../lib/formatters/output.js"; import { fuzzyMatch } from "../lib/fuzzy.js"; import { getCommandApiReferences, type RouteMap } from "../lib/introspect.js"; +import { filterByName } from "../lib/list-filter.js"; // --------------------------------------------------------------------------- // Output data types @@ -342,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 }; @@ -358,17 +353,9 @@ const QUERY_WORD_SEPARATOR = /\s+/; /** Explicit resource globs retain their resource-only behavior. */ function resolveGlobQuery(resource: string): SchemaResult { - // 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()), - ); + const matched = filterByName(getResourceSummaries(), resource, (entry) => [ + entry.name, + ]); if (matched.length === 0) { throw new ResolutionError( `Pattern "${resource}"`, @@ -399,7 +386,8 @@ export function resolveResourceQuery( return resolvePathLookup(pathQuery, resource, operation); } - const identifierResult = resolveIdentifierLookup(query); + const parsed = parseEndpointQuery(query); + const identifierResult = resolveIdentifierLookup(parsed.text, parsed.method); if (identifierResult) { return identifierResult; } @@ -496,6 +484,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", diff --git a/packages/cli/src/commands/trace/list.ts b/packages/cli/src/commands/trace/list.ts index 54751a26f..16998ea52 100644 --- a/packages/cli/src/commands/trace/list.ts +++ b/packages/cli/src/commands/trace/list.ts @@ -5,7 +5,6 @@ */ import type { SentryContext } from "../../context.js"; -import { LIST_TRANSACTIONS_API } from "../../lib/api/traces.js"; import { listTransactions } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { @@ -196,7 +195,6 @@ function jsonTransformTraceList( export const listCommand = buildListCommand("trace", { docs: { - relatedApi: [LIST_TRANSACTIONS_API], brief: "List recent traces in a project", fullDescription: "List recent traces from Sentry projects.\n\n" + diff --git a/packages/cli/src/commands/trace/view.ts b/packages/cli/src/commands/trace/view.ts index 4c5a836e8..3aa370eff 100644 --- a/packages/cli/src/commands/trace/view.ts +++ b/packages/cli/src/commands/trace/view.ts @@ -5,7 +5,6 @@ */ import type { SentryContext } from "../../context.js"; -import { TRACE_DETAILS_API } from "../../lib/api/traces.js"; import { attributesToDict, fetchMultiSpanDetails, @@ -433,7 +432,6 @@ function fetchTraceSpanDetails( export const viewCommand = buildCommand({ docs: { - relatedApi: [TRACE_DETAILS_API], brief: "View details of a specific trace", fullDescription: "View detailed information about a distributed trace by its ID.\n\n" + diff --git a/packages/cli/src/lib/api-schema.ts b/packages/cli/src/lib/api-schema.ts index cba9bb039..7e1c5aea0 100644 --- a/packages/cli/src/lib/api-schema.ts +++ b/packages/cli/src/lib/api-schema.ts @@ -7,6 +7,7 @@ */ import endpoints from "../generated/api-schema.json"; +import { fuzzyMatch } from "./fuzzy.js"; /** Searchable query parameter metadata from the pinned OpenAPI specification. */ export type ApiQueryParameter = { @@ -206,31 +207,13 @@ const CAMEL_CASE_BOUNDARY = /([a-z0-9])([A-Z])/g; const WORD_SEPARATOR = /[^a-z0-9]+/; const PARAM_PLACEHOLDER = /\{[^}]+\}/g; -/** Conservative English plurals for discovery only, never endpoint identity. */ -function normalizeWord(word: string): string { - if (word.length > 4 && word.endsWith("ies")) { - return `${word.slice(0, -3)}y`; - } - if ( - word.length > 3 && - word.endsWith("s") && - !word.endsWith("ss") && - !word.endsWith("us") && - !word.endsWith("is") - ) { - return word.slice(0, -1); - } - return word; -} - function words(text: string): Set { return new Set( text .replace(CAMEL_CASE_BOUNDARY, "$1 $2") .toLowerCase() .split(WORD_SEPARATOR) - .filter(Boolean) - .map(normalizeWord), + .filter(Boolean), ); } @@ -285,6 +268,7 @@ 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) @@ -292,8 +276,13 @@ function matchEndpoint( const scores = new Map(); const reasons: string[] = []; for (const field of searchFields(endpoint)) { - const fieldWords = words(field.text); - const matches = [...tokens].filter((token) => fieldWords.has(token)); + 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; } @@ -316,7 +305,9 @@ function matchEndpoint( /** * Discover API capabilities, retaining why each candidate matched. All query - * words must match, unless the CLI explicitly declares a related operation. + * 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. @@ -330,24 +321,29 @@ export function searchEndpoints( if (parsed.path) { return findEndpointsByPath(parsed.path, parsed.method, catalog); } - const exact = findEndpointsByIdentifier(parsed.text, catalog).filter( - (endpoint) => !parsed.method || endpoint.method === parsed.method, - ); + const exact = findEndpointsByIdentifier(parsed.text, catalog); if (exact.length > 0) { - return exact; + return exact.filter( + (endpoint) => !parsed.method || endpoint.method === parsed.method, + ); } const tokens = words(parsed.text); if (tokens.size === 0) { return []; } const matches: { endpoint: SchemaEndpoint; score: number }[] = []; - for (const endpoint of catalog) { - if (parsed.method && endpoint.method !== parsed.method) { - continue; + 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); + } } - const match = matchEndpoint(endpoint, tokens, references); - if (match) { - matches.push(match); + if (matches.length > 0) { + break; } } // A stable tie-breaker prevents catalog insertion order from choosing an API. diff --git a/packages/cli/src/lib/api/alerts.ts b/packages/cli/src/lib/api/alerts.ts index 2e8cb5d85..26591fc4b 100644 --- a/packages/cli/src/lib/api/alerts.ts +++ b/packages/cli/src/lib/api/alerts.ts @@ -29,7 +29,6 @@ import { metricAlertDatasetName, metricAlertSource, } from "../../types/metric-alert.js"; -import type { ApiReference } from "../api-schema.js"; import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; import { @@ -394,34 +393,8 @@ export async function getIssueAlertRule( // (create/update/delete) also target the detectors write API — the flat CLI // input is translated into the nested detector body by `buildDetectorBody`. -/** Detector kind managed by the metric alert commands. */ -const METRIC_DETECTOR_TYPE = "metric_issue"; /** Search query that limits the detectors endpoint to metric alert rules. */ -const METRIC_DETECTOR_QUERY = `type:${METRIC_DETECTOR_TYPE}`; - -/** API references shared by metric alert commands; detectors also serve other products. */ -export const METRIC_ALERT_API = { - list: { - fn: "listOrganizationDetectors", - note: `Filters detectors with query=${METRIC_DETECTOR_QUERY}.`, - }, - get: { - fn: "getOrganizationDetector", - note: `The CLI accepts ${METRIC_DETECTOR_TYPE} detectors and presents them as metric alert rules.`, - }, - create: { - fn: "createOrganizationProjectDetector", - note: `Creates a ${METRIC_DETECTOR_TYPE} detector from the CLI metric alert configuration.`, - }, - update: { - fn: "updateOrganizationDetector", - note: "The CLI merges changes into the existing metric alert configuration before updating the detector.", - }, - delete: { - fn: "deleteOrganizationDetector", - note: `The CLI resolves a ${METRIC_DETECTOR_TYPE} detector before deleting it.`, - }, -} satisfies Record; +const METRIC_DETECTOR_QUERY = "type:metric_issue"; /** * List metric alert rules for an organization with cursor-based pagination. @@ -512,7 +485,7 @@ async function getMetricDetector( result, `Failed to get metric alert rule '${ruleId}'`, ); - if (data.type !== undefined && data.type !== METRIC_DETECTOR_TYPE) { + if (data.type !== undefined && data.type !== "metric_issue") { throw new ApiError( `Metric alert rule '${ruleId}' not found`, 404, @@ -732,7 +705,7 @@ function buildDetectorBody( !(triggers && !isDetectorConditionList(triggers)); const detectorBody: Record = { name: typeof body.name === "string" ? body.name : "", - type: METRIC_DETECTOR_TYPE, + type: "metric_issue", dataSources: [buildDetectorDataSource(body)], config: keepStoredConfig ? body.detectorConfig diff --git a/packages/cli/src/lib/api/logs.ts b/packages/cli/src/lib/api/logs.ts index b1f0c22ce..2d192387e 100644 --- a/packages/cli/src/lib/api/logs.ts +++ b/packages/cli/src/lib/api/logs.ts @@ -19,7 +19,6 @@ import { type TraceLog, TraceLogsResponseSchema, } from "../../types/index.js"; -import type { ApiReference } from "../api-schema.js"; import { ApiError } from "../errors.js"; import { decodeUuidV7Timestamp } from "../hex-id.js"; import { resolveOrgRegion } from "../region.js"; @@ -36,14 +35,6 @@ import { } from "./infrastructure.js"; import { getTraceItemDetail } from "./traces.js"; -const LOG_DATASET = "logs" as const; - -/** Shared API discovery context for log listing and item lookup. */ -export const LOGS_API = { - fn: "listOrganizationEvents", - note: `Uses dataset=${LOG_DATASET}; the CLI adds fields and filters to list or look up log entries.`, -} satisfies ApiReference; - /** Sort direction for log queries: newest-first or oldest-first. */ export type LogSortDirection = "newest" | "oldest"; @@ -215,7 +206,7 @@ export async function listLogs( ...config, path: { organization_id_or_slug: orgSlug }, query: { - dataset: LOG_DATASET, + dataset: "logs", field: fields, project: numericProjectId === undefined ? undefined : [numericProjectId], @@ -345,7 +336,7 @@ async function getLogsBatch( ) { // `sampling` is supported by the backend but absent from the SDK's query type. const queryParams = { - dataset: LOG_DATASET, + dataset: "logs" as const, field: fields, project: numericProjectId === undefined ? undefined : [numericProjectId], query: `${projectFilter}sentry.item_id:[${ids.join(",")}]`, diff --git a/packages/cli/src/lib/api/traces.ts b/packages/cli/src/lib/api/traces.ts index 6c6cb2963..97da21964 100644 --- a/packages/cli/src/lib/api/traces.ts +++ b/packages/cli/src/lib/api/traces.ts @@ -23,7 +23,6 @@ import { // Re-export so existing callers (api-client.ts, formatters/trace.ts) don't need to change. export type { TraceItemAttribute, TraceItemDetail } from "../../types/index.js"; -import type { ApiReference } from "../api-schema.js"; import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; import { isAllDigits } from "../utils.js"; @@ -38,21 +37,6 @@ import { const log = logger.withTag("api.traces"); -const TRANSACTION_DATASET = "spans"; -const TRANSACTION_QUERY = "is_transaction:true"; - -/** Trace list queries root spans rather than a dedicated traces collection. */ -export const LIST_TRANSACTIONS_API = { - fn: "listOrganizationEvents", - note: `Uses dataset=${TRANSACTION_DATASET} with ${TRANSACTION_QUERY}; the CLI resolves projects and paginates.`, -} satisfies ApiReference; - -/** Main trace view API; full span attributes may require additional requests. */ -export const TRACE_DETAILS_API = { - fn: "getOrganizationTrace", - note: "Retrieves the trace tree; the CLI resolves context and may fetch additional span attributes.", -} satisfies ApiReference; - // --------------------------------------------------------------------------- // Trace item (span) detail types // --------------------------------------------------------------------------- @@ -470,7 +454,7 @@ async function fetchTransactionsPage( ); const projectFilter = numericProjectId === undefined ? `project:${projectSlug}` : ""; - const fullQuery = [TRANSACTION_QUERY, projectFilter, options.query] + const fullQuery = ["is_transaction:true", projectFilter, options.query] .filter(Boolean) .join(" "); @@ -480,7 +464,7 @@ async function fetchTransactionsPage( `/organizations/${orgSlug}/events/`, { params: { - dataset: TRANSACTION_DATASET, + dataset: "spans", field: TRANSACTION_FIELDS, project: numericProjectId === undefined diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 35225c61b..bc073dd4b 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -37,7 +37,6 @@ import { numberParser as stricliNumberParser, } from "@stricli/core"; import type { Writer } from "../types/index.js"; -import type { ApiReference } from "./api-schema.js"; import { appendCacheHint } from "./cache-hint.js"; import { type CommandExample, exampleSteps } from "./command-example.js"; import { getAuthConfig, hasUsableStoredAccount } from "./db/auth.js"; @@ -108,8 +107,6 @@ type StricliBuilderArgs = export type CommandDocumentation = StricliBuilderArgs["docs"] & { readonly examples?: readonly CommandExample[]; - /** Related API operations, with context rather than equivalence claims. */ - readonly relatedApi?: readonly ApiReference[]; }; /** @@ -929,10 +926,6 @@ export function buildCommand< if (builderArgs.destructive) { (cmd as unknown as Record).__destructive = true; } - if (builderArgs.docs.relatedApi?.length) { - (cmd as unknown as Record).__relatedApi = - builderArgs.docs.relatedApi; - } // Attach the JSON schema to the built command as a non-standard property. // introspect.ts reads this to populate CommandInfo.jsonFields for help diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index c7d6d4c28..f2030c24e 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -10,6 +10,7 @@ * for introspection and documentation generation. */ +import generatedCommandApi from "../generated/command-api.json"; import { type CommandExample, exampleSteps } from "./command-example.js"; import { COMPLETION_TARGETS, @@ -18,11 +19,7 @@ import { ORG_PROJECT_TARGET, ORG_TARGET, } from "./completion-target.js"; -import type { - ApiEndpoint, - ApiReference, - CommandApiReference, -} from "./api-schema.js"; +import type { ApiReference, CommandApiReference } from "./api-schema.js"; import { extractSchemaFields, type SchemaFieldInfo, @@ -76,8 +73,6 @@ export type Command = { __examples?: readonly CommandExample[]; /** Set by `buildCommand({ destructive: true })` and `buildDeleteCommand`. */ __destructive?: boolean; - /** Related API metadata retained by the command builder. */ - __relatedApi?: readonly ApiReference[]; }; /** Positional parameter definitions — either fixed-length tuple or variadic array */ @@ -136,7 +131,6 @@ export type CommandInfo = { jsonFields?: SchemaFieldInfo[]; /** Present (and `true`) when the command is destructive or irreversible */ destructive?: true; - relatedApi?: readonly ApiReference[]; }; /** Extracted metadata for a single flag */ @@ -335,7 +329,6 @@ export function buildCommandInfo(cmd: Command, path: string): CommandInfo { ), jsonFields: jsonFields?.length ? jsonFields : undefined, ...(cmd.__destructive ? { destructive: true as const } : {}), - ...(cmd.__relatedApi?.length ? { relatedApi: cmd.__relatedApi } : {}), }; } @@ -498,7 +491,7 @@ const MAX_SUGGESTIONS = 3; const COMMAND_PATH_SEPARATOR = /\s+/; /** Find canonical public paths so hidden aliases cannot expose internal commands. */ -function publicTargetPaths( +export function publicTargetPaths( routeMap: RouteMap, prefix: readonly string[] = [], paths = new Map(), @@ -588,6 +581,9 @@ export function resolveCommandPath( 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); @@ -597,32 +593,9 @@ export function getCommandApiReferences( const commands = resolved.kind === "command" ? [resolved.info] : resolved.info.commands; return commands.flatMap((command) => - (command.relatedApi ?? []).map((reference) => ({ + (apiByCommand[command.path] ?? []).map((reference) => ({ ...reference, command: command.path, })), ); } - -/** Reject stale or ambiguous references before generating command documentation. */ -export function validateCommandApiReferences( - routeMap: RouteMap, - endpoints: readonly Pick[], -): void { - const counts = new Map(); - for (const endpoint of endpoints) { - counts.set(endpoint.fn, (counts.get(endpoint.fn) ?? 0) + 1); - } - for (const route of extractAllRoutes(routeMap)) { - for (const command of route.commands) { - for (const reference of command.relatedApi ?? []) { - const count = counts.get(reference.fn) ?? 0; - if (!reference.fn || count !== 1) { - throw new Error( - `${command.path}: related API "${reference.fn}" matches ${count} schema operations; expected exactly one`, - ); - } - } - } - } -} diff --git a/packages/cli/test/commands/schema-path.test.ts b/packages/cli/test/commands/schema-path.test.ts index 5ffa4012e..e4d004a87 100644 --- a/packages/cli/test/commands/schema-path.test.ts +++ b/packages/cli/test/commands/schema-path.test.ts @@ -39,6 +39,8 @@ describe("sentry schema path lookup", () => { test.each([ ["listOrganizationIssues"], ["issues", "listOrganizationIssues"], + ["GET", "listOrganizationIssues"], + ["GET listOrganizationIssues"], [SKILL_QUERY], ["GET", ORG_ISSUES], ])("exact lookup preserves the endpoint object for %j", async (...args) => { @@ -52,6 +54,32 @@ describe("sentry schema path lookup", () => { ); }); + 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(); diff --git a/packages/cli/test/commands/schema.test.ts b/packages/cli/test/commands/schema.test.ts index c46d2cc66..6b9cdd41f 100644 --- a/packages/cli/test/commands/schema.test.ts +++ b/packages/cli/test/commands/schema.test.ts @@ -60,7 +60,7 @@ describe("resolveResourceQuery", () => { test("no-match resource throws ResolutionError instead of listing everything", () => { let thrown: unknown; try { - resolveResourceQuery("committers"); + resolveResourceQuery("zzzznomatch"); } catch (error) { thrown = error; } @@ -68,26 +68,20 @@ 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("known resource with unknown operation shows that resource's endpoints", () => { diff --git a/packages/cli/test/lib/api-schema-discovery.test.ts b/packages/cli/test/lib/api-schema-discovery.test.ts index 2bed60e48..2424ce42a 100644 --- a/packages/cli/test/lib/api-schema-discovery.test.ts +++ b/packages/cli/test/lib/api-schema-discovery.test.ts @@ -42,6 +42,30 @@ const flagLogs: ApiEndpoint = { }; 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([ @@ -151,4 +175,16 @@ describe("schema discovery", () => { 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/command-api-references.test.ts b/packages/cli/test/lib/command-api-references.test.ts index 5908e1f51..f92a3a8e1 100644 --- a/packages/cli/test/lib/command-api-references.test.ts +++ b/packages/cli/test/lib/command-api-references.test.ts @@ -1,20 +1,16 @@ import { describe, expect, test } from "vitest"; import { routes } from "../../src/app.js"; -import { - type ApiReference, - getAllEndpoints, -} from "../../src/lib/api-schema.js"; +import type { ApiReference } from "../../src/lib/api-schema.js"; import { buildCommand } from "../../src/lib/command.js"; import { getCommandApiReferences, type RouteMap, - validateCommandApiReferences, } from "../../src/lib/introspect.js"; import { buildRouteMap } from "../../src/lib/route-map.js"; -function command(relatedApi?: readonly ApiReference[]) { +function command() { return buildCommand({ - docs: { brief: "Example command", relatedApi }, + docs: { brief: "Example command" }, auth: false, async *func() { yield; @@ -22,30 +18,35 @@ function command(relatedApi?: readonly ApiReference[]) { }); } +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([{ fn: "queryEvents", note: "Uses the logs dataset." }]); - const view = command([ - { fn: "queryEvents", note: "Filters by log item ID." }, - ]); + const list = command(); const log = buildRouteMap({ - routes: { list, view }, + routes: { list, view: command() }, aliases: { details: "view" }, docs: { brief: "Logs" }, }); const metrics = buildRouteMap({ - routes: { list: command([{ fn: "listDetectors" }]) }, + routes: { list: command() }, docs: { brief: "Metric alerts" }, }); return buildRouteMap({ routes: { log, logs: list, - internal: command([{ fn: "notPublished" }]), + internal: command(), alert: buildRouteMap({ routes: { metrics }, docs: { brief: "Alerts" }, }), - future: command([{ fn: "newOperation" }]), + future: command(), local: command(), }, docs: { brief: "CLI", hideRoute: { logs: true, internal: true } }, @@ -53,45 +54,47 @@ function exampleRoutes() { } describe("command API discovery", () => { - test("reads metadata from new public commands and full nested paths", () => { + test("reads generated references for new public commands and nested paths", () => { const tree = exampleRoutes(); - expect(getCommandApiReferences(tree, "future")).toEqual([ + expect(getCommandApiReferences(tree, "future", exampleApi)).toEqual([ { command: "sentry future", fn: "newOperation" }, ]); - expect(getCommandApiReferences(tree, "alert metrics list")).toEqual([ - { command: "sentry alert metrics list", fn: "listDetectors" }, - ]); - expect(getCommandApiReferences(tree, "alert metrics")).toEqual( - getCommandApiReferences(tree, "alert"), + 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"); - expect(getCommandApiReferences(tree, "log ls")).toEqual(list); - expect(getCommandApiReferences(tree, "logs")).toEqual(list); - expect(getCommandApiReferences(tree, "log details")).toEqual( - getCommandApiReferences(tree, "log show"), - ); - expect(getCommandApiReferences(tree, "log show")[0]?.command).toBe( - "sentry log view", + 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")).toEqual([ - { - command: "sentry log list", - fn: "queryEvents", - note: "Uses the logs dataset.", - }, - { - command: "sentry log view", - fn: "queryEvents", - note: "Filters by log item ID.", - }, - ]); + 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", () => { @@ -103,74 +106,65 @@ describe("command API discovery", () => { "local", "", ]) { - expect(getCommandApiReferences(tree, query)).toEqual([]); + expect(getCommandApiReferences(tree, query, exampleApi)).toEqual([]); } }); - test("rejects missing and ambiguous API references as the catalog changes", () => { - const tree = exampleRoutes(); - const endpoints = [ - { fn: "queryEvents" }, - { fn: "listDetectors" }, - { fn: "newOperation" }, - ]; - expect(() => validateCommandApiReferences(tree, endpoints)).not.toThrow(); - expect(() => - validateCommandApiReferences(tree, endpoints.slice(1)), - ).toThrow( - 'sentry log list: related API "queryEvents" matches 0 schema operations', - ); - expect(() => - validateCommandApiReferences(tree, [...endpoints, { fn: "queryEvents" }]), - ).toThrow( - 'sentry log list: related API "queryEvents" matches 2 schema operations', - ); - }); - - test("published command references resolve against the bundled API catalog", () => { + test("published command references include their direct and transitive API calls", () => { const tree = routes as unknown as RouteMap; - expect(() => - validateCommandApiReferences(tree, getAllEndpoints()), - ).not.toThrow(); + // 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.objectContaining({ - fn: "listOrganizationEvents", - note: expect.stringContaining("is_transaction:true"), - }), - ]); - expect(getCommandApiReferences(tree, "log view")).toEqual([ - expect.objectContaining({ - fn: "listOrganizationEvents", - note: expect.stringContaining("dataset=logs"), - }), - ]); - expect(getCommandApiReferences(tree, "trace show")).toEqual([ - expect.objectContaining({ fn: "getOrganizationTrace" }), - ]); + 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([ - { command: "sentry alert metrics list", fn: "listOrganizationDetectors" }, - { command: "sentry alert metrics view", fn: "getOrganizationDetector" }, - { - 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", - }, - ]); + ).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 index 468500e8c..349fd3bfe 100644 --- a/packages/cli/test/script/generate-api-schema.test.ts +++ b/packages/cli/test/script/generate-api-schema.test.ts @@ -1,11 +1,95 @@ import { describe, expect, test } from "vitest"; import { extractApiEndpoints, + loadSdkApiEndpoints, type OpenApiSpec, } from "../../script/lib/api-schema.js"; const EVENTS_PATH = "/api/0/organizations/{organization_id_or_slug}/events/"; +describe("SDK catalog integration", () => { + test("projects SDK identities and metadata to the compatible CLI output", async () => { + const operations = [ + { + functionName: "listOrganizationEvents", + operationId: "List an Organization's Events", + method: "GET", + path: EVENTS_PATH, + 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, + }, + ]; + const endpoints = await loadSdkApiEndpoints( + () => + `data:text/javascript,export const operations = ${encodeURIComponent(JSON.stringify(operations))};`, + ); + + expect(endpoints).toEqual([ + { + fn: "listOrganizationEvents", + operationId: "List an Organization's Events", + method: "GET", + path: EVENTS_PATH, + summary: "Query events", + description: "Select the dataset to query.", + tags: ["Explore"], + pathParams: ["organization_id_or_slug"], + queryParams: ["dataset", "field"], + queryParameters: operations[0]?.queryParameters, + resource: "events", + deprecated: false, + }, + ]); + }); + + test("falls back only when the installed SDK does not export a catalog", async () => { + const notExported = Object.assign(new Error("No catalog export"), { + code: "ERR_PACKAGE_PATH_NOT_EXPORTED", + }); + await expect( + loadSdkApiEndpoints(() => { + throw notExported; + }), + ).resolves.toBeUndefined(); + + const missingPackage = Object.assign(new Error("SDK package missing"), { + code: "ERR_MODULE_NOT_FOUND", + }); + await expect( + loadSdkApiEndpoints(() => { + throw missingPackage; + }), + ).rejects.toBe(missingPackage); + + // An exported entry can itself fail to resolve an import. It must not be + // mistaken for an old SDK and silently replaced with fetched metadata. + const broken = + 'throw Object.assign(new Error("Broken catalog"), { code: "ERR_PACKAGE_PATH_NOT_EXPORTED" });'; + await expect( + loadSdkApiEndpoints( + () => `data:text/javascript,${encodeURIComponent(broken)}`, + ), + ).rejects.toThrow("Broken catalog"); + }); +}); + /** Reduced public OpenAPI Explore operation; no account or event data. */ function exploreSpec(datasets = ["errors", "logs", "spans"]): OpenApiSpec { return { 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 }); + }); +}); From 676fb777fd1208921d1ae24f72128ed365aff5d3 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 27 Oct 2026 21:07:06 +0100 Subject: [PATCH 4/7] refactor(cli): consume the published API operation catalog --- packages/cli/DEVELOPMENT.md | 6 +- packages/cli/script/generate-api-schema.ts | 119 +------ packages/cli/script/lib/api-schema.ts | 172 +-------- .../test/script/generate-api-schema.test.ts | 337 ++++-------------- pnpm-lock.yaml | 14 +- pnpm-workspace.yaml | 4 +- 6 files changed, 98 insertions(+), 554 deletions(-) diff --git a/packages/cli/DEVELOPMENT.md b/packages/cli/DEVELOPMENT.md index 88ab7db01..0afa8076b 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -107,9 +107,9 @@ pnpm run build `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 prefers `@sentry/api/operations`. Until that export is published -and included in the pinned SDK, older versions retain the version-matched -OpenAPI extraction fallback. Remove that fallback when upgrading the dependency. +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` follows command implementations and their helpers to generate API relationships from SDK calls and recognized raw request diff --git a/packages/cli/script/generate-api-schema.ts b/packages/cli/script/generate-api-schema.ts index 9f536fd1b..920b7fe3f 100644 --- a/packages/cli/script/generate-api-schema.ts +++ b/packages/cli/script/generate-api-schema.ts @@ -1,121 +1,18 @@ #!/usr/bin/env tsx /** - * Generate API Schema Index from the installed Sentry SDK's operation catalog. + * Generate the CLI discovery index from the installed SDK's operation catalog. * - * Bundled into the CLI for runtime introspection via `sentry schema`. - * Older SDKs without @sentry/api/operations temporarily use their versioned - * OpenAPI spec and bundled function names. Remove that compatibility path - * after the pinned SDK dependency includes the operation catalog. - * - * Usage: - * tsx script/generate-api-schema.ts - * - * Output: - * src/generated/api-schema.json + * Usage: tsx script/generate-api-schema.ts + * Output: src/generated/api-schema.json */ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { - extractApiEndpoints, - loadSdkApiEndpoints, - type OpenApiSpec, -} from "./lib/api-schema.js"; - -/** 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 { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { operations } from "@sentry/api/operations"; +import { projectApiOperations } from "./lib/api-schema.js"; const OUTPUT_PATH = "src/generated/api-schema.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`; -} - -// --------------------------------------------------------------------------- -// 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; -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -let endpoints = await loadSdkApiEndpoints(); -if (endpoints) { - console.log("Using operation catalog from @sentry/api/operations..."); -} else { - const openApiUrl = await getOpenApiUrl(); - console.log(`SDK catalog not yet available; fetching ${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; - const sdkMap = await buildSdkFunctionMap(); - endpoints = extractApiEndpoints(spec, sdkMap); -} +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/lib/api-schema.ts b/packages/cli/script/lib/api-schema.ts index df9a7683b..56ea95f38 100644 --- a/packages/cli/script/lib/api-schema.ts +++ b/packages/cli/script/lib/api-schema.ts @@ -1,37 +1,11 @@ /** Project SDK operation metadata into the CLI's bundled discovery index. */ -import type { - ApiEndpoint, - ApiQueryParameter, -} from "../../src/lib/api-schema.js"; - -const HTTP_METHODS = ["get", "post", "put", "delete", "patch"] as const; -const PATH_PARAM_PATTERN = /\{(\w+)\}/g; - -/** - * Structural subset of the forthcoming @sentry/api/operations contract. Import - * its public type directly once the pinned SDK includes that entry point. - */ -type SdkOperation = Pick< - ApiEndpoint, - "method" | "path" | "operationId" | "summary" | "description" | "deprecated" -> & { - /** Actual exported SDK function name after normalization. */ - functionName: string; - /** Categories assigned by the OpenAPI specification. */ - tags?: readonly string[]; - /** Parameter names present in the canonical path template. */ - pathParameters: readonly string[]; - /** Resolved query metadata; inherited parameters are already merged. */ - queryParameters: readonly (Omit & { - /** Allowed scalar values, or allowed item values for an array parameter. */ - enum?: readonly (string | number | boolean | null)[]; - })[]; -}; +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 SdkOperation[], + operations: readonly ApiOperation[], ): ApiEndpoint[] { return operations .map((operation) => ({ @@ -60,66 +34,6 @@ export function projectApiOperations( }); } -/** An older SDK may lack the entry point; a broken exported catalog must fail. */ -export async function loadSdkApiEndpoints( - resolveModule: (specifier: string) => string = (specifier) => - import.meta.resolve(specifier), -): Promise { - let entry: string; - try { - entry = resolveModule("@sentry/api/operations"); - } catch (error) { - if ( - error instanceof Error && - "code" in error && - error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED" - ) { - return; - } - throw error; - } - - const { operations } = (await import(entry)) as { - operations: readonly SdkOperation[]; - }; - return projectApiOperations(operations); -} - -/** - * OpenAPI fields used by SDK versions without /operations. Remove this - * compatibility extraction when the pinned dependency includes the catalog. - */ -export type OpenApiSpec = { - paths: Record; -}; - -type OpenApiPathItem = Partial< - Record<(typeof HTTP_METHODS)[number], OpenApiOperation> -> & { - parameters?: OpenApiParameter[]; -}; - -type OpenApiOperation = { - operationId?: string; - summary?: string; - description?: string; - tags?: string[]; - deprecated?: boolean; - parameters?: OpenApiParameter[]; -}; - -type OpenApiParameter = { - in: "path" | "query" | "header" | "cookie"; - name: string; - required?: boolean; - description?: string; - schema?: { - type?: string; - enum?: unknown[]; - items?: { enum?: unknown[] }; - }; -}; - /** Keep the existing resource grouping: the last literal URL segment. */ function deriveResource(url: string): string { const segments = url @@ -128,83 +42,3 @@ function deriveResource(url: string): string { const meaningful = segments.filter((s) => s !== "api" && s !== "0"); return meaningful.at(-1) ?? "unknown"; } - -function isEnumValue( - value: unknown, -): value is string | number | boolean | null { - return ( - value === null || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ); -} - -/** Operation parameters override inherited parameters with the same name/in. */ -function extractQueryParameters( - pathItem: OpenApiPathItem, - operation: OpenApiOperation, -): ApiQueryParameter[] { - const parameters = new Map(); - for (const parameter of [ - ...(pathItem.parameters ?? []), - ...(operation.parameters ?? []), - ]) { - if (parameter.in === "query") { - parameters.set(parameter.name, parameter); - } - } - - return [...parameters.values()].map((parameter) => { - const schema = parameter.schema; - const values = schema?.type === "array" ? schema.items?.enum : schema?.enum; - return { - name: parameter.name, - description: parameter.description?.trim(), - type: schema?.type, - required: parameter.required === true, - enum: values?.filter(isEnumValue), - }; - }); -} - -/** - * Generate entries directly from API metadata so new operations and enum values - * become discoverable on the next schema generation, without a vocabulary map. - */ -export function extractApiEndpoints( - spec: OpenApiSpec, - sdkMap: ReadonlyMap, -): ApiEndpoint[] { - const operations: SdkOperation[] = []; - - for (const [urlPath, pathItem] of Object.entries(spec.paths)) { - for (const method of HTTP_METHODS) { - const operation = pathItem[method]; - if (!operation) { - continue; - } - - const methodUpper = method.toUpperCase(); - const fn = sdkMap.get(`${methodUpper}:${urlPath}`) ?? ""; - const queryParameters = extractQueryParameters(pathItem, operation); - - operations.push({ - functionName: fn, - method: methodUpper, - path: urlPath, - summary: operation.summary?.trim(), - description: (operation.description ?? "").trim(), - tags: operation.tags, - pathParameters: [...urlPath.matchAll(PATH_PARAM_PATTERN)].map((match) => - match[0].slice(1, -1), - ), - queryParameters, - deprecated: operation.deprecated === true, - operationId: operation.operationId ?? "", - }); - } - } - - return projectApiOperations(operations); -} diff --git a/packages/cli/test/script/generate-api-schema.test.ts b/packages/cli/test/script/generate-api-schema.test.ts index 349fd3bfe..6d5791e0a 100644 --- a/packages/cli/test/script/generate-api-schema.test.ts +++ b/packages/cli/test/script/generate-api-schema.test.ts @@ -1,291 +1,104 @@ +import { operations, type ApiOperation } from "@sentry/api/operations"; import { describe, expect, test } from "vitest"; -import { - extractApiEndpoints, - loadSdkApiEndpoints, - type OpenApiSpec, -} from "../../script/lib/api-schema.js"; - -const EVENTS_PATH = "/api/0/organizations/{organization_id_or_slug}/events/"; - -describe("SDK catalog integration", () => { - test("projects SDK identities and metadata to the compatible CLI output", async () => { - const operations = [ - { - functionName: "listOrganizationEvents", - operationId: "List an Organization's Events", - method: "GET", - path: EVENTS_PATH, - 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, - }, - ]; - const endpoints = await loadSdkApiEndpoints( - () => - `data:text/javascript,export const operations = ${encodeURIComponent(JSON.stringify(operations))};`, - ); +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, +} as const satisfies ApiOperation; - expect(endpoints).toEqual([ +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: EVENTS_PATH, + 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: operations[0]?.queryParameters, + queryParameters: events.queryParameters, resource: "events", deprecated: false, }, ]); }); - test("falls back only when the installed SDK does not export a catalog", async () => { - const notExported = Object.assign(new Error("No catalog export"), { - code: "ERR_PACKAGE_PATH_NOT_EXPORTED", - }); - await expect( - loadSdkApiEndpoints(() => { - throw notExported; - }), - ).resolves.toBeUndefined(); - - const missingPackage = Object.assign(new Error("SDK package missing"), { - code: "ERR_MODULE_NOT_FOUND", - }); - await expect( - loadSdkApiEndpoints(() => { - throw missingPackage; - }), - ).rejects.toBe(missingPackage); - - // An exported entry can itself fail to resolve an import. It must not be - // mistaken for an old SDK and silently replaced with fetched metadata. - const broken = - 'throw Object.assign(new Error("Broken catalog"), { code: "ERR_PACKAGE_PATH_NOT_EXPORTED" });'; - await expect( - loadSdkApiEndpoints( - () => `data:text/javascript,${encodeURIComponent(broken)}`, - ), - ).rejects.toThrow("Broken catalog"); - }); -}); - -/** Reduced public OpenAPI Explore operation; no account or event data. */ -function exploreSpec(datasets = ["errors", "logs", "spans"]): OpenApiSpec { - return { - paths: { - [EVENTS_PATH]: { - get: { - operationId: "listOrganizationEvents", - summary: "Query Explore Events in Table Format", - description: "Retrieves explore data for a given organization.", - tags: ["Explore"], - parameters: [ - { - in: "query", - name: "dataset", - required: true, - description: - "Which dataset to query. The chosen dataset determines which fields are queryable.", - schema: { type: "string", enum: datasets }, - }, - { - in: "query", - name: "field", - required: true, - schema: { type: "array" }, - }, - { in: "query", name: "query", schema: { type: "string" } }, - ], - }, + 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", }, - }, - }; -} - -describe("extractApiEndpoints", () => { - test("preserves Explore dataset vocabulary and the existing endpoint fields", () => { - const endpoints = extractApiEndpoints( - exploreSpec(), - new Map([[`GET:${EVENTS_PATH}`, "listOrganizationEvents"]]), - ); + ]); - expect(endpoints).toEqual([ + expect( + endpoints.map(({ resource, operationId, deprecated }) => ({ + resource, + operationId, + deprecated, + })), + ).toEqual([ { - fn: "listOrganizationEvents", - method: "GET", - path: EVENTS_PATH, - resource: "events", - operationId: "listOrganizationEvents", - summary: "Query Explore Events in Table Format", - description: "Retrieves explore data for a given organization.", - tags: ["Explore"], + resource: "alerts", + operationId: "Get a project alert", deprecated: false, - pathParams: ["organization_id_or_slug"], - queryParams: ["dataset", "field", "query"], - queryParameters: [ - { - name: "dataset", - required: true, - description: - "Which dataset to query. The chosen dataset determines which fields are queryable.", - type: "string", - enum: ["errors", "logs", "spans"], - }, - { name: "field", required: true, type: "array" }, - { name: "query", required: false, type: "string" }, - ], }, + { resource: "events", operationId: "A", deprecated: true }, + { + resource: "events", + operationId: "Deprecated operation", + deprecated: true, + }, + { resource: "events", operationId: "Z", deprecated: true }, ]); }); - test("includes new operations and datasets without adding vocabulary mappings", () => { - const spec = exploreSpec(["errors", "logs", "spans", "uptime_results"]); - // Based on the separate Explore timeseries operation: the API can add it - // before the installed SDK has a mapped function name. - const timeseriesPath = - "/api/0/organizations/{organization_id_or_slug}/events-timeseries/"; - spec.paths[timeseriesPath] = { - get: { - ...spec.paths[EVENTS_PATH]?.get, - operationId: "listOrganizationEventsTimeseries", - summary: "Query Explore Events in Timeseries Format", - }, - }; - - const endpoints = extractApiEndpoints(spec, new Map()); - - expect(endpoints).toHaveLength(2); - for (const endpoint of endpoints) { - expect(endpoint.fn).toBe(""); - expect(endpoint.queryParameters).toContainEqual( - expect.objectContaining({ - name: "dataset", - enum: ["errors", "logs", "spans", "uptime_results"], - }), + 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]), + }), + ]), + }), + ]), ); } - expect(endpoints).toContainEqual( - expect.objectContaining({ - path: timeseriesPath, - operationId: "listOrganizationEventsTimeseries", - summary: "Query Explore Events in Timeseries Format", - tags: ["Explore"], - }), - ); - }); - - test("inherits path parameters and lets operations replace matching query parameters", () => { - const spec: OpenApiSpec = { - paths: { - [EVENTS_PATH]: { - parameters: [ - { in: "path", name: "organization_id_or_slug", required: true }, - { in: "query", name: "cursor", schema: { type: "string" } }, - { - in: "query", - name: "dataset", - schema: { type: "string", enum: ["errors"] }, - }, - ], - get: { - parameters: [ - { - in: "query", - name: "dataset", - required: true, - schema: { type: "string", enum: ["logs", "spans"] }, - }, - { in: "header", name: "cursor", required: true }, - ], - }, - post: {}, - }, - }, - }; - - const endpoints = extractApiEndpoints(spec, new Map()); - - expect(endpoints.find((endpoint) => endpoint.method === "GET")).toEqual( - expect.objectContaining({ - pathParams: ["organization_id_or_slug"], - queryParams: ["cursor", "dataset"], - queryParameters: [ - { name: "cursor", required: false, type: "string" }, - { - name: "dataset", - required: true, - type: "string", - enum: ["logs", "spans"], - }, - ], - }), - ); - expect(endpoints.find((endpoint) => endpoint.method === "POST")).toEqual( - expect.objectContaining({ - queryParameters: [ - { name: "cursor", required: false, type: "string" }, - { - name: "dataset", - required: false, - type: "string", - enum: ["errors"], - }, - ], - }), - ); - }); - - test("retains repeated query enum values", () => { - const spec: OpenApiSpec = { - paths: { - "/api/0/organizations/{organization_id_or_slug}/stats_v2/": { - get: { - parameters: [ - { - in: "query", - name: "groupBy", - required: true, - schema: { - type: "array", - items: { enum: ["outcome", "category", "reason", "project"] }, - }, - }, - ], - }, - }, - }, - }; - - const [endpoint] = extractApiEndpoints(spec, new Map()); - - expect(endpoint?.queryParameters).toEqual([ - { - name: "groupBy", - required: true, - type: "array", - enum: ["outcome", "category", "reason", "project"], - }, - ]); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47ea8b669..bd37f952d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,8 +40,8 @@ catalogs: specifier: ^1.2.3 version: 1.2.3 '@sentry/api': - specifier: 0.400.0 - version: 0.400.0 + specifier: 0.412.0 + version: 0.412.0 '@sentry/cloudflare': specifier: 11.6.0 version: 11.6.0 @@ -466,7 +466,7 @@ importers: version: 1.46.0(ai@6.0.230(zod@4.6.7))(express@5.3.0)(zod@4.6.7) '@sentry/api': specifier: 'catalog:' - version: 0.400.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 0.412.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 10.63.0 version: 10.63.0(patch_hash=e663994979ff877a26ab6d4dea5968fbaee4ccdfdeb85623983535a572678940) @@ -803,7 +803,7 @@ importers: version: 2.3.0 '@sentry/api': specifier: 'catalog:' - version: 0.400.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 0.412.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -4303,8 +4303,8 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sentry/api@0.400.0': - resolution: {integrity: sha512-JEffW2hxsRTr3WuP0t/XW9lUdhZRR7RN+rOLQC7UwlqDzz7F893oIK4/8Ba7mNwtUeiYQ/4pl9rc7gG/iuItyQ==} + '@sentry/api@0.412.0': + resolution: {integrity: sha512-9XSN4IZYoOXTzUMch/j2Q1E6h11bDdkuiLfuEL465p3e4sewj0xnftNxWupCbvArd0hOQKjTMd741G+T9UPp/w==} engines: {node: '>=22'} peerDependencies: valibot: '*' @@ -12524,7 +12524,7 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sentry/api@0.400.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': + '@sentry/api@0.412.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': optionalDependencies: valibot: 1.4.2(typescript@5.9.3) zod: 4.6.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f0d9cc083..dda95f815 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -53,7 +53,7 @@ catalog: "@modelcontextprotocol/server": 2.3.0 "@radix-ui/react-accordion": ^1.2.20 "@radix-ui/react-slot": ^1.2.3 - "@sentry/api": 0.400.0 + "@sentry/api": 0.412.0 "@sentry/cloudflare": 11.6.0 "@sentry/core": 11.6.0 "@sentry/node": 11.6.0 @@ -163,6 +163,6 @@ minimumReleaseAgeExclude: - "@oxlint/binding-win32-x64-msvc@1.87.0" - oxfmt@0.72.0 - oxlint@1.87.0 - - "@sentry/api@0.400.0" + - "@sentry/api@0.412.0" - miniflare@5.20261019.1-beta - wrangler@4.151.3 From e16b420e883cf980cca38ed059cd6754def830fb Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 27 Oct 2026 22:01:38 +0100 Subject: [PATCH 5/7] fix(cli): use shared schema glob detection --- packages/cli/src/commands/schema.ts | 7 ++----- packages/cli/test/commands/schema.test.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index 2c0c7a100..19aae0281 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -31,7 +31,7 @@ import { import { CommandOutput } from "../lib/formatters/output.js"; import { fuzzyMatch } from "../lib/fuzzy.js"; import { getCommandApiReferences, type RouteMap } from "../lib/introspect.js"; -import { filterByName } from "../lib/list-filter.js"; +import { filterByName, isGlobFilter } from "../lib/list-filter.js"; // --------------------------------------------------------------------------- // Output data types @@ -393,10 +393,7 @@ export function resolveResourceQuery( } // Globs remain an explicit resource filter, separate from concept discovery. - if ( - !(operation || query.includes(" ")) && - (resource.includes("*") || resource.includes("?")) - ) { + if (!(operation || query.includes(" ")) && isGlobFilter(resource)) { return resolveGlobQuery(resource); } diff --git a/packages/cli/test/commands/schema.test.ts b/packages/cli/test/commands/schema.test.ts index 6b9cdd41f..34809bf66 100644 --- a/packages/cli/test/commands/schema.test.ts +++ b/packages/cli/test/commands/schema.test.ts @@ -119,6 +119,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); }); From 39f1f1d9bef23a8e94f89e24dbad87ef46c06ffe Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 27 Oct 2026 22:27:42 +0100 Subject: [PATCH 6/7] fix(cli): preserve HTTP filters in scoped schema lookups --- packages/cli/src/commands/schema.ts | 6 ++- .../cli/test/commands/schema-path.test.ts | 1 + packages/cli/test/commands/schema.test.ts | 52 +++++++++++++------ 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index 19aae0281..6f64054bd 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -397,10 +397,12 @@ export function resolveResourceQuery( return resolveGlobQuery(resource); } - const [resourceName, ...operationWords] = query + const [resourceName, ...operationWords] = parsed.text .trim() .split(QUERY_WORD_SEPARATOR); - const resourceEndpoints = getEndpointsByResource(resourceName ?? ""); + const resourceEndpoints = getEndpointsByResource(resourceName ?? "").filter( + (endpoint) => !parsed.method || endpoint.method === parsed.method, + ); const operationQuery = operationWords.join(" "); if (operationQuery) { const exact = findEndpointsByIdentifier(operationQuery).filter((endpoint) => diff --git a/packages/cli/test/commands/schema-path.test.ts b/packages/cli/test/commands/schema-path.test.ts index e4d004a87..965d235e1 100644 --- a/packages/cli/test/commands/schema-path.test.ts +++ b/packages/cli/test/commands/schema-path.test.ts @@ -40,6 +40,7 @@ describe("sentry schema path lookup", () => { ["listOrganizationIssues"], ["issues", "listOrganizationIssues"], ["GET", "listOrganizationIssues"], + ["GET", "issues", "listOrganizationIssues"], ["GET listOrganizationIssues"], [SKILL_QUERY], ["GET", ORG_ISSUES], diff --git a/packages/cli/test/commands/schema.test.ts b/packages/cli/test/commands/schema.test.ts index 34809bf66..45cc76905 100644 --- a/packages/cli/test/commands/schema.test.ts +++ b/packages/cli/test/commands/schema.test.ts @@ -84,22 +84,42 @@ describe("resolveResourceQuery", () => { } }); - 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.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("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); } }); From 87ef8525cb9ce8cca877a89b95f22b345c32b186 Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 29 Oct 2026 10:33:49 +0100 Subject: [PATCH 7/7] fix(cli): clarify schema query terminology --- apps/cli-docs/src/content/docs/agent-guidance.md | 2 +- apps/cli-docs/src/fragments/commands/schema.md | 3 +++ packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md | 4 ++-- .../plugins/sentry-cli/skills/sentry-cli/references/schema.md | 4 ++-- packages/cli/src/commands/schema.ts | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) 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 36e366af5..0980db9c9 100644 --- a/apps/cli-docs/src/fragments/commands/schema.md +++ b/apps/cli-docs/src/fragments/commands/schema.md @@ -5,6 +5,9 @@ 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 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 1b5fb4488..abf6963d0 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. @@ -797,7 +797,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 487c7af38..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 | |----------|-------------| -| `` | API concept, CLI command path, 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` diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index 6f64054bd..1266a69b0 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -518,7 +518,7 @@ export const schemaCommand = buildCommand({ brief: "API concept, CLI command path, exact operation ID, or METHOD /path", parse: String, - placeholder: "resource", + placeholder: "query", }, }, },