From db4e4c1e3e2052033e2391f6462e13bc84dce0d0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:15:23 +0000 Subject: [PATCH 1/3] feat(api): Export API reference data as @sentry/api/docs Add a docs generator next to the operations catalog. It projects the dereferenced spec onto what an API reference page shows: tags, full parameters, the request body's first content type, responses with schemas and examples, security and servers. It ships as the ./docs export with types, plus extractDocs() to build the same data from an unreleased spec. As in the client and the operation catalog since 2.0.0, paths are relative to the API base path, which the export carries as API_BASE_PATH, and server overrides keep only their origin. Tests pin the shape with a fixture and check the published export against the spec it was built from, so the two can't drift. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- README.md | 25 ++++++ build.mjs | 3 + lib/docs-catalog.mjs | 102 +++++++++++++++++++++ lib/docs-types.ts | 90 +++++++++++++++++++ lib/http-methods.mjs | 2 + lib/operation-catalog.mjs | 2 +- package.json | 4 + scripts/generate-docs.mjs | 33 +++++++ test/docs.test.ts | 181 ++++++++++++++++++++++++++++++++++++++ test/package.test.ts | 2 + 10 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 lib/docs-catalog.mjs create mode 100644 lib/docs-types.ts create mode 100644 lib/http-methods.mjs create mode 100644 scripts/generate-docs.mjs create mode 100644 test/docs.test.ts diff --git a/README.md b/README.md index ce9fd26..2da2721 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,31 @@ experimental.has("listOrganizationTraceMetrics"); // true The catalog is generated from the same schema and SDK output as each release. Consumers own search, grouping, ranking, and command aliases; the catalog does not fetch a live schema or infer undocumented API behavior. +### API reference data + +`@sentry/api/docs` exports what an API reference page shows for each operation, so a docs site can render the reference from the package version it pins instead of fetching the spec: + +```ts +import { API_BASE_PATH, operations, tags } from "@sentry/api/docs"; + +for (const operation of operations) { + console.log(operation.method, `${API_BASE_PATH}${operation.path}`, operation.summary); + for (const response of operation.responses) console.log(response.status, response.example); +} +``` + +Operation paths are relative to `API_BASE_PATH` (`/api/0`), as in the client and `@sentry/api/operations`, and server overrides keep only their origin. A page shows an operation's full path as `${API_BASE_PATH}${operation.path}`. + +`tags` lists the categories, with their sidebar names and descriptions. Each `ApiDocsOperation` has the original `operationId`, summary and description as the spec writes them, tags, deprecated and experimental flags, server overrides, security requirements, path and query parameters with their schemas, the request body's first content type with its schema and example, and every response with its schema and examples. The data comes from the same spec as the client in each release, so it is about 3.7 MB; import it only where you render the reference. Its shape follows semver: a breaking change to it is a major release. + +To preview a spec that isn't released yet, such as a local `make build-api-docs` output, build the same data from it with `extractDocs`: + +```ts +import { extractDocs } from "@sentry/api/docs"; + +const { apiBasePath, tags, operations } = extractDocs(JSON.parse(readFileSync("tests/apidocs/openapi-derefed.json", "utf8"))); +``` + ### Build-time source analysis `@sentry/api/source-analysis` follows references in supplied TypeScript source to discover calls to catalog operations. Install TypeScript only when using this optional build tool; the analyzer is tested with TypeScript 5.9. Its optional peer uses a wildcard so it does not constrain the compiler used by applications that only import the client. diff --git a/build.mjs b/build.mjs index 528db91..1c77979 100644 --- a/build.mjs +++ b/build.mjs @@ -50,6 +50,8 @@ const steps = [ "apply-query-defaults", "generate-client", "generate-operations", + // API reference data (tags, parameters, bodies, responses) for docs sites. + "generate-docs", // Use Valibot's description action for the descriptions, which getDescription reads. "describe-valibot-schemas", // Annotate schema constructors as pure and defer the default client so bundlers @@ -118,6 +120,7 @@ await Promise.all([ bundle("api-client/index", { external: ["@sentry/api", "http-cache-semantics", "p-limit"] }), bundle("api-client/endpoints/index", { external: ["@sentry/api", "@sentry/api/client"] }), bundle("operations"), + bundle("docs"), bundle("source-analysis", { external: ["typescript"], platform: "node" }), emitDeclarations([["valibot.ts", "valibot.gen.ts"], ["zod.ts", "zod.gen.ts"]]), ]); diff --git a/lib/docs-catalog.mjs b/lib/docs-catalog.mjs new file mode 100644 index 0000000..baa2799 --- /dev/null +++ b/lib/docs-catalog.mjs @@ -0,0 +1,102 @@ +/** Project the dereferenced OpenAPI document onto what an API reference shows. */ +import { splitApiBasePath } from "./api-base-path.mjs"; +import { HTTP_METHODS } from "./http-methods.mjs"; + +/** Keep `key` only when the source defines it, so the output has no `undefined` values. */ +const optional = (key, value) => (value === undefined ? {} : { [key]: value }); + +function tag(source) { + return { + name: source.name, + ...optional("description", source.description), + ...optional("sidebarName", source["x-sidebar-name"]), + displayDescription: source["x-display-description"] === true, + }; +} + +/** Operation-level parameters replace path-level parameters with the same name/in. */ +function parameters(pathItem, operation) { + const merged = new Map(); + for (const parameter of [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])]) { + merged.set(`${parameter.in} ${parameter.name}`, parameter); + } + return [...merged.values()].map(parameter => ({ + name: parameter.name, + in: parameter.in, + required: parameter.required === true, + ...optional("description", parameter.description), + experimental: parameter["x-sentry-experimental"] === true, + ...optional("schema", parameter.schema), + })); +} + +/** The first declared content type and its media type object, if any. */ +function firstContent(content) { + const [entry] = Object.entries(content ?? {}); + return entry ? { contentType: entry[0], media: entry[1] } : undefined; +} + +function requestBody(source) { + const first = firstContent(source?.content); + if (!first) return undefined; + return { + contentType: first.contentType, + required: source.required === true, + ...optional("schema", first.media.schema), + ...optional("example", first.media.example), + }; +} + +function response(status, source) { + const first = firstContent(source.content); + return { + status, + description: source.description ?? "", + ...(first + ? { + contentType: first.contentType, + ...optional("schema", first.media.schema), + ...optional("example", first.media.example), + ...optional("examples", first.media.examples), + } + : {}), + }; +} + +/** + * Build the API reference data for every operation, in specification order. + * Text fields are copied as written, so pages render exactly what the spec says. + * Paths are relative to the API base path and server URLs keep only their + * origin, as in the client and the operation catalog. + * @param {{servers?: {url: string}[], paths: object, tags?: object[]}} source Dereferenced + * OpenAPI document, such as this repository's `openapi-derefed.json` or a local spec build. + * It is not modified. + * @returns {import("./docs-types.ts").ApiDocs} + */ +export function extractDocs(source) { + const spec = structuredClone(source); + const apiBasePath = splitApiBasePath(spec); + const operations = []; + for (const [path, pathItem] of Object.entries(spec.paths)) { + for (const [method, operation] of Object.entries(pathItem)) { + if (!HTTP_METHODS.has(method)) continue; + const body = requestBody(operation.requestBody); + operations.push({ + operationId: operation.operationId ?? "", + method: method.toUpperCase(), + path, + ...optional("summary", operation.summary), + description: operation.description ?? "", + tags: operation.tags ?? [], + deprecated: operation.deprecated === true, + experimental: operation["x-sentry-experimental"] === true, + servers: (operation.servers ?? []).map(server => server.url), + security: operation.security ?? [], + parameters: parameters(pathItem, operation), + ...optional("requestBody", body), + responses: Object.entries(operation.responses ?? {}).map(([status, source]) => response(status, source)), + }); + } + } + return { apiBasePath, tags: (spec.tags ?? []).map(tag), operations }; +} diff --git a/lib/docs-types.ts b/lib/docs-types.ts new file mode 100644 index 0000000..4fd83d1 --- /dev/null +++ b/lib/docs-types.ts @@ -0,0 +1,90 @@ +import type { ApiOperation } from "./operation-types.ts"; + +/** A JSON Schema from the release's dereferenced OpenAPI specification, as written there. */ +export type ApiDocsSchema = { readonly [keyword: string]: unknown }; + +/** An API reference category. */ +export type ApiDocsTag = { + /** Name operations use in their `tags`. */ + readonly name: string; + /** Markdown description, when documented. */ + readonly description?: string; + /** Sidebar label (`x-sidebar-name`), when it differs from the name. */ + readonly sidebarName?: string; + /** Whether the category page shows the description (`x-display-description`). */ + readonly displayDescription: boolean; +}; + +/** An operation parameter. */ +export type ApiDocsParameter = { + readonly name: string; + readonly in: "path" | "query" | "header" | "cookie"; + readonly required: boolean; + /** Markdown description, when documented. */ + readonly description?: string; + /** Whether Sentry marks the parameter with `x-sentry-experimental`. */ + readonly experimental: boolean; + readonly schema?: ApiDocsSchema; +}; + +/** The request body, from its first declared content type. */ +export type ApiDocsRequestBody = { + readonly contentType: string; + readonly required: boolean; + readonly schema?: ApiDocsSchema; + readonly example?: unknown; +}; + +/** A named response example. */ +export type ApiDocsExample = { + readonly summary?: string; + readonly value: unknown; +}; + +/** A documented response, with the content of its first declared content type. */ +export type ApiDocsResponse = { + /** HTTP status code, or `default`, as the specification keys it. */ + readonly status: string; + readonly description: string; + readonly contentType?: string; + readonly schema?: ApiDocsSchema; + readonly example?: unknown; + readonly examples?: { readonly [name: string]: ApiDocsExample }; +}; + +/** Everything the API reference shows for one operation. */ +export type ApiDocsOperation = { + /** Original OpenAPI identifier; an empty string means it was not declared. */ + readonly operationId: string; + readonly method: ApiOperation["method"]; + /** OpenAPI path template, relative to `apiBasePath`, such as `/organizations/{organization_id_or_slug}/`. */ + readonly path: string; + /** Title, as written in the specification, when documented. */ + readonly summary?: string; + /** Markdown description, as written in the specification; empty when undocumented. */ + readonly description: string; + readonly tags: readonly string[]; + /** Whether OpenAPI explicitly marks the operation deprecated. */ + readonly deprecated: boolean; + /** Whether Sentry marks the operation with `x-sentry-experimental`. */ + readonly experimental: boolean; + /** Origins of the servers that override the document's servers; empty when none do. */ + readonly servers: readonly string[]; + /** Accepted security requirements: each maps a scheme to the scopes it needs. */ + readonly security: readonly { readonly [scheme: string]: readonly string[] }[]; + /** Parameters after applying operation overrides to path-level parameters. */ + readonly parameters: readonly ApiDocsParameter[]; + readonly requestBody?: ApiDocsRequestBody; + /** Responses in specification order. */ + readonly responses: readonly ApiDocsResponse[]; +}; + +/** The API reference data for this SDK version. */ +export type ApiDocs = { + /** The path every operation path is relative to, such as `/api/0`, from the spec's server URL. */ + readonly apiBasePath: string; + /** Categories in specification order. */ + readonly tags: readonly ApiDocsTag[]; + /** Operations in specification order. */ + readonly operations: readonly ApiDocsOperation[]; +}; diff --git a/lib/http-methods.mjs b/lib/http-methods.mjs new file mode 100644 index 0000000..0a2f6ec --- /dev/null +++ b/lib/http-methods.mjs @@ -0,0 +1,2 @@ +/** Path item keys that are operations rather than shared fields such as `parameters`. */ +export const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options", "trace"]); diff --git a/lib/operation-catalog.mjs b/lib/operation-catalog.mjs index e70345a..70279ef 100644 --- a/lib/operation-catalog.mjs +++ b/lib/operation-catalog.mjs @@ -1,7 +1,7 @@ /** Combine original OpenAPI metadata with the generated SDK's operation identities. */ +import { HTTP_METHODS } from "./http-methods.mjs"; import { parseSdkOperations } from "./sdk-operations.mjs"; -const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options", "trace"]); // RFC 9110 9.2.1: safe methods never change server state, so they are always idempotent. const SAFE_METHODS = new Set(["get", "head", "options", "trace"]); diff --git a/package.json b/package.json index a516da5..adbc041 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,10 @@ "types": "./dist/operations.d.ts", "import": "./dist/operations.js" }, + "./docs": { + "types": "./dist/docs.d.ts", + "import": "./dist/docs.js" + }, "./source-analysis": { "types": "./dist/source-analysis.d.ts", "import": "./dist/source-analysis.js" diff --git a/scripts/generate-docs.mjs b/scripts/generate-docs.mjs new file mode 100644 index 0000000..3f95ba5 --- /dev/null +++ b/scripts/generate-docs.mjs @@ -0,0 +1,33 @@ +/** Generate the standalone API reference data from the same release inputs. */ +import { copyFileSync, readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { srcPath } from "../lib/generated-source.mjs"; +import { extractDocs } from "../lib/docs-catalog.mjs"; + +export function emit() { + // build.mjs has made sourceSpec's paths relative already, and extractDocs + // does that itself, so read the spec as Sentry publishes it. + const spec = JSON.parse(readFileSync("./openapi-derefed.json", "utf8")); + for (const name of ["docs-types.ts", "docs-catalog.mjs", "api-base-path.mjs", "http-methods.mjs"]) { + copyFileSync(fileURLToPath(new URL(`../lib/${name}`, import.meta.url)), srcPath(name)); + } + // A JSON string literal parses faster than an object literal and keeps tsc + // from inferring a type for megabytes of data. + writeFileSync(srcPath("docs.ts"), [ + "// This file is auto-generated by scripts/generate-docs.mjs.", + "import type { ApiDocs } from './docs-types.ts';", + "export type * from './docs-types.ts';", + "/** Build the same data from another spec, such as a local build, to preview it. */", + "export { extractDocs } from './docs-catalog.mjs';", + "", + `const docs: ApiDocs = JSON.parse(${JSON.stringify(JSON.stringify(extractDocs(spec)))});`, + "", + "/** The path every operation path is relative to, from the spec's server URL. */", + "export const API_BASE_PATH = docs.apiBasePath;", + "/** API reference categories, in specification order. */", + "export const tags = docs.tags;", + "/** API reference data for every operation, in specification order. */", + "export const operations = docs.operations;", + "", + ].join("\n")); +} diff --git a/test/docs.test.ts b/test/docs.test.ts new file mode 100644 index 0000000..fb6afb8 --- /dev/null +++ b/test/docs.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "vitest"; +import { API_BASE_PATH, extractDocs, operations, tags } from "@sentry/api/docs"; +import { readFileSync } from "node:fs"; + +const fixture = { + openapi: "3.0.3", + info: { title: "API reference", version: "1" }, + servers: [{ url: "https://sentry.io/api/0/" }], + tags: [ + { name: "Teams", description: "Manage teams.", "x-display-description": true }, + { name: "Organizations", "x-sidebar-name": "Orgs" }, + ], + paths: { + "/organizations/{organization_id_or_slug}/teams/": { + parameters: [ + { name: "organization_id_or_slug", in: "path", required: true, description: "The org.", schema: { type: "string" } }, + { name: "detailed", in: "query", schema: { type: "string" } }, + ], + get: { + operationId: "List an Organization's Teams", + summary: "(DEPRECATED) List teams ", + description: "Return teams.\n", + tags: ["Teams"], + deprecated: true, + security: [{ auth_token: ["team:read"] }], + parameters: [ + { name: "detailed", in: "query", description: "Overrides the path-level one.", "x-sentry-experimental": true, schema: { type: "string", enum: ["0", "1"] } }, + ], + responses: { + "200": { + description: "Success", + content: { + "application/json": { + schema: { type: "array", items: { type: "object" } }, + example: [{ slug: "web" }], + }, + }, + }, + "403": { description: "Forbidden" }, + }, + }, + post: { + operationId: "Create a Team", + tags: ["Teams"], + "x-sentry-experimental": true, + servers: [{ url: "https://us.sentry.io/api/0" }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { type: "object", required: ["slug"], properties: { slug: { type: "string", description: "Team slug." } } }, + example: { slug: "web" }, + }, + "multipart/form-data": { schema: { type: "object" } }, + }, + }, + responses: { + "201": { + description: "Created", + content: { + "application/json": { + schema: { type: "object" }, + examples: { created: { summary: "A team", value: { slug: "web" } } }, + }, + }, + }, + }, + }, + }, + }, +}; + +describe("API reference data", () => { + test("copies what a reference page shows, in specification order", () => { + expect(extractDocs(fixture)).toEqual({ + apiBasePath: "/api/0", + tags: [ + { name: "Teams", description: "Manage teams.", displayDescription: true }, + { name: "Organizations", sidebarName: "Orgs", displayDescription: false }, + ], + operations: [ + { + operationId: "List an Organization's Teams", + method: "GET", + path: "/organizations/{organization_id_or_slug}/teams/", + summary: "(DEPRECATED) List teams ", + description: "Return teams.\n", + tags: ["Teams"], + deprecated: true, + experimental: false, + servers: [], + security: [{ auth_token: ["team:read"] }], + parameters: [ + { name: "organization_id_or_slug", in: "path", required: true, description: "The org.", experimental: false, schema: { type: "string" } }, + { name: "detailed", in: "query", required: false, description: "Overrides the path-level one.", experimental: true, schema: { type: "string", enum: ["0", "1"] } }, + ], + responses: [ + { + status: "200", + description: "Success", + contentType: "application/json", + schema: { type: "array", items: { type: "object" } }, + example: [{ slug: "web" }], + }, + { status: "403", description: "Forbidden" }, + ], + }, + { + operationId: "Create a Team", + method: "POST", + path: "/organizations/{organization_id_or_slug}/teams/", + description: "", + tags: ["Teams"], + deprecated: false, + experimental: true, + servers: ["https://us.sentry.io"], + security: [], + parameters: [ + { name: "organization_id_or_slug", in: "path", required: true, description: "The org.", experimental: false, schema: { type: "string" } }, + { name: "detailed", in: "query", required: false, experimental: false, schema: { type: "string" } }, + ], + requestBody: { + contentType: "application/json", + required: true, + schema: { type: "object", required: ["slug"], properties: { slug: { type: "string", description: "Team slug." } } }, + example: { slug: "web" }, + }, + responses: [ + { + status: "201", + description: "Created", + contentType: "application/json", + schema: { type: "object" }, + examples: { created: { summary: "A team", value: { slug: "web" } } }, + }, + ], + }, + ], + }); + }); + + describe("the published export", () => { + const spec = JSON.parse(readFileSync(new URL("../openapi-derefed.json", import.meta.url), "utf8")); + const sourceOperations = Object.entries(spec.paths).flatMap(([path, pathItem]) => + Object.entries(pathItem as Record>).map(([method, operation]) => ({ path, method, operation })), + ); + + test("is built from this release's spec", () => { + expect({ apiBasePath: API_BASE_PATH, tags, operations }).toEqual(extractDocs(spec)); + expect(API_BASE_PATH).toBe("/api/0"); + }); + + test("covers every tag and operation the spec documents", () => { + expect(tags.map(tag => tag.name)).toEqual(spec.tags.map((tag: { name: string }) => tag.name)); + expect(operations.map(operation => `${operation.method} ${API_BASE_PATH}${operation.path}`)).toEqual( + sourceOperations.map(({ path, method }) => `${method.toUpperCase()} ${path}`), + ); + }); + + test("keeps each operation's text, parameters, examples and responses as the spec writes them", () => { + operations.forEach((operation, index) => { + const source = sourceOperations[index]!.operation; + const media = (content: object | undefined) => Object.values(content ?? {})[0]; + expect(operation.operationId).toBe(source.operationId); + expect(operation.summary).toBe(source.summary); + expect(operation.description).toBe(source.description ?? ""); + expect(operation.parameters.map(({ name, in: location }) => `${location} ${name}`)).toEqual( + (source.parameters ?? []).map((parameter: { name: string; in: string }) => `${parameter.in} ${parameter.name}`), + ); + expect(operation.requestBody?.example).toEqual(media(source.requestBody?.content)?.example); + expect(operation.requestBody?.schema).toEqual(media(source.requestBody?.content)?.schema); + expect(operation.responses.map(response => response.status)).toEqual(Object.keys(source.responses)); + operation.responses.forEach(response => { + const sourceMedia = media(source.responses[response.status].content); + expect(response.schema).toEqual(sourceMedia?.schema); + expect(response.example ?? response.examples).toEqual(sourceMedia?.example ?? sourceMedia?.examples); + }); + }); + }); + }); +}); diff --git a/test/package.test.ts b/test/package.test.ts index 5f15a46..bf31da0 100644 --- a/test/package.test.ts +++ b/test/package.test.ts @@ -106,6 +106,8 @@ describe("package entry points", () => { } const { operations } = await import("@sentry/api/operations"); assert(operations.some(operation => operation.functionName === "listOrganizations")); + const docs = await import("@sentry/api/docs"); + assert(docs.operations.some(operation => operation.path === "/organizations/")); // The root entry creates its default client on first use, not at import. const root = await import("@sentry/api"); assert.equal(typeof root.getOrganization, "function"); From 80fe15f222110718a94709b9c50e40f847de3909 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:44:23 +0000 Subject: [PATCH 2/3] ref(api): Print docs.ts from compiler nodes and lint against TS source strings generate-docs now builds docs.ts from ts.factory nodes and writes it through a new writeSource helper that prints with the TypeScript printer. A local oxlint plugin adds sentry-api/no-typescript-source-strings, which reports .ts files written from strings, templates, concatenation or .join(), and string patches through replaceOnce, in build.mjs, scripts/ and lib/. CI runs it as pnpm lint. The generators that predate the rule are listed in .oxlintrc.json until they move to the printer. --- .github/workflows/build.yml | 2 + .oxlintrc.json | 29 +++++ AGENTS.md | 4 + lib/generated-source.mjs | 13 +++ lint/plugin.mjs | 90 +++++++++++++++ package.json | 2 + pnpm-lock.yaml | 217 ++++++++++++++++++++++++++++++++++++ scripts/generate-docs.mjs | 80 +++++++++---- test/lint-rules.test.ts | 58 ++++++++++ 9 files changed, 474 insertions(+), 21 deletions(-) create mode 100644 .oxlintrc.json create mode 100644 lint/plugin.mjs create mode 100644 test/lint-rules.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2a9256d..d1f2a20 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,6 +38,8 @@ jobs: - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm build - run: pnpm typecheck diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..3f0055c --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,29 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [], + "jsPlugins": ["./lint/plugin.mjs"], + "categories": { "correctness": "off" }, + "ignorePatterns": ["dist/**", "src/**"], + "overrides": [ + { + "files": ["build.mjs", "scripts/**", "lib/**"], + "rules": { "sentry-api/no-typescript-source-strings": "error" } + }, + { + "files": [ + "build.mjs", + "scripts/generate-api-base-path.mjs", + "scripts/generate-base-url-resolver.mjs", + "scripts/generate-client.mjs", + "scripts/generate-error-results.mjs", + "scripts/generate-operations.mjs", + "scripts/generate-pagination.mjs", + "scripts/generate-query-enum-types.mjs", + "scripts/generate-request-preparation.mjs", + "scripts/generate-response-contract.mjs", + "scripts/make-tree-shakeable.mjs" + ], + "rules": { "sentry-api/no-typescript-source-strings": "off" } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 26c5139..43ddf10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,10 @@ For long-term knowledge entries managed by [lore](https://github.com/BYK/loreai) Use pnpm and Node.js (22.13 or later); do not add Bun-only APIs or tooling. Run `pnpm build` before `pnpm typecheck` and `pnpm test`: both read the generated `src/` and `dist/` output. +## Generated TypeScript + +Build generated `.ts` modules from TypeScript compiler nodes (`ts.factory`) and write them with `writeSource` from `lib/generated-source.mjs`. Do not write TypeScript from strings, templates, or `.join()`, and do not patch it with `replaceOnce`. `pnpm lint` enforces this with the `sentry-api/no-typescript-source-strings` rule in `lint/plugin.mjs`. The generators that predate the rule are listed in `.oxlintrc.json`; take a file off that list when you move it to the printer, and do not add new ones. + ## URLs Parse URLs with `URL` (`new URL`, `URL.canParse`), never with regular expressions. `test/url-parsing.test.ts` fails on any regex that matches a scheme separator (`://`). diff --git a/lib/generated-source.mjs b/lib/generated-source.mjs index 8383287..0b42b8a 100644 --- a/lib/generated-source.mjs +++ b/lib/generated-source.mjs @@ -27,6 +27,19 @@ export function update(name, transform) { writeFileSync(path, transform(readFileSync(path, "utf8"), name)); } +/** + * Write src/`name` from TypeScript AST `statements`, printed by the compiler, + * so generated modules are built from nodes rather than source strings. + */ +export function writeSource(name, statements) { + const path = srcPath(name); + const file = ts.factory.updateSourceFile( + ts.createSourceFile(path, "", ts.ScriptTarget.Latest, false, ts.ScriptKind.TS), + statements, + ); + writeFileSync(path, ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }).printFile(file)); +} + /** Parse src/`name`, keeping its text for position-based edits. */ export function parse(name) { const path = srcPath(name); diff --git a/lint/plugin.mjs b/lint/plugin.mjs new file mode 100644 index 0000000..7683326 --- /dev/null +++ b/lint/plugin.mjs @@ -0,0 +1,90 @@ +/** + * Repository oxlint rules. + * + * `no-typescript-source-strings`: generated TypeScript is built from compiler + * nodes and printed with `writeSource` (lib/generated-source.mjs). Writing a + * `.ts` file from a string, template, concatenation or `.join()`, or patching + * one with `replaceOnce`, builds source text by hand, which nothing checks + * until tsc runs on the output. + */ + +const WRITERS = new Set(["writeFileSync", "appendFileSync"]); + +const calleeName = callee => + callee.type === "Identifier" ? callee.name + : callee.type === "MemberExpression" && !callee.computed ? callee.property.name + : undefined; + +/** The initializer of the `const` that `identifier` names, if any. */ +function constInit(context, identifier) { + let scope = context.sourceCode.getScope(identifier); + for (; scope; scope = scope.upper) { + const variable = scope.set.get(identifier.name); + if (!variable) continue; + const declarator = variable.defs[0]?.node; + return declarator?.type === "VariableDeclarator" && declarator.parent.kind === "const" + ? declarator.init ?? undefined + : undefined; + } + return undefined; +} + +/** + * Whether `node` names a `.ts` file: "x.ts", `${dir}/x.ts`, srcPath("x.ts"), + * or a `const` set to one of those. + */ +function isTypeScriptPath(context, node) { + switch (node.type) { + case "Literal": + return typeof node.value === "string" && node.value.endsWith(".ts"); + case "TemplateLiteral": + return node.quasis.at(-1).value.cooked.endsWith(".ts"); + case "CallExpression": + return node.arguments.some(argument => isTypeScriptPath(context, argument)); + case "Identifier": { + const init = constInit(context, node); + return init !== undefined && isTypeScriptPath(context, init); + } + default: + return false; + } +} + +/** Whether `node` assembles text by hand rather than printing nodes. */ +function isHandBuiltText(node) { + switch (node.type) { + case "Literal": + return typeof node.value === "string"; + case "TemplateLiteral": + return true; + case "BinaryExpression": + return node.operator === "+"; + case "CallExpression": + return calleeName(node.callee) === "join"; + default: + return false; + } +} + +const message = "Build generated TypeScript from compiler nodes and print it with writeSource(), not from source strings."; + +export default { + meta: { name: "sentry-api" }, + rules: { + "no-typescript-source-strings": { + meta: { type: "problem", docs: { description: message } }, + create(context) { + return { + CallExpression(node) { + const name = calleeName(node.callee); + const [path, data] = node.arguments; + if (name === "replaceOnce" + || (WRITERS.has(name) && path && data && isTypeScriptPath(context, path) && isHandBuiltText(data))) { + context.report({ node, message }); + } + }, + }; + }, + }, + }, +}; diff --git a/package.json b/package.json index adbc041..f700f3f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ ], "scripts": { "build": "node build.mjs", + "lint": "oxlint", "test": "vitest run", "typecheck": "tsc -p tsconfig.test.json && tsc -p tsconfig.browser.json" }, @@ -97,6 +98,7 @@ "@types/node": "^22", "esbuild": "^0.28.2", "fast-check": "^4.8.0", + "oxlint": "1.93.0", "typescript": "^5.9.3", "valibot": "^1.4.2", "vitest": "^5.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5bcf23..ca20c1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: fast-check: specifier: ^4.8.0 version: 4.10.2 + oxlint: + specifier: 1.93.0 + version: 1.93.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -249,6 +252,128 @@ packages: '@oxc-project/types@0.156.0': resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} + '@oxlint/binding-android-arm-eabi@1.93.0': + resolution: {integrity: sha512-KKw70pbD5Pvq3G2lJ+WQ71XPMjCmg37rkSe2+mDqCRP3PoTSdDMuEi4UOnE8JBtyNM7bek61EC2s0Jt7S7u+Sg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.93.0': + resolution: {integrity: sha512-fvr1fTUZGelwRxKDt64pf28aL1WaWgviwz3X+E/FcRYvrlEahH0NECRz/c1SY+K+KWmZk5uAf6h1kQnyBkp/2A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.93.0': + resolution: {integrity: sha512-VdvSN9JRf4Es6ZzoAdvEeh8tgKdw8L4H82WmA8MMPVfPdZGCwE/nI27Z1HrRHYQhPxOh4kmnxQEioSUFrOvDaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.93.0': + resolution: {integrity: sha512-QR1V890Ml0YGRG0F/iV0K9igG+pOhe+VWd+aZnxY849rRHZf3F2gEoQks5+GVHf105X9Mh/6HdShTP5juekG2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.93.0': + resolution: {integrity: sha512-jDfIbi30zZaKi+rTvig3sge+Xhi8pFFSgHxb0jzovc4qh8ETm/irKV9c7N/JsuwnYc5sbvf4IC8w1vQQ0IL+BA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.93.0': + resolution: {integrity: sha512-qtxZ714ND/koFgVH00QgxlzZiIbRPmguaPhnHRU5DFFtrtEdOrSnZD4U/H2AzXrXkZ59eQbMBnJwbum5XohrIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.93.0': + resolution: {integrity: sha512-5pJ9NjMvH7WMUqXB6bTTOSemBm39w2cod27Hu3+Yom/GhbTeNXlYcpDx1o4CgBP6fhp0YHG66TNdVaUm6sHjsA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.93.0': + resolution: {integrity: sha512-pRf+7jtbt2fHE23SX7qBxRgnBZicUgsoCK1PVIjnOk+t3a6UBkpt/GrKI99WlxsKUxftz/5nv/kfKvScmYywkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.93.0': + resolution: {integrity: sha512-zM8Fd/K8sqGvsBcJBZ3oVZ3ctGWbszA08IaGt5gFFF5RcunppAI/ZnAXbouABvnWeJ//S44KKlfPZnDDbeiphg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.93.0': + resolution: {integrity: sha512-7qKCnvhZKDXJG2pdbLazd2hw4FxjJHbZOFrrbzyy5PdHfPLH9vnNUa1le01A8uItZuYKPBPncH+HCoEiFT4SgA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.93.0': + resolution: {integrity: sha512-2pgulxC6G8V5wE750NJE/fRkf9aO6i6yoj10wyaR2EdiNtan4OemsOtsQ0ZTXGdY8Q3IoTmlND3GYvjJsvikYg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.93.0': + resolution: {integrity: sha512-yWsn6Gg2PnGG5jlyDz5+pHGiMTMiy4adaa4WRMzDgzB8efaShk1JMmTHIBSVLQMh0tYXRs4CN5uJCO79r4Mi4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.93.0': + resolution: {integrity: sha512-WCn5plwp/ezS2RDPQKp0/EXEVRr9/dDexUdRKmsMFqzNM5xBSZ1wbsi3PuMk1VfkMRnI5BuazCsziCWMXRDOlw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.93.0': + resolution: {integrity: sha512-F2CGjb9LAkzH+IYTZSzd1klQ1vk5z6Utjax64WeLByCkYXbVDbsun+Oy2hLfW2MDSKCvW+jwy/9ld0EPgdeu+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.93.0': + resolution: {integrity: sha512-plf5DE2objcG8Tf7mXRuRa8tT8rzDkpjqXQtKoGsKHWkpgsialbON1+cCtoSEUTVbQUjXvuAm8vV9+3rR0yCBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.93.0': + resolution: {integrity: sha512-Kp2yJrKvoTe9FfKdO/xwLNUbs2bYTMKx0+aoQD1s0h/Tx0kSgtHSdGmrk8tTKdluosjFbo88qYRIaNIX43AfFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.93.0': + resolution: {integrity: sha512-vigjcHCFsR6KjnWTEVfaCxcMYapEuaK9FZa1MpewwQ1S+Xud3v0okfkj1RySsD6RF1NRHIjSlQSalTnKIaj3nA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.93.0': + resolution: {integrity: sha512-0Ot3BEe/AAr2/uQhe55r8CmPlperYk2mQmxdutOru7oU0kir6IiEVHb1TG3GORRlfra97lPHVDLhgmihiKzLrg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.93.0': + resolution: {integrity: sha512-ZzzIIVs/Qkm8trUWYKF0OYub5ALlv50/3gsEZbKAvWgz+yNJX6uRmQ8ySPXU2Q0/Y9m4JCOjhpmC8XUYbPHX5Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/binding-android-arm-eabi@1.2.16': resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -623,6 +748,19 @@ packages: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} + oxlint@1.93.0: + resolution: {integrity: sha512-19JMsQFX+YvEbcUTFQxjTwhvs0Ibg0D6CFneSLgpwnACWcY/6LjhpGpkPodh0T5aIT1pjMTLlYaeBFzaVgugTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2005' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + p-limit@7.3.3: resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} engines: {node: '>=20'} @@ -975,6 +1113,63 @@ snapshots: '@oxc-project/types@0.156.0': {} + '@oxlint/binding-android-arm-eabi@1.93.0': + optional: true + + '@oxlint/binding-android-arm64@1.93.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.93.0': + optional: true + + '@oxlint/binding-darwin-x64@1.93.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.93.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.93.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.93.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.93.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.93.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.93.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.93.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.93.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.93.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.93.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.93.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.93.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.93.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.93.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.93.0': + optional: true + '@rolldown/binding-android-arm-eabi@1.2.16': optional: true @@ -1258,6 +1453,28 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 + oxlint@1.93.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.93.0 + '@oxlint/binding-android-arm64': 1.93.0 + '@oxlint/binding-darwin-arm64': 1.93.0 + '@oxlint/binding-darwin-x64': 1.93.0 + '@oxlint/binding-freebsd-x64': 1.93.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.93.0 + '@oxlint/binding-linux-arm-musleabihf': 1.93.0 + '@oxlint/binding-linux-arm64-gnu': 1.93.0 + '@oxlint/binding-linux-arm64-musl': 1.93.0 + '@oxlint/binding-linux-ppc64-gnu': 1.93.0 + '@oxlint/binding-linux-riscv64-gnu': 1.93.0 + '@oxlint/binding-linux-riscv64-musl': 1.93.0 + '@oxlint/binding-linux-s390x-gnu': 1.93.0 + '@oxlint/binding-linux-x64-gnu': 1.93.0 + '@oxlint/binding-linux-x64-musl': 1.93.0 + '@oxlint/binding-openharmony-arm64': 1.93.0 + '@oxlint/binding-win32-arm64-msvc': 1.93.0 + '@oxlint/binding-win32-ia32-msvc': 1.93.0 + '@oxlint/binding-win32-x64-msvc': 1.93.0 + p-limit@7.3.3: dependencies: yocto-queue: 1.2.2 diff --git a/scripts/generate-docs.mjs b/scripts/generate-docs.mjs index 3f95ba5..18754b3 100644 --- a/scripts/generate-docs.mjs +++ b/scripts/generate-docs.mjs @@ -1,9 +1,30 @@ /** Generate the standalone API reference data from the same release inputs. */ -import { copyFileSync, readFileSync, writeFileSync } from "node:fs"; +import { copyFileSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { srcPath } from "../lib/generated-source.mjs"; +import ts from "typescript"; +import { srcPath, writeSource } from "../lib/generated-source.mjs"; import { extractDocs } from "../lib/docs-catalog.mjs"; +const f = ts.factory; + +/** Attach `/** text *\/` to `node`. */ +const documented = (node, text) => + ts.addSyntheticLeadingComment(node, ts.SyntaxKind.MultiLineCommentTrivia, `* ${text} `, true); + +/** `export const name = docs.property;` */ +const exportDocsProperty = (name, property, description) => + documented( + f.createVariableStatement( + [f.createModifier(ts.SyntaxKind.ExportKeyword)], + f.createVariableDeclarationList( + [f.createVariableDeclaration(name, undefined, undefined, + f.createPropertyAccessExpression(f.createIdentifier("docs"), property))], + ts.NodeFlags.Const, + ), + ), + description, + ); + export function emit() { // build.mjs has made sourceSpec's paths relative already, and extractDocs // does that itself, so read the spec as Sentry publishes it. @@ -11,23 +32,40 @@ export function emit() { for (const name of ["docs-types.ts", "docs-catalog.mjs", "api-base-path.mjs", "http-methods.mjs"]) { copyFileSync(fileURLToPath(new URL(`../lib/${name}`, import.meta.url)), srcPath(name)); } - // A JSON string literal parses faster than an object literal and keeps tsc - // from inferring a type for megabytes of data. - writeFileSync(srcPath("docs.ts"), [ - "// This file is auto-generated by scripts/generate-docs.mjs.", - "import type { ApiDocs } from './docs-types.ts';", - "export type * from './docs-types.ts';", - "/** Build the same data from another spec, such as a local build, to preview it. */", - "export { extractDocs } from './docs-catalog.mjs';", - "", - `const docs: ApiDocs = JSON.parse(${JSON.stringify(JSON.stringify(extractDocs(spec)))});`, - "", - "/** The path every operation path is relative to, from the spec's server URL. */", - "export const API_BASE_PATH = docs.apiBasePath;", - "/** API reference categories, in specification order. */", - "export const tags = docs.tags;", - "/** API reference data for every operation, in specification order. */", - "export const operations = docs.operations;", - "", - ].join("\n")); + const typesModule = f.createStringLiteral("./docs-types.ts"); + writeSource("docs.ts", [ + ts.addSyntheticLeadingComment( + f.createImportDeclaration( + undefined, + f.createImportClause(true, undefined, + f.createNamedImports([f.createImportSpecifier(false, undefined, f.createIdentifier("ApiDocs"))])), + typesModule, + ), + ts.SyntaxKind.SingleLineCommentTrivia, " This file is auto-generated by scripts/generate-docs.mjs.", true, + ), + f.createExportDeclaration(undefined, true, undefined, typesModule), + documented( + f.createExportDeclaration(undefined, false, + f.createNamedExports([f.createExportSpecifier(false, undefined, "extractDocs")]), + f.createStringLiteral("./docs-catalog.mjs")), + "Build the same data from another spec, such as a local build, to preview it.", + ), + // A JSON string literal parses faster than an object literal and keeps tsc + // from inferring a type for megabytes of data. + f.createVariableStatement( + undefined, + f.createVariableDeclarationList( + [f.createVariableDeclaration("docs", undefined, f.createTypeReferenceNode("ApiDocs"), + f.createCallExpression( + f.createPropertyAccessExpression(f.createIdentifier("JSON"), "parse"), + undefined, + [f.createStringLiteral(JSON.stringify(extractDocs(spec)))], + ))], + ts.NodeFlags.Const, + ), + ), + exportDocsProperty("API_BASE_PATH", "apiBasePath", "The path every operation path is relative to, from the spec's server URL."), + exportDocsProperty("tags", "tags", "API reference categories, in specification order."), + exportDocsProperty("operations", "operations", "API reference data for every operation, in specification order."), + ]); } diff --git a/test/lint-rules.test.ts b/test/lint-rules.test.ts new file mode 100644 index 0000000..acb5504 --- /dev/null +++ b/test/lint-rules.test.ts @@ -0,0 +1,58 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, describe, expect, it } from "vitest"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const directory = mkdtempSync(join(tmpdir(), "lint-rules-")); +afterAll(() => rmSync(directory, { recursive: true, force: true })); + +const RULE = "sentry-api/no-typescript-source-strings"; + +/** The 1-based lines RULE reports in `source`. */ +function reportedLines(source: string): number[] { + const config = join(directory, ".oxlintrc.json"); + writeFileSync(config, JSON.stringify({ + plugins: [], + jsPlugins: [join(root, "lint", "plugin.mjs")], + categories: { correctness: "off" }, + rules: { [RULE]: "error" }, + })); + const file = join(directory, "fixture.mjs"); + writeFileSync(file, source); + let output: string; + try { + output = execFileSync(join(root, "node_modules", ".bin", "oxlint"), ["-c", config, "-f", "json", file], { encoding: "utf8" }); + } catch (error) { + output = (error as { stdout: string }).stdout; + } + const { diagnostics } = JSON.parse(output) as { diagnostics: { labels: [{ span: { line: number } }] }[] }; + return diagnostics.map(diagnostic => diagnostic.labels[0].span.line).sort((a, b) => a - b); +} + +describe(RULE, () => { + it("reports TypeScript files written from strings and string patches", () => { + expect(reportedLines([ + 'import { appendFileSync, writeFileSync } from "node:fs";', + 'const OUT = srcPath("out.gen.ts");', + 'writeFileSync("src/a.ts", "export {};\\n");', + 'writeFileSync(srcPath("b.ts"), ["export {};"].join("\\n"));', + "writeFileSync(`${dir}/c.ts`, `export const c = ${value};`);", + 'appendFileSync(OUT, "export {};" + "\\n");', + 'source = replaceOnce(source, "a", "b");', + ].join("\n"))).toEqual([3, 4, 5, 6, 7]); + }); + + it("allows printed TypeScript and non-TypeScript output", () => { + expect(reportedLines([ + 'import { writeFileSync } from "node:fs";', + 'writeFileSync("src/a.ts", printer.printFile(file));', + 'writeFileSync("out.json", JSON.stringify(data));', + 'writeFileSync("notes.md", ["a", "b"].join("\\n"));', + 'let path = "src/d.ts";', + 'writeFileSync(path, "x");', + ].join("\n"))).toEqual([]); + }); +}); From 0d39d5a5eb8e78e3ad83f481e1f6a04e5d929362 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:45:31 +0000 Subject: [PATCH 3/3] docs(api): Don't hard-code the API base path in the README API_BASE_PATH comes from the spec's server URL, so naming its current value would go stale if the server changes it. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2da2721..9b7abe3 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Each `createSentryClient` call creates an independent transport configuration. T Supply the base URL and authentication appropriate for your deployment. See Sentry's [API authentication documentation](https://docs.sentry.io/api/auth/). -The base URL is the Sentry origin, such as `https://sentry.io` or a self-hosted `https://sentry.example.com`. Operation paths are relative to the API base path, which the client adds after the origin. It comes from the server URL in Sentry's OpenAPI spec and is exported as `API_BASE_PATH` (`"/api/0"`) from `@sentry/api`, `@sentry/api/client`, and `@sentry/api/operations`. Raw requests take the same relative paths, such as `sentry.client.get({ url: "/organizations/" })`. +The base URL is the Sentry origin, such as `https://sentry.io` or a self-hosted `https://sentry.example.com`. Operation paths are relative to the API base path, which the client adds after the origin. It comes from the server URL in Sentry's OpenAPI spec and is exported as `API_BASE_PATH` from `@sentry/api`, `@sentry/api/client`, and `@sentry/api/operations`. Raw requests take the same relative paths, such as `sentry.client.get({ url: "/organizations/" })`. ### Standalone operations @@ -150,7 +150,7 @@ for (const operation of operations) { } ``` -Operation paths are relative to `API_BASE_PATH` (`/api/0`), as in the client and `@sentry/api/operations`, and server overrides keep only their origin. A page shows an operation's full path as `${API_BASE_PATH}${operation.path}`. +Operation paths are relative to `API_BASE_PATH`, which comes from the spec's server URL, as in the client and `@sentry/api/operations`. Server overrides keep only their origin. A page shows an operation's full path as `${API_BASE_PATH}${operation.path}`. `tags` lists the categories, with their sidebar names and descriptions. Each `ApiDocsOperation` has the original `operationId`, summary and description as the spec writes them, tags, deprecated and experimental flags, server overrides, security requirements, path and query parameters with their schemas, the request body's first content type with its schema and example, and every response with its schema and examples. The data comes from the same spec as the client in each release, so it is about 3.7 MB; import it only where you render the reference. Its shape follows semver: a breaking change to it is a major release.