diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3531709..f43571b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,11 +13,13 @@ jobs: id-token: write attestations: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + with: + version: 12.4.0 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: 26 registry-url: https://registry.npmjs.org/ diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6976b13..07418a2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -17,11 +17,13 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + with: + version: 12.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: ${{ matrix.version }} cache: "pnpm" diff --git a/__tests__/codegen-regressions.test.ts b/__tests__/codegen-regressions.test.ts index 5272276..915a439 100644 --- a/__tests__/codegen-regressions.test.ts +++ b/__tests__/codegen-regressions.test.ts @@ -1,5 +1,5 @@ import type { oas31 } from "openapi3-ts"; -import { expect, test } from "vitest"; +import { assert, expect, test } from "vitest"; import { processOpenApiDocument } from "../lib/process-document.ts"; const respOk = { @@ -78,9 +78,10 @@ test("optional query params do not carry `| undefined` in their property type", const result = await processOpenApiDocument("/tmp/whatever", schema); const typesText = result.typesFile.getText(); - const queryBlock = - typesText.match(/export type ListFilesCommandQuery = \{[\s\S]*?\};/)?.[0] ?? - ""; + const queryBlock = typesText.match( + /export type ListFilesCommandQuery = \{[\s\S]*?\};/, + )?.[0]; + assert.isDefined(queryBlock, "ListFilesCommandQuery"); expect(queryBlock).toContain("purpose?: string"); expect(queryBlock).toContain("limit?: `${number}`"); @@ -138,11 +139,16 @@ test("AllInputs union includes every command's Input (no silent drops)", async ( const mainText = result.mainFile.getText(); const commandsText = result.commandsFile.getText(); - const allInputsBlock = mainText.match(/type AllInputs =[\s\S]*?;/)?.[0] ?? ""; + const allInputsBlock = mainText.match(/type AllInputs =[\s\S]*?;/)?.[0]; + assert.isDefined(allInputsBlock, "AllInputs"); const commandNames = [ ...commandsText.matchAll(/^export class (\w+Command) extends Command m[1] ?? ""); + ].map(([, name]) => { + assert.isDefined(name, "command name"); + + return name; + }); expect(commandNames.length).toBeGreaterThan(0); const missing = commandNames.filter( @@ -151,10 +157,7 @@ test("AllInputs union includes every command's Input (no silent drops)", async ( expect(missing).toEqual([]); }); -function docWithSchema( - name: string, - schema: oas31.SchemaObject, -): oas31.OpenAPIObject { +function docWithSchema(name: string, schema: oas31.SchemaObject) { return { openapi: "3.1.0", info: { title: "Test", version: "1.0.0" }, @@ -335,11 +338,11 @@ test("an array request body with parameters stays readable as both", async () => }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const inputBlock = - result.typesFile - .getTypeAlias("PluginPullCommandInput") - ?.getTypeNode() - ?.getText() ?? ""; + const inputBlock = result.typesFile + .getTypeAlias("PluginPullCommandInput") + ?.getTypeNode() + ?.getText(); + assert.isDefined(inputBlock, "PluginPullCommandInput"); expect(inputBlock).toContain("PluginPullCommandBodyWrapper"); expect(result.commandsFile.getText()).toMatch( @@ -390,11 +393,11 @@ test("nested query param members get the same stringish treatment as top-level o }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const queryBlock = - result.typesFile - .getTypeAlias("SearchCommandQuery") - ?.getTypeNode() - ?.getText() ?? ""; + const queryBlock = result.typesFile + .getTypeAlias("SearchCommandQuery") + ?.getTypeNode() + ?.getText(); + assert.isDefined(queryBlock, "SearchCommandQuery"); expect(queryBlock).toMatch(/limit\?: `\$\{number\}`/); expect(queryBlock).toMatch(/"age"\?: `\$\{number\}`/); @@ -441,11 +444,11 @@ test("json request body members keep their real JSON types, nested included", as }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const bodyBlock = - result.typesFile - .getTypeAlias("CreateContainerCommandJsonBody") - ?.getTypeNode() - ?.getText() ?? ""; + const bodyBlock = result.typesFile + .getTypeAlias("CreateContainerCommandJsonBody") + ?.getTypeNode() + ?.getText(); + assert.isDefined(bodyBlock, "CreateContainerCommandJsonBody"); expect(bodyBlock).toMatch(/"tty"\?: boolean/); expect(bodyBlock).toMatch(/"retries"\?: number/); @@ -502,14 +505,14 @@ test("a oneOf query param keeps the stringish wire types in every branch", async }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const queryBlock = - result.typesFile - .getTypeAlias("ListThingsCommandQuery") - ?.getTypeNode() - ?.getText() ?? ""; - - // The composition branch has to forward the codegen options the same way the - // array and object branches do, or a oneOf collapses back to the JSON types + const queryBlock = result.typesFile + .getTypeAlias("ListThingsCommandQuery") + ?.getTypeNode() + ?.getText(); + assert.isDefined(queryBlock, "ListThingsCommandQuery"); + + // Composition has to forward the codegen options the same way the array + // and object branches do, or a oneOf collapses back to the JSON types expect(queryBlock).toContain("`${number}`"); expect(queryBlock).toContain('"true" | "false"'); expect(queryBlock).not.toMatch(/size\?: number/); diff --git a/__tests__/nullables.test.ts b/__tests__/nullables.test.ts index 81df40c..bf03270 100644 --- a/__tests__/nullables.test.ts +++ b/__tests__/nullables.test.ts @@ -139,7 +139,7 @@ test("RFC 3339 temporal formats", async () => { expect(valibot.match(/v\.regex\(/g)?.length).toBe(8); expect(valibot.match(/v\.custom(() => true)", ); @@ -152,14 +152,14 @@ test("enums short-circuit type constraints (picklist only)", async () => { paths: {}, components: { schemas: { - // integer enum with a range constraint: must NOT emit minValue/integer + // integer enum with a range constraint, which skips minValue and integer IntegerEnum: { type: "integer", enum: [0, 1, 2], minimum: 0, maximum: 9, }, - // string enum carrying minLength/format: must NOT emit minLength/regex + // string enum with minLength and format, which skips minLength and regex StringEnum: { type: "string", format: "email", @@ -355,7 +355,9 @@ test("header parameters", async () => { expect(result.typesFile.getText()).toMatchSnapshot("types"); expect(result.commandsFile.getText()).toMatchSnapshot("commands"); - expect(result.commandsValidatedFile.getText()).toMatchSnapshot("commands-validated"); + expect(result.commandsValidatedFile.getText()).toMatchSnapshot( + "commands-validated", + ); expect(result.valibotFile.getText()).toMatchSnapshot("valibot"); expect(result.honoFile.getText()).toMatchSnapshot("hono"); }); diff --git a/__tests__/openai.test.ts b/__tests__/openai.test.ts index 59bea52..355c761 100644 --- a/__tests__/openai.test.ts +++ b/__tests__/openai.test.ts @@ -30,13 +30,7 @@ describe("OpenAI", () => { fetcher: createIsomorphicNativeFetcher({ retry: { retries: 0 }, fetch: (input, init) => - undiciFetch( - // @ts-expect-error @types/node resolves fetch types via undici-types@7, but we - // import undici@8 directly — Request.headers.keys() iterator types diverge. - // Fix: remove when @types/node ships undici-types@8 - input, - { ...init, dispatcher: mockAgent }, - ), + undiciFetch(input, { ...init, dispatcher: mockAgent }), }), }); diff --git a/__tests__/petstore.test.ts b/__tests__/petstore.test.ts index 3afd016..4456f59 100644 --- a/__tests__/petstore.test.ts +++ b/__tests__/petstore.test.ts @@ -35,13 +35,7 @@ describe("Petstore", () => { fetcher: createIsomorphicNativeFetcher({ retry: { retries: 0 }, fetch: (input, init) => - undiciFetch( - // @ts-expect-error @types/node resolves fetch types via undici-types@7, but we - // import undici@8 directly — Request.headers.keys() iterator types diverge. - // Fix: remove when @types/node ships undici-types@8 - input, - { ...init, dispatcher: mockAgent }, - ), + undiciFetch(input, { ...init, dispatcher: mockAgent }), }), }); const command = new FindPetsCommand({ diff --git a/__tests__/query-roundtrip.test.ts b/__tests__/query-roundtrip.test.ts index 96d35fc..1a3d16b 100644 --- a/__tests__/query-roundtrip.test.ts +++ b/__tests__/query-roundtrip.test.ts @@ -8,34 +8,28 @@ import { processOpenApiDocument } from "../lib/process-document.ts"; import { listauditlogs } from "./fixtures/openai/hono.ts"; import { findPets } from "./fixtures/petstore/hono.ts"; -// A query parameter's `style` and `explode` decide how a client writes it into -// a query string, so they also decide how the server has to read it back. These -// take the exact query string a client sends for an operation, hand it to that -// same operation's generated middleware through a real Hono app, and check that -// what comes out of validation is what went in. -// -// The client half of each contract is pinned on the other side, by the -// "query string building" tests in @block65/rest-client — this repo installs a -// released copy of that package, which predates the style work, so the wire -// strings here are written out rather than generated +// Runs a real query string through the generated middleware and back out async function validatedQuery( middleware: readonly MiddlewareHandler[], search: string, -): Promise { +) { + // Coverage of the client half lives with the "query string building" tests + // in @block65/rest-client. This repo installs a released + // copy of that package, which predates the style work, so the wire strings + // here are written out by hand const res = await appFor(middleware).request(`/target?${search}`); const body = await res.clone().text(); - // the body says why a route rejected the query, so it rides along into the - // failure output + // the body says why a route rejected the query, so the failure output + // includes it expect({ status: res.status, body }).toMatchObject({ status: 200 }); return res.json(); } -// OpenAI's ListAuditLogs `effective_at` is an object and its document states no -// style, so OpenAPI's default applies: the members go out on their own, without -// the parent name, and only the member list the document declares can put them -// back together +// OpenAI's ListAuditLogs `effective_at` is an object and its document states +// no style. OpenAPI's default sends the members without the parent name, and +// the declared member list is what puts them back together test("an object query parameter under the default style survives the round trip", async () => { const query = { effective_at: { gt: 1700000000, lte: 1700000100 }, @@ -59,7 +53,7 @@ test("an object query parameter under the default style survives the round trip" ); }); -// the parent stays absent rather than arriving as an empty object +// an absent parent stays absent, and never arrives as an empty object test("an absent object query parameter does not materialise", async () => { await expect(validatedQuery(listauditlogs, "limit=5")).resolves.toStrictEqual( { @@ -82,14 +76,10 @@ test("an array query parameter with one value is still an array", async () => { }); }); -// Nothing in the corpus declares `deepObject` or puts `explode: false` on an -// object, so those shapes have no fixture to borrow: the server is generated -// from a document written here, written to disk and imported, so the round trip -// runs real generated code rather than matching against its text +// Root for documents generated, written to disk and imported by these tests const generatedRoot = join(import.meta.dirname, ".generated"); -// The validated data's key is not visible through a spread of middleware, so -// the handler reads it untyped +// Mounts the middleware on a Hono app, with an untyped handler reading it function appFor(middleware: readonly MiddlewareHandler[]) { const app = new Hono(); @@ -97,15 +87,28 @@ function appFor(middleware: readonly MiddlewareHandler[]) { app.use("/target", handler); } + // TYPESAFETY: `c.req.valid` reads the key from the validator types a route + // was built with, and these middleware arrive as an opaque array, so the + // key is unreachable through the spread app.get("/target", (c) => c.json(c.req.valid("query" as never))); return app; } -async function serverFor( - name: string, - parameters: oas31.ParameterObject[], -): Promise { +// OAS 3.2 added `in: "querystring"`, which the 3.1 types predate +type TestParameter = + | oas31.ParameterObject + | { + name: string; + in: "querystring"; + content: oas31.ParameterObject["content"]; + }; + +async function serverFor(name: string, parameters: readonly TestParameter[]) { + // Nowhere in the corpus does `deepObject` or `explode: false` appear on an + // object, so those shapes need a document of their own. Generating it and importing + // the result exercises the emitted code, where a text match would only read + // it const document: oas31.OpenAPIObject = { openapi: "3.1.0", info: { title: "Test", version: "1.0.0" }, @@ -113,7 +116,10 @@ async function serverFor( "/things": { get: { operationId: "listThingsCommand", - parameters, + // TYPESAFETY: `TestParameter` widens the 3.1 union by the one 3.2 + // location these tests exercise, and the generator reads `in` as a + // string + parameters: parameters as oas31.ParameterObject[], responses: { "200": { description: "OK", @@ -132,6 +138,8 @@ async function serverFor( await mkdir(outputDir, { recursive: true }); await Promise.all([result.honoFile.save(), result.valibotFile.save()]); + // TYPESAFETY: a dynamic import is typed `any`, and the code below picks the + // one array export by inspection const module = (await import(join(outputDir, "hono.ts"))) as Record< string, unknown @@ -145,6 +153,8 @@ async function serverFor( expect(middleware).toBeDefined(); + // TYPESAFETY: the generator emits one array export per operation, and + // `toBeDefined` fails the test on a missing one return middleware as readonly MiddlewareHandler[]; } @@ -179,7 +189,7 @@ test("a deepObject parameter survives the round trip as bracket keys", async () }); }); -// the collision the default style cannot express, which deepObject can +// deepObject expresses a collision that the default style flattens away test("two deepObject parameters sharing a member name stay apart", async () => { const middleware = await serverFor("deep-object-pair", [ { name: "created", in: "query", style: "deepObject", schema: rangeSchema }, @@ -250,8 +260,8 @@ test("a malformed bracket key is rejected by name rather than reinterpreted", as await expect(res.text()).resolves.toContain("at[gt"); }); -// The document is the only thing that says where a member belongs, so a -// parameter that declares no style is worth saying out loud +// Member placement comes from the document alone, so an undeclared style is +// worth saying out loud test("an object query parameter with no declared style is warned about", async () => { const warnings: string[] = []; const original = console.warn; @@ -270,8 +280,8 @@ test("an object query parameter with no declared style is warned about", async ( ); }); -// Two such parameters cannot be told apart once their members lose the parent -// name, and no encoding this generator could pick would change that +// Two such parameters read alike once their members lose the parent name, and +// every encoding this generator could pick keeps them alike test("two default-style object parameters sharing a member name are warned about", async () => { const warnings: string[] = []; const original = console.warn; @@ -310,7 +320,7 @@ test("two default-style object parameters sharing a member name are warned about ); }); -async function warningsFrom(parameters: oas31.ParameterObject[]) { +async function warningsFrom(parameters: readonly TestParameter[]) { const warnings: string[] = []; const original = console.warn; console.warn = (message: string) => warnings.push(message); @@ -325,7 +335,7 @@ async function warningsFrom(parameters: oas31.ParameterObject[]) { } // OpenAPI marks these n/a and leaves them undefined, so the generator says so -// rather than both sides confidently producing something different +// before both sides confidently produce something different test("style and explode combinations the spec leaves undefined are warned about", async () => { const arrayOfStrings = { type: "array", @@ -356,15 +366,15 @@ test("style and explode combinations the spec leaves undefined are warned about" ).resolves.toContain("is an array with `style: deepObject`"); }); -// `in: "querystring"` matches no branch in the parameter loop, so without a -// word from the generator the operation silently loses its query entirely +// `in: "querystring"` matches no branch in the parameter loop, so a warning +// is what keeps the operation from silently losing its query test("an in: querystring parameter is warned about rather than dropped in silence", async () => { const warning = await warningsFrom([ { name: "whole", in: "querystring", content: { "application/json": { schema: { type: "object" } } }, - } as unknown as oas31.ParameterObject, + }, ]); expect(warning).toContain("uses `in: querystring`"); diff --git a/__tests__/test1.test.ts b/__tests__/test1.test.ts index d909d13..35b19ae 100644 --- a/__tests__/test1.test.ts +++ b/__tests__/test1.test.ts @@ -42,13 +42,7 @@ describe("Test1", () => { fetcher: createIsomorphicNativeFetcher({ retry: { retries: 0 }, fetch: (input, init) => - undiciFetch( - // @ts-expect-error @types/node resolves fetch types via undici-types@7, but we - // import undici@8 directly — Request.headers.keys() iterator types diverge. - // Fix: remove when @types/node ships undici-types@8 - input, - { ...init, dispatcher: mockAgent }, - ), + undiciFetch(input, { ...init, dispatcher: mockAgent }), }), }); const command = new GetBillingAccountCommand({ diff --git a/bin/index.ts b/bin/index.ts index 30aab91..3e0250c 100755 --- a/bin/index.ts +++ b/bin/index.ts @@ -22,7 +22,7 @@ const cliArgs = await yargs(hideBin(process.argv)) alias: "t", type: "array", description: "tags", - coerce(arg: string[] | string): string[] { + coerce(arg: string[] | string) { return Array.isArray(arg) ? arg : [arg]; }, }) @@ -35,9 +35,13 @@ const cliArgs = await yargs(hideBin(process.argv)) }) .help().argv; +const tags = Array.isArray(cliArgs.tags) + ? cliArgs.tags.map((tag) => String(tag)) + : undefined; + await build( join(process.cwd(), String(cliArgs.i)), join(process.cwd(), String(cliArgs.o)), - cliArgs.t as Array, + tags, { inputOnly: Boolean(cliArgs.inputOnly) }, ); diff --git a/lib/build.ts b/lib/build.ts index 4e33bd7..b8ef21a 100644 --- a/lib/build.ts +++ b/lib/build.ts @@ -7,27 +7,17 @@ import { processOpenApiDocument, } from "./process-document.ts"; -// `@generated` is the marker review tools collapse on, and the one the block65 -// comment rules read to tell generated files from hand-written ones. It carries -// no date or revision: those belong to the commit, and a timestamp in a comment -// is the history version control already holds +// `@generated` marks the file for review tools and the block65 comment rules const BANNER = `/** * @generated by @block65/openapi-codegen * * Do not edit directly */`; -// Generated files are usually reformatted after emission (oxfmt etc.), so their -// bytes never match the raw emitter output and comparing them would rewrite -// every file every run. The manifest records what the emitter produced, which -// makes an unchanged file detectable without a stamp inside it +// Records emitter output so a file reformatted on disk still compares equal const MANIFEST = ".openapi-codegen-manifest.json"; -function revision(text: string): string { - return createHash("sha256").update(text).digest("hex").slice(0, 32); -} - -async function readManifest(path: string): Promise> { +async function readManifest(path: string) { const text = await readFile(path, "utf8").catch(() => {}); if (text === undefined) { @@ -36,6 +26,10 @@ async function readManifest(path: string): Promise> { try { const parsed: unknown = JSON.parse(text); + + // TYPESAFETY: `writeManifest` below writes this file, storing a string + // revision per path. A hand-edited file degrades to a rewrite of every + // file, the same as an absent manifest return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; @@ -50,6 +44,8 @@ export async function build( tags?: string[], options?: CodegenOptions, ) { + // TYPESAFETY: a JSON import is typed `any`, and `$RefParser` validates the + // document before the generator reads it const apischema = (await import(inputFile, { with: { type: "json" }, })) as { default: oas31.OpenAPIObject }; @@ -80,33 +76,37 @@ export async function build( const manifestPath = join(outputDir, MANIFEST); const previous = await readManifest(manifestPath); - const next: Record = {}; - - for (const file of files) { - try { - file.formatText(); - } catch (err) { - console.warn(err); - } - - const contents = `${BANNER}\n${file.getFullText()}`; - const name = file.getBaseName(); - const rev = revision(contents); - - next[name] = rev; - - const unchanged = - previous[name] === rev && - (await readFile(file.getFilePath(), "utf8") + const revisions = await Promise.all( + files.map(async (file) => { + try { + file.formatText(); + } catch (err) { + console.warn(err); + } + + const contents = `${BANNER}\n${file.getFullText()}`; + const name = file.getBaseName(); + const rev = createHash("sha256") + .update(contents) + .digest("hex") + .slice(0, 32); + + const present = await readFile(file.getFilePath(), "utf8") .then(() => true) - .catch(() => false)); + .catch(() => false); - if (unchanged) { - continue; - } + if (previous[name] !== rev || !present) { + await writeFile(file.getFilePath(), contents); + } - await writeFile(file.getFilePath(), contents); - } + return { name, rev }; + }), + ); + + const next = new Map(revisions.map(({ name, rev }) => [name, rev])); - await writeFile(manifestPath, `${JSON.stringify(next, null, "\t")}\n`); + await writeFile( + manifestPath, + `${JSON.stringify(Object.fromEntries(next), null, "\t")}\n`, + ); } diff --git a/lib/hono.ts b/lib/hono.ts index 8da125b..af126a8 100644 --- a/lib/hono.ts +++ b/lib/hono.ts @@ -2,11 +2,12 @@ import { join } from "node:path"; import camelcase from "camelcase"; import type { Project, SourceFile } from "ts-morph"; import { VariableDeclarationKind } from "ts-morph"; +import { typedEntries } from "./utils.ts"; /** - * `hasQueryValidator` gates the query decoder rather than leaving it to - * `fixUnusedIdentifiers`, which removes the unused entry point but strands the - * helpers it called. + * `hasQueryValidator` controls whether the query decoder is emitted. + * `fixUnusedIdentifiers` would drop the unused entry point and strand the + * helpers it called */ export function createHonoFile( project: Project, @@ -57,14 +58,8 @@ export function createHonoFile( return file; } -// Hono hands a query validator a flat map of literal query keys. How those keys -// relate to the schema's shape is the document's decision: OpenAPI's `style` -// and `explode` say whether an object arrived hoisted to the top level, joined -// into one value, or bracketed under its own name. The decoder below inverts -// the Style Examples table (OAS 3.2 §4.12.6) and is driven by the same -// per-parameter entries the client encodes from, so the two agree by -// specification rather than by convention -function addQueryDecoder(file: SourceFile): void { +// Inverts the OAS 3.2 §4.12.6 style table to decode Hono's flat query map +function addQueryDecoder(file: SourceFile) { file.addStatements(` type QueryParamSpec = { readonly name: string; @@ -311,8 +306,8 @@ export function createHonoMiddleware( ): void { const name = camelcase(exportName); - // Only a parameter that is not a plain scalar needs a spec: a scalar reaches - // the validator as the string it was sent as, whatever its style + // A parameter beyond a plain scalar needs a spec. A scalar reaches the + // validator as the string it was sent as, under every style if (schemas.query && queryParams.length > 0) { honoFile.addVariableStatement({ declarationKind: VariableDeclarationKind.Const, @@ -334,10 +329,10 @@ export function createHonoMiddleware( initializer: (writer) => { writer.write("["); writer.indent(() => { - // Hono validators only run on inbound request data; response - // schemas are emitted for client-side consumption only, and - // `header` is intentionally skipped (extra HTTP headers ok) - for (const [target, schemaName] of Object.entries(schemas).filter( + // Hono validators run on inbound request data alone. Response + // schemas are emitted for client-side consumption, and `header` + // is skipped so extra HTTP headers pass + for (const [target, schemaName] of typedEntries(schemas).filter( ([t]) => t !== "header" && t !== "response", )) { const value = @@ -359,7 +354,7 @@ export function createHonoMiddleware( export function addSchemaImportsToHonoFile( honoFile: SourceFile, schemaNames: string[], -): void { +) { if (schemaNames.length === 0) { return; } diff --git a/lib/process-document.ts b/lib/process-document.ts index b58a4ca..4925339 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -39,9 +39,9 @@ import { export type CodegenOptions = { /** - * Emit only `input*` variants (TS-side schemas: `v.optional`, no wire - * coercion). Skips the `*Schema` (wire) variants used by hono middleware - * and response parsing. For non-HTTP / in-memory-only consumers. + * Emit only `input*` variants, which use `v.optional` and skip wire + * coercion. Suits consumers that stay in memory and never reach hono + * middleware or response parsing */ inputOnly?: boolean; }; @@ -58,17 +58,31 @@ type OperationMiddlewareInfo = { queryParams: QueryParamSpec[]; }; -// A query parameter's `style` and `explode` decide how it is written into a -// query string and, therefore, how it has to be read back out. Both sides of -// the generated code work from this table (OAS 3.2 §4.12.6), so neither has to -// guess. -// -// OpenAPI's defaults are `form` and, for `form` only, `explode: true`; -// `explode` defaults to false for every other style. 3.2 drops `explode` from -// `deepObject` altogether — the table's cell for it reads n/a — so it is -// normalised here rather than recorded, and both sides ignore it +const queryStyles = [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject", +] as const; + +function isQueryStyle(style: string): style is QueryParamSpec["style"] { + return queryStyles.some((candidate) => candidate === style); +} + +// OAS 3.2 added this location, so the 3.0 union this generator reads omits it +function isQuerystringLocation(location: string) { + return location === "querystring"; +} + +// Resolves the OAS 3.2 §4.12.6 style and explode defaults for a parameter function queryParameterEncoding(parameter: oas30.ParameterObject) { - const style = (parameter.style ?? "form") as QueryParamSpec["style"]; + // OpenAPI defaults `style` to `form`, and `explode` to true for `form` and + // false elsewhere. OAS 3.2 marks `explode` n/a for `deepObject`, so it is + // normalised to true here and both sides of the generated code ignore it + const style = + parameter.style !== undefined && isQueryStyle(parameter.style) + ? parameter.style + : "form"; return { style, @@ -81,7 +95,10 @@ function queryParameterEncoding(parameter: oas30.ParameterObject) { function queryParameterSpec( parameter: oas30.ParameterObject, ): QueryParamSpec | undefined { - const schema = (parameter.schema ?? {}) as oas30.SchemaObject; + const schema: oas30.SchemaObject = + parameter.schema !== undefined && !("$ref" in parameter.schema) + ? parameter.schema + : {}; const { style, explode } = queryParameterEncoding(parameter); if (schema.type === "array") { @@ -98,14 +115,11 @@ function queryParameterSpec( }; } - // a scalar arrives as the string it was sent as, whatever its style + // a scalar arrives as the string it was sent as, under every style return undefined; } -// The Style Examples table (OAS 3.2 §4.12.6) marks some style/explode/type -// combinations n/a and says their behaviour is undefined. Both sides of the -// generated code would still produce *something* for them, so the document is -// told off here rather than the disagreement being discovered in production +// Warns on style and explode combinations that OAS 3.2 §4.12.6 marks n/a function warnOnUndefinedCombination(operationId: string, spec: QueryParamSpec) { const undefinedCombination = (spec.style === "spaceDelimited" || spec.style === "pipeDelimited") && @@ -124,12 +138,7 @@ function warnOnUndefinedCombination(operationId: string, spec: QueryParamSpec) { } } -// An object-valued query parameter whose document states no style gets -// OpenAPI's default of form/explode, which drops the parent name. That is -// nearly always an oversight rather than a decision — it makes two such -// parameters sharing a member name indistinguishable, and it is not what the -// servers these documents describe tend to expect — so it is called out where -// someone can still fix the document +// Warns when an object query parameter omits style and falls back to form function warnOnUnderspecifiedQuery( operationId: string, parameters: oas30.ParameterObject[], @@ -176,10 +185,6 @@ function isUnspecifiedKeyword(type: TypeAliasDeclaration) { const emptyKeyword = "undefined" as const; -// function isEmptyKeyword(type: TypeAliasDeclaration) { -// return type?.getTypeNode()?.getKindName() === emptyKeyword; -// } - // the union/intersect helpers keep TypeScript happy due to ts-morph typings function createIntersection(...types: (string | undefined)[]) { // create a type of all the inputs @@ -219,10 +224,10 @@ export async function processOpenApiDocument( }, ); - // Parallel file: subclasses that attach `static responseSchema`. Consumers - // import from this file (or alias `./commands` → `./commands-validated` in - // dev) to opt into runtime response validation. Lean `commands.ts` carries - // zero schema imports, so prod bundles stay small + // Subclasses that attach `static responseSchema`. Consumers import from + // this module to opt into runtime response validation, or alias + // `./commands` to it in dev. The base command module imports zero + // schemas, so prod bundles stay small const commandsValidatedFile = project.createSourceFile( join(outputDir, "commands-validated.ts"), "", @@ -262,13 +267,13 @@ export async function processOpenApiDocument( InterfaceDeclaration | TypeAliasDeclaration | string >(); - // The exact Input type-arg expressions used in `Command` per - // operation. The client's `` union is built from these so - // commands from other generated clients fail the constraint on `.json()` + // Input type-arg expressions used in `Command` per operation. The + // client's `` union is built from these so commands from + // other generated clients fail the constraint on `.json()` const inputTypeArgs = new Set(); - // Bare type names referenced by `inputTypeArgs` expressions — collected at - // the source so we don't have to re-extract them from wrapped strings + // Bare type names referenced by `inputTypeArgs` expressions, collected at + // the source where they are still separate from the wrapped strings const inputTypeNames = new Set(); const refs = await $RefParser.resolve(schema); @@ -325,9 +330,9 @@ export async function processOpenApiDocument( const valibotModuleSpecifier = `./${valibotFile.getBaseNameWithoutExtension()}.js`; - // Commands with a response schema → emit a subclass in commands-validated.ts. - // Commands without one → re-export the base. Both keep the same exported name - // so consumers can swap files (or alias) without changing import sites + // Commands with a response schema get a subclass in the validated module. + // The rest re-export the base. Both keep the same exported name so + // consumers can swap modules and leave import sites alone const validatedSubclasses: { commandName: string; responseSchema: string }[] = []; const validatedReExports: string[] = []; @@ -536,21 +541,15 @@ export async function processOpenApiDocument( ...(operationObject.parameters || []), ...(pathItemObject.parameters || []), ]) { + // TYPESAFETY: `$RefParser.resolve` types every target as + // `unknown`, and this pointer came from a parameter list, so the + // document declares it as a parameter const resolvedParameter = ( "$ref" in parameter ? refs.get(parameter.$ref) : parameter ) as oas30.ParameterObject; if (resolvedParameter.in === "path") { pathParameters.push(resolvedParameter); - - // jsdoc.addTag({ - // tagName: 'param', - // text: wordWrap( - // `${parameterName} {String} ${ - // resolvedParameter.description || '' - // }`, - // ).trim(), - // }); } if ( @@ -581,11 +580,11 @@ export async function processOpenApiDocument( } // OpenAPI 3.2's `in: "querystring"` hands over the whole query - // string as one content-typed value, which this generator has no - // way to express. Without this it matches no branch at all and the - // operation quietly loses its query entirely, from a document that - // is perfectly valid - if (resolvedParameter.in === ("querystring" as string)) { + // string as one content-typed value, which this generator lacks a + // way to express. A warning is all that is left, since a valid + // document would otherwise generate an operation with its query + // silently dropped + if (isQuerystringLocation(resolvedParameter.in)) { console.warn( `${operationObject.operationId}: parameter "${resolvedParameter.name}" uses \`in: querystring\`, which this generator does not support — the operation is generated with no query at all. Declare the members as \`in: query\` parameters instead.`, ); @@ -609,9 +608,9 @@ export async function processOpenApiDocument( } } - // Only where the document departs from OpenAPI's default does the - // client need telling; an absent entry means that default rather - // than a generator's guess + // Entries here mark where the document departs from OpenAPI's + // default. An absent entry means that default, and never a + // generator's guess const styledQueryParameters = queryParameters.filter((parameter) => { const { style, explode } = queryParameterEncoding(parameter); return style !== "form" || !explode; @@ -767,13 +766,6 @@ export async function processOpenApiDocument( pascalCase(operationObject.operationId || "", "JsonBody"), ); - // if (!requestBodySchema) { - // return { - // name, - // hasQuestionToken: !requestBodyObjectJson.schema.required, - // }; - // } - const type = schemaToType( typesAndInterfaces, jsonRequestBodyObject.schema.required @@ -791,10 +783,6 @@ export async function processOpenApiDocument( type: typeof type.type === "function" ? type.type : String(type.type), }); - - // console.warn("Couldn't find a body type for", requestBodyObjectJson.schema); - - // return undefined; }); const nonJsonBodyEntries = requestBodyObject?.content @@ -835,19 +823,6 @@ export async function processOpenApiDocument( type: "NonNullable", }); - // nonJsonBody.addJsDoc({ - // description: `The body of the request, encoded as ${contentType}`, - // tags: [ - // { - // tagName: 'param', - // text: wordWrap( - // `body {${nonJsonBody.getName()}} ${mediaTypeObj.schema?.description || '' - // }`, - // ).trim(), - // }, - // ], - // }); - return nonJsonBody.getName(); }, ), @@ -858,18 +833,6 @@ export async function processOpenApiDocument( }) : undefined; - // ensureImport(bodyType); - - // const paramsParamName = 'parameters'; - // if (bodyType) { - // jsdoc.addTag({ - // tagName: 'param', - // text: wordWrap( - // `${paramsParamName}.body {${bodyType.getName()}} ${maybeJsDocDescription()}`, - // ).trim(), - // }); - // } - const paramsType = pathParameters.length > 0 ? typesFile.addTypeAlias({ @@ -928,9 +891,9 @@ export async function processOpenApiDocument( ensureImport(bodyType); } - // An array body cannot be intersected with the parameters and still - // be read as either: the array wins and the parameters vanish. It - // goes under `body`, the same way a non-JSON body already does + // An array body intersected with the parameters reads as the array + // alone, and the parameters vanish. It goes under `body`, the same + // way a non-JSON body already does const jsonBodySchema = jsonRequestBodyObject?.schema; const jsonBodyIsArray = !!jsonBodySchema && @@ -986,7 +949,7 @@ export async function processOpenApiDocument( return response.content?.["application/json"]?.schema; }); - // Hook: Generate Valibot validator for operation input + // Generate the valibot validator for the operation input const operationSchemas = createValidatorForOperationInput( validators, valibotFile, @@ -1024,16 +987,13 @@ export async function processOpenApiDocument( .filter((spec) => spec !== undefined), }); - // CommandInput — widen optional fields with `| undefined` at the - // serialization boundary. Outbound payloads are about to be - // JSON.stringified (which drops `undefined`), so callers can pass - // `{ field: undefined }` even under exactOptionalPropertyTypes. - // For non-JSON bodies (octet-stream, multipart, …) the `body` - // field carries a BodyInit class instance (Blob, URLSearchParams, - // ReadableStream, …). UndefinedOnPartialDeep traverses class - // instances and mangles them, so split the input: widen everything - // except `body`, then re-intersect the raw body field - const inputTypeArg = ((): string => { + // Widen optional fields with `| undefined` at the serialization + // boundary. Outbound payloads are JSON.stringified, which drops + // `undefined`, so callers can pass `{ field: undefined }` even + // under exactOptionalPropertyTypes. A non-JSON `body` field holds + // a BodyInit class instance, which UndefinedOnPartialDeep would + // mangle, so widen everything else and re-intersect `body` + const inputTypeArg = (() => { if (!inputType) { return unspecifiedKeyword; } @@ -1052,30 +1012,6 @@ export async function processOpenApiDocument( commandClassDeclaration.getExtends()?.addTypeArgument(inputTypeArg); - // if (queryType && !isVoidKeyword(queryType)) { - // ctor.addParameter({ - // name: 'query', - // type: queryType.getName(), - // }); - // } - - // for (const queryParam of queryParameters) { - // const queryParameterName = camelcase(queryParam.name); - - // jsdoc.addTag({ - // tagName: 'param', - // text: wordWrap( - // `${paramsParamName}.query.${queryParameterName}${ - // queryParam.required ? '' : '?' - // } {String} ${maybeJsDocDescription( - // queryParam.deprecated && 'DEPRECATED', - // queryParam.description, - // String(queryParam.example || ''), - // )}`, - // ).trim(), - // }); - // } - // this is just like a 204 response let hasOutputType = false; @@ -1092,10 +1028,10 @@ export async function processOpenApiDocument( for (const [statusCode, response] of Object.entries({ ...operationObject.responses, }).filter(([s]) => s.startsWith("2"))) { - // The output is one type argument, so the first usable 2xx - // response settles it. Without this an operation documenting both - // a 200 and a 204 adds a second argument, which lands in the - // query slot and is not a query type + // Output is one type argument, so the first usable 2xx response + // settles it. An operation documenting both a 200 and a 204 would + // otherwise add a second argument, which lands in the query slot + // and is not a query type if (hasOutputType) { break; } @@ -1156,10 +1092,10 @@ export async function processOpenApiDocument( ?.addTypeArgument(outputTypeName); hasOutputType = true; - // Handler-return alias for `c.json(...)` on the server side: - // about to be JSON.stringified (drops `undefined`), so the - // optional fields can carry `undefined`. Mirrors the `input*` - // prefix used for the lax variant in valibot.ts + // Handler-return alias for `c.json(...)` on the server side. + // The value is JSON.stringified, which drops `undefined`, so + // optional fields may hold `undefined`. Mirrors the `input*` + // prefix used for the lax variant in the valibot module typesFile.addTypeAlias({ name: pascalCase( "Input", @@ -1169,11 +1105,6 @@ export async function processOpenApiDocument( type: `UndefinedOnPartialDeep<${outputTypeName}>`, isExported: true, }); - - // jsdoc.addTag({ - // tagName: 'returns', - // text: `{${retVal}} HTTP ${statusCode}`, - // }); } else if (jsonResponse.schema) { const outputType = schemaToType( typesAndInterfaces, @@ -1220,13 +1151,12 @@ export async function processOpenApiDocument( ?.addTypeArgument(unspecifiedKeyword); } - // Defer static schema attachment to commands-validated.ts. The lean - // commands.ts file carries no schema imports — body/param/query - // schemas aren't read by rest-client anyway (hono.ts imports - // directly from valibot.ts for server middleware), and the response - // schema lives on the validated subclass. - // Wire variant is what rest-client + hono consume; falls back to - // the input variant under --input-only + // Static schema attachment is deferred to the validated module, so + // the base command module imports zero schemas. rest-client reads + // the response schema from the validated subclass, and the server + // middleware imports body, param and query schemas directly. The + // wire variant is what rest-client and hono consume, falling back + // to the input variant under --input-only const wireSchemas = options?.inputOnly ? operationSchemas.input : operationSchemas.wire; @@ -1451,15 +1381,15 @@ export async function processOpenApiDocument( ], }); - // Re-export the runtime error consumers need for `instanceof` narrowing so - // they don't have to add a direct @block65/rest-client dependency just for it + // Re-export the runtime error consumers need for `instanceof` narrowing, + // sparing them a direct @block65/rest-client dependency for it alone mainFile.addExportDeclaration({ moduleSpecifier: "@block65/rest-client", namedExports: ["ResponseValidationError"], }); - // Cross-client guard: commands from another generated client fail the - // `` constraint on `.json()` + // Commands from another generated client fail the `` + // constraint on `.json()`, which guards against mixing clients const outputUnionMembers = [...outputTypes] .map((t) => (typeof t === "string" ? t : t.getName())) .filter((name): name is string => !!name && name !== unspecifiedKeyword); @@ -1521,12 +1451,6 @@ export async function processOpenApiDocument( }')`, }); - // const fetcherParam = ctor.addParameter({ - // name: 'fetcher', - // // type: fetcherMethodType, - // initializer: `${fetcherName}()`, - // }); - const configParam = ctor.addParameter({ name: "config", type: configType, @@ -1542,24 +1466,19 @@ export async function processOpenApiDocument( // type narrowing if (Node.isCallExpression(callExpr)) { - callExpr?.addArguments([ - baseUrl.getName(), - // fetcherParam.getName() - configParam.getName(), - ]); + callExpr?.addArguments([baseUrl.getName(), configParam.getName()]); } - // Build commands-validated.ts: subclasses attach `static responseSchema`, - // commands with no response schema are re-exported unchanged so the file - // keeps export parity with commands.ts (alias-safe) + // Build the validated module. Subclasses attach `static responseSchema`, + // and commands lacking one are re-exported unchanged so the module keeps + // export parity with the base and stays alias-safe const commandsModuleSpecifier = `./${commandsFile.getBaseNameWithoutExtension()}.js`; if (validatedSubclasses.length > 0) { // Namespace imports keep the generated file compact and stable across - // regenerations: adding or removing a single command leaves the import + // regenerations. Adding or removing a single command leaves the import // list alone. Modern bundlers tree-shake namespace imports correctly - // when source modules are side-effect-free (which valibot.ts and - // commands.ts both are) + // when the source modules are pure, which holds for both const commandsNs = "commands"; const schemasNs = "schemas"; @@ -1622,7 +1541,7 @@ export async function processOpenApiDocument( } } - // Add imports from valibot.ts + // Add the schema imports addSchemaImportsToHonoFile(honoFile, [...schemaImports]); // Generate middleware exports for each operation @@ -1632,9 +1551,9 @@ export async function processOpenApiDocument( honoFile.fixUnusedIdentifiers(); - // `Command` defaults its output to `unknown`, so spelling it out says - // nothing. Only a trailing one can go: an earlier argument still holds the - // position of whatever follows it + // `Command` defaults its output to `unknown`, so an explicit `unknown` + // repeats the default. A trailing argument can go, while an earlier one + // holds the position of the arguments after it for (const commandClass of commandsFile.getClasses()) { const base = commandClass.getExtends(); const typeArguments = base?.getTypeArguments() ?? []; diff --git a/lib/process-schema.ts b/lib/process-schema.ts index 4634d5b..db0c0b6 100644 --- a/lib/process-schema.ts +++ b/lib/process-schema.ts @@ -24,11 +24,8 @@ function maybeWithNullUnion(type: string | WriterFunction, withNull = false) { return withNull && type !== "null" ? Writers.unionType(type, "null") : type; } -// RFC 3339 temporal `format`s as template-literal TypeScript types, so the -// digit shape is visible in the type (strictly narrower than `string`). These -// are shape hints — the runtime valibot schema does the real RFC 3339 -// validation. Kept in lock-step with valibot's `temporalTypeHint` -function temporalStringType(format: string | undefined): string | undefined { +// Template-literal type per RFC 3339 temporal format, mirrored in valibot +function temporalStringType(format: string | undefined) { switch (format) { case "date": // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type @@ -43,14 +40,14 @@ function temporalStringType(format: string | undefined): string | undefined { // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type return "`P${string}`"; default: - return undefined; + return; } } -// int64 carries values beyond Number.MAX_SAFE_INTEGER, so it maps to `bigint` -// as the domain type and `${bigint}` on the wire (query/header/path/body values -// arrive as strings). Every other integer/number stays `number` / `${number}` -function numericType(isInt64: boolean, stringish: boolean | undefined): string { +// int64 maps to bigint, and every other integer or number maps to number +function numericType(isInt64: boolean, stringish: boolean | undefined) { + // int64 reaches past Number.MAX_SAFE_INTEGER, and query, header, path and + // body values arrive as strings, hence the `${bigint}` wire form if (isInt64) { // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type return stringish ? "`${bigint}`" : "bigint"; @@ -71,9 +68,7 @@ function schemaTypeIsNull(schema: oas30.SchemaObject | oas31.SchemaObject) { ); } -// `unknown` swallows a union, `never` adds nothing to one, and the same -// constituent twice is still one type. Only string members can be compared: -// a writer's text is not available until it runs +// Drops `unknown`, `never` and duplicate string members from a union function collapseUnion(types: (string | WriterFunction)[]) { const seen = new Set(); const deduped = types.filter((type) => { @@ -111,7 +106,7 @@ function maybeUnion(...types: (string | WriterFunction)[]) { : Writers.unionType(first, second, ...rest); } -function recordType(value: string | WriterFunction): WriterFunction { +function recordType(value: string | WriterFunction) { return (writer: CodeBlockWriter) => { writer.write("Record !schema.enum); @@ -147,11 +139,11 @@ function literalUnionType( const [base] = bare; if (bare.length !== 1 || enums.length === 0 || base?.type !== "string") { - return undefined; + return; } if (!enums.every((schema) => schema.type === "string")) { - return undefined; + return; } const values = [ @@ -201,7 +193,6 @@ export function schemaToType( const existingSchema = typesAndInterfaces.get(schemaObject.$ref); if (!existingSchema) { - // throw new Error(`ref used before available: ${schemaObject.$ref}`); console.warn("ref used before available: schema=%j", schemaObject); return { @@ -444,7 +435,7 @@ export function schemaToType( const isNullable = schemaTypeIsNull(schemaObject); if (intersect) { - // For allOf: intersect non-null types, wrap in union with null if nullable + // For allOf, intersect the non-null types and add null when nullable const nonNullTypes = filteredTypes.filter((t) => t !== "null"); const intersectionType = maybeIntersection(...nonNullTypes); @@ -458,7 +449,7 @@ export function schemaToType( }; } - // For oneOf/anyOf: union all types (include null if present or if nullable) + // For oneOf and anyOf, union every type, adding null when nullable return { name, hasQuestionToken, @@ -470,8 +461,8 @@ export function schemaToType( }; } - // A document often leaves `type` off a schema that plainly describes an - // object, so the keys that only an object can carry stand in for it + // A document often omits `type` from a schema that plainly describes an + // object, so keys unique to objects stand in for it const describesObject = schemaObject.type === "object" || (schemaObject.type === undefined && @@ -521,8 +512,8 @@ export function schemaToType( typeof schemaObject.additionalProperties === "object" && schemaObject.additionalProperties !== null ) { - // The parent is empty because the value schema keys off nothing in it: - // a record value is always present, so it never takes a question token + // A record value is always present, so it stays required and the + // parent contributes an empty set of keys const value = schemaToType( typesAndInterfaces, {}, @@ -742,7 +733,7 @@ export function registerTypesFromSchema( ...objectTypesFromNonRefSchemas, ...nonObjectTypesFromNonRefSchemas .map((t) => - // a writer's text is not available here, so it cannot be wrapped + // a writer's text is unavailable here, so wrapping applies to strings t.isReadonly && typeof t.type === "string" ? `Readonly<${t.type}>` : t.type, @@ -768,7 +759,7 @@ export function registerTypesFromSchema( typesAndInterfaces.set(`#/components/schemas/${schemaName}`, typeAlias); } - // deal with type arrays (OpenAPI 3.1: type: ["string", "null"]) + // deal with type arrays, added in OpenAPI 3.1 else if (Array.isArray(schemaObject.type)) { const prop = schemaToType(typesAndInterfaces, {}, schemaName, schemaObject); @@ -812,10 +803,9 @@ export function registerTypesFromSchema( const newIf = typesFile.addTypeAlias({ name: pascalCase(schemaName), isExported: true, - // The same walk an inline schema takes, so a named schema and an + // Reuses the walk an inline schema takes, so a named schema and an // inline one of the same shape agree. It also spells the value type - // in TypeScript: the JSON Schema name for it is not always one, and - // `integer` is not + // in TypeScript, since JSON Schema names such as `integer` differ type: schemaToType(typesAndInterfaces, {}, schemaName, schemaObject).type ?? "Record", @@ -840,33 +830,14 @@ export function registerTypesFromSchema( ] : []; - // bonus enum interface for the same set of strings - // handy for looping over the enum values - // const enumDeclaration = typesFile.addEnum({ - // name: pascalCase(schemaName, 'Enum'), - // isExported: true, - // members: schemaObject.enum.map((e: unknown) => ({ - // name: typeof e === 'string' ? pascalCase(e) : String(e), - // value: String(e), - // })), - // }); - const stringUnion = typesFile.addTypeAlias({ name: pascalCase(schemaName), isExported: true, - type: maybeUnion( - // enumDeclaration.getName() - ...schemaObject.enum.map((e) => JSON.stringify(e)), - ), + type: maybeUnion(...schemaObject.enum.map((e) => JSON.stringify(e))), docs, }); typesAndInterfaces.set(`#/components/schemas/${schemaName}`, stringUnion); - - // typesAndInterfaces.set( - // `#/components/schemas/${enumDeclaration.getName()}`, - // enumDeclaration, - // ); } // deal with non-enum strings @@ -979,7 +950,5 @@ export function registerTypesFromSchema( `unsupported ${schemaObject.type} schema object: %j`, schemaObject, ); - - // throw new Error(`unsupported type "${schemaObject.type}"`); } } diff --git a/lib/utils.ts b/lib/utils.ts index bdc7660..bb37c46 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -6,7 +6,7 @@ export function isReferenceObject(obj: unknown): obj is oas31.ReferenceObject { return typeof obj === "object" && obj !== null && "$ref" in obj; } -function getDependency(obj: unknown): string | undefined { +function getDependency(obj: unknown) { return isReferenceObject(obj) ? obj.$ref : undefined; } @@ -20,7 +20,9 @@ export function isNotNullOrUndefined(obj: T | null | undefined): obj is T { return obj !== null && obj !== undefined; } -const strOnly = (x: string | undefined): x is string => typeof x === "string"; +function strOnly(x: string | undefined): x is string { + return typeof x === "string"; +} export function getDependents( obj: oas31.ReferenceObject | oas31.SchemaObject, @@ -79,3 +81,14 @@ export function castToValidJsIdentifier(name: string) { export function iife(fn: () => T): T { return fn(); } + +/** + * `Object.entries` types every key as `string`, and this puts back what `T` + * declares. An index signature on `T` would widen them again, and every caller + * passes a closed object type + */ +export function typedEntries(obj: T) { + // TYPESAFETY: this is the etire point of the function + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + return Object.entries(obj) as [keyof T, T[keyof T]][]; +} diff --git a/lib/valibot.ts b/lib/valibot.ts index 3643aaf..2dc0d9d 100644 --- a/lib/valibot.ts +++ b/lib/valibot.ts @@ -13,16 +13,7 @@ import type { Primitive } from "type-fest"; import type * as v from "valibot"; import { wordWrap } from "./utils.ts"; -// Two variants per type, split by direction of data flow: -// -// input — for outgoing/TS-side values. Uses `v.optional(...)`, so callers can -// pass `{ foo: undefined }` (common in destructure-with-default patterns). -// No wire coercion since TS types are already native. -// -// wire — for incoming JSON-parsed values (server middleware, response -// parsing). Uses `v.exactOptional(...)` — undefined can't appear on the -// wire, so a field is either present-with-a-value or absent. Includes -// bigint / number coercion since JSON & HTTP carry those as strings +// input uses `v.optional` and skips coercion, wire uses `v.exactOptional` type SchemaMode = "input" | "wire"; type ValidatorEntry = { @@ -107,20 +98,12 @@ function minMaxProperties(schema: oas30.SchemaObject | oas31.SchemaObject) { const noTrimFormats = new Set(["uuid", "byte", "binary", "password"]); -// RFC 3339 temporal formats. The regex does the real runtime validation; the -// matching template-literal *type* is carried separately by `temporalHintSchema` -// (a `v.custom<...>`) so the schema's InferOutput equals the template-literal -// type emitted into types.ts by process-schema's `temporalStringType`. Without -// it the schema would infer bare `string`, diverging from the consumer-facing -// type — visible to `v.parse` / hono `c.req.valid()` callers. -// - date-time / time require an offset (`Z` or `±hh:mm`) — RFC 3339 has no -// bare-local form, unlike ISO 8601. -// - the seconds field permits a leap second (`60`). -// - `T`/`Z` may be lower-case and the date/time separator may be a space. -// - `duration` is the ISO 8601 grammar from RFC 3339 Appendix A -function temporalRegexConstraint( - format: string | undefined, -): WriterFunction | undefined { +// RFC 3339 temporal formats, validated by regex at runtime +function temporalRegexConstraint(format: string | undefined) { + // RFC 3339 departs from ISO 8601 in ways these patterns encode. `date-time` + // and `time` require an offset. The seconds field admits a leap second of + // `60`. `T` and `Z` may be lower case, and a space may separate the date + // from the time. `duration` follows the ISO 8601 grammar in Appendix A switch (format) { case "date": return vcall( @@ -147,15 +130,12 @@ function temporalRegexConstraint( JSON.stringify(format), ); default: - return undefined; + return; } } -// Template-literal type for each temporal format. Kept in lock-step with -// process-schema's `temporalStringType` (types.ts is the source of truth for -// consumer-facing types); duplicated rather than shared so neither generator -// has to import the other -function temporalTypeHint(format: string | undefined): string | undefined { +// Template-literal type per temporal format, mirrored in process-schema +function temporalTypeHint(format: string | undefined) { switch (format) { case "date": // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type @@ -170,21 +150,17 @@ function temporalTypeHint(format: string | undefined): string | undefined { // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type return "`P${string}`"; default: - return undefined; + return; } } -// `v.custom(() => true)` narrows the schema's inferred output type -// (the regex already validates), so a direct `v.parse(schema, x)` yields the -// template-literal type rather than bare `string` -function temporalHintSchema(format: string | undefined): string | undefined { +// Narrows the inferred output to the template-literal type, past the regex +function temporalHintSchema(format: string | undefined) { const type = temporalTypeHint(format); return type ? `v.custom<${type}>(() => true)` : undefined; } -function stringNeedsCoercion( - schema: oas30.SchemaObject | oas31.SchemaObject, -): boolean { +function stringNeedsCoercion(schema: oas30.SchemaObject | oas31.SchemaObject) { return ( !schema.enum && !schema.pattern && @@ -194,7 +170,7 @@ function stringNeedsCoercion( function propertiesNeedCoercion( schema: oas30.SchemaObject | oas31.SchemaObject, -): boolean { +) { const properties = schema.properties ?? {}; const required = new Set(schema.required ?? []); const hasOptional = Object.keys(properties).some((k) => !required.has(k)); @@ -236,7 +212,7 @@ function resolveRef( validators: Map, ref: string, mode: SchemaMode, -): string | WriterFunction { +) { const entry = validators.get(ref); if (!entry) { return vcall("unknown"); @@ -262,6 +238,9 @@ function writeStrictObjectEntries( mode: SchemaMode, ) => WriterFunction | string = schemaToValidator, ) { + // input schemas face TS callers, so `v.optional` lets them pass + // `{ foo: undefined }`. wire schemas face JSON-parsed payloads, where + // `undefined` is absent by construction const optionalWrapper = mode === "input" ? "optional" : "exactOptional"; Object.entries(properties).forEach(([name, s]) => { const isRequired = requiredProps.has(name); @@ -311,7 +290,7 @@ function schemaToValidator( ? `v.custom<${typescriptHint}>(() => true)` : undefined; - // Handle const values (OpenAPI 3.1: const: "value") + // Handle const values, added in OpenAPI 3.1 if ("const" in schema) { return schema.const === null ? vcall("null") @@ -321,11 +300,10 @@ function schemaToValidator( ); } - // Enums short-circuit every type-specific constraint. Whatever the declared - // type / format / minLength, the only valid values are the enum members, so - // emit a bare picklist — layering string()/minLength()/regex() on top yields - // a misleading "expected string" / "minLength" error when the real contract - // is simply "must be one of [...]" + // Enums short-circuit every type-specific constraint. Valid values are + // exactly the enum members, under any declared type, format or minLength. + // Layering string() or minLength() on top yields a misleading error about + // the wrong contract, so emit a bare picklist if (schema.enum) { const hasNull = schema.enum.some((value) => value === null); const members = schema.enum.filter((value) => value !== null); @@ -350,8 +328,8 @@ function schemaToValidator( ); } - // Boolean (and any other) literals go through `literal()` instead — or a - // `union` of them when there's more than one + // Boolean and other literals use `literal()` instead, or a `union` of + // them when there is more than one const base = rest.length === 0 ? vcall("literal", JSON.stringify(first)) @@ -363,7 +341,7 @@ function schemaToValidator( return maybeNullable(base, isNullable || hasNull); } - // Handle type arrays (OpenAPI 3.1: type: ["string", "null"]) + // Handle type arrays, added in OpenAPI 3.1 if (Array.isArray(schema.type)) { const nonNullTypes = schema.type.filter((t) => t !== "null"); const [singleType] = nonNullTypes; @@ -422,7 +400,7 @@ function schemaToValidator( schema.pattern ? vcall("regex", `new RegExp(${JSON.stringify(schema.pattern)})`) : undefined, - // An explicit x-typescript-hint wins over the format-derived hint + // A hint set by the `x-typescript-hint` extension wins over the format !typescriptHint ? temporalHintSchema(schema.format) : undefined, typescriptHintSchema, ), @@ -513,11 +491,11 @@ function schemaToValidator( const combinator = schema.oneOf || schema.anyOf || schema.allOf; if (combinator) { - // allOf of object schemas: compose into a single v.strictObject. Inline - // object members contribute their properties directly; $ref members are - // spread via `.entries`. v.intersect of strictObjects is - // unsatisfiable when member property sets differ (each strictObject - // independently rejects keys the others contribute) + // allOf of object schemas composes into a single v.strictObject. Inline + // object members contribute their properties directly, and $ref members + // are spread via `.entries`. v.intersect of strictObjects is + // unsatisfiable when member property sets differ, because each + // strictObject independently rejects keys the others contribute const allOfMembers = schema.allOf; if (allOfMembers) { @@ -559,9 +537,9 @@ function schemaToValidator( return; } - // Nested combinators / unusual shapes: recurse and spread the - // result's entries (valid as long as the recursion yields an - // object-like schema; otherwise GIGO) + // Nested combinators and unusual shapes recurse, spreading the + // result's entries. Valid as long as the recursion yields an + // object-like schema, and GIGO otherwise const validator = schemaToValidator(validators, member, mode); writer.write("..."); @@ -734,10 +712,10 @@ export function registerValidatorFromSchema( ], }); - // Wire schema — unless --input-only (parses incoming JSON: no undefined, - // with bigint/number coercion). Aliases to the input schema when the type - // has no coercion concerns and the only difference would be optional vs - // exactOptional — both are equivalent on JSON-parsed data anyway + // Wire schema, skipped under --input-only. It parses incoming JSON with + // bigint and number coercion. Aliases to the input schema when the type + // lacks coercion concerns and the difference would be optional versus + // exactOptional, which are equivalent on JSON-parsed data if (!inputOnly) { if (schemaNeedsCoercion(schemaObject)) { valibotFile.addVariableStatement({ @@ -765,11 +743,7 @@ export function registerValidatorFromSchema( } } -/** - * Wraps a validator with string-to-native coercion for HTTP params (query/header). - * These always arrive as strings on the wire, so coercion is justified. - * For non-numeric types, returns the validator unchanged - */ +/** Coerces HTTP param strings to native values, leaving other types alone */ function asHttpParamValidator( validatorSchemas: Map, schema: oas30.SchemaObject | oas31.SchemaObject | oas31.ReferenceObject, @@ -778,12 +752,12 @@ function asHttpParamValidator( return resolveRef(validatorSchemas, schema.$ref, "wire"); } - // The members of an object-valued query parameter reach the validator as - // strings for the same reason its scalar siblings do — they are bracket- - // encoded into the query string — so the coercion below has to reach them + // Members of an object-valued query parameter reach the validator as + // strings, for the same reason its scalar siblings do. They are bracket- + // encoded into the query string, so the coercion below has to reach them // too. Recursion stops at a `$ref`, which resolves to the one named schema - // emitted for the whole document and so cannot carry a query-only variant, - // and at shapes whose emission carries more than members and items + // emitted for the whole document and therefore lacks a query-only variant. + // It also stops at shapes that emit more than members and items if ( schema.type === "object" && schema.properties && diff --git a/package.json b/package.json index 10a47e3..f24f487 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@standard-schema/spec": "^1.1.0", "@tsconfig/node24": "^24.0.5", "@tsconfig/strictest": "^2.0.8", - "@types/node": "^26.4.0", + "@types/node": "^26.6.1", "@types/toposort": "^2.0.7", "@types/yargs": "^17.0.35", "hono": "^4.13.8", @@ -43,7 +43,7 @@ "oxfmt": "^0.68.0", "oxlint": "^1.83.0", "oxlint-tsgolint": "^7.0.2001", - "type-fest": "^5.9.0", + "type-fest": "^5.10.0", "typescript": "^7.0.2", "undici": "^8.10.2", "valibot": "^1.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c732de5..1461ae1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,8 +50,8 @@ importers: specifier: ^2.0.8 version: 2.0.8 '@types/node': - specifier: ^26.4.0 - version: 26.4.0 + specifier: ^26.6.1 + version: 26.6.1 '@types/toposort': specifier: ^2.0.7 version: 2.0.7 @@ -77,8 +77,8 @@ importers: specifier: npm:@block65/oxlint-tsgolint@>=7.0.2001 version: '@block65/oxlint-tsgolint@7.0.2001' type-fest: - specifier: ^5.9.0 - version: 5.9.0 + specifier: ^5.10.0 + version: 5.10.0 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -90,10 +90,10 @@ importers: version: 1.5.0(typescript@7.0.2) vite: specifier: ^8.3.0 - version: 8.3.0(@types/node@26.4.0)(yaml@2.9.1) + version: 8.3.0(@types/node@26.6.1)(yaml@2.9.1) vitest: specifier: ^5.0.1 - version: 5.0.1(@types/node@26.4.0)(vite@8.3.0(@types/node@26.4.0)(yaml@2.9.1)) + version: 5.0.1(@types/node@26.6.1)(vite@8.3.0(@types/node@26.6.1)(yaml@2.9.1)) packages: @@ -207,8 +207,8 @@ packages: resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/css-tree@4.1.0': - resolution: {integrity: sha512-cg0ohyrAG3swyGqt8t1K/OK97DqBw/ftDvlvyY1fmEst5B40UOmsimwLENq74z2dyw5CDM+3zJIW+CV2nFNDdA==} + '@eslint/css-tree@4.1.1': + resolution: {integrity: sha512-A+s89eP+yDAPkKjnIOfjlkGifg5J7GPn01nWx14UjBVWktfkxOU4975F9ZZ13xNxkGiblNcBaAqgKhnQrW6SWA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/object-schema@3.0.5': @@ -258,8 +258,8 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@oxc-project/types@0.149.0': - resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} + '@oxc-project/types@0.150.0': + resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} '@oxfmt/binding-android-arm-eabi@0.68.0': resolution: {integrity: sha512-dhfYPbzv/h9JgHjNkl2R6sOjUfxDyLGOZVb3g8/ScaTNwwJcYgmHh8kcYFDUhinuy1QAoANCWUvw1jlk+z6gAg==} @@ -383,98 +383,98 @@ packages: cpu: [x64] os: [win32] - '@rolldown/binding-android-arm-eabi@1.2.8': - resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} + '@rolldown/binding-android-arm-eabi@1.2.9': + resolution: {integrity: sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.8': - resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} + '@rolldown/binding-android-arm64@1.2.9': + resolution: {integrity: sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.8': - resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} + '@rolldown/binding-darwin-arm64@1.2.9': + resolution: {integrity: sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.8': - resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} + '@rolldown/binding-darwin-x64@1.2.9': + resolution: {integrity: sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.8': - resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} + '@rolldown/binding-freebsd-x64@1.2.9': + resolution: {integrity: sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.8': - resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + resolution: {integrity: sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.8': - resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} + '@rolldown/binding-linux-arm64-gnu@1.2.9': + resolution: {integrity: sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.8': - resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} + '@rolldown/binding-linux-arm64-musl@1.2.9': + resolution: {integrity: sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.8': - resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + resolution: {integrity: sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.8': - resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} + '@rolldown/binding-linux-s390x-gnu@1.2.9': + resolution: {integrity: sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.8': - resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} + '@rolldown/binding-linux-x64-gnu@1.2.9': + resolution: {integrity: sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.8': - resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} + '@rolldown/binding-linux-x64-musl@1.2.9': + resolution: {integrity: sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.8': - resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} + '@rolldown/binding-openharmony-arm64@1.2.9': + resolution: {integrity: sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.8': - resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} + '@rolldown/binding-win32-arm64-msvc@1.2.9': + resolution: {integrity: sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.8': - resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} + '@rolldown/binding-win32-x64-msvc@1.2.9': + resolution: {integrity: sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -509,8 +509,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@26.4.0': - resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} + '@types/node@26.6.1': + resolution: {integrity: sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==} '@types/toposort@2.0.7': resolution: {integrity: sha512-sQNk65vbC36+UixCkcky+dCr7MlflHcVILg1FVGqlUntsLFv9xd9ToWIVko/gTuin+cVe16t+2YubEFkhnSuPQ==} @@ -687,8 +687,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.24: - resolution: {integrity: sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==} + baseline-browser-mapping@2.11.25: + resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} engines: {node: '>=6.0.0'} hasBin: true @@ -701,8 +701,8 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - builtin-modules@5.3.0: - resolution: {integrity: sha512-hMQUl2bUFG339QygPM97E+mc8OY1IAchORZxm4a/frcYwKzozMzRVDBwHW0NjOqGElLm2O37AVQE8ikxlZHrMQ==} + builtin-modules@5.4.0: + resolution: {integrity: sha512-JCWSCdun+4Ovd9BhM/Vtlmwb7oD6Wl4YiPRXXwhAuwlPhW1un/MKgXn7GZoFm53ginnoNzus69V8JWx0K393jg==} engines: {node: '>=18.20'} cacheable@2.5.0: @@ -765,8 +765,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - electron-to-chromium@1.5.430: - resolution: {integrity: sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==} + electron-to-chromium@1.5.431: + resolution: {integrity: sha512-AAVihz2YwJeOdAynX8MUtqpvjY0gaiARp/7+r4kwqgzqXMrfG1qDN7vwT80uxAoOQRPcZH8t73EI+CA/1OJN8A==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -885,8 +885,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.6.0: - resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} engines: {node: '>=18'} glob-parent@6.0.2: @@ -1052,8 +1052,8 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - mdn-data@2.34.0: - resolution: {integrity: sha512-OgIlLv0NxJKVW4GTSAoEgpRGd4F2XCqGinK0MsMlBCCS/Zcm2/LsbercNWNA7PeMMcjl75NnI97eqyo7zkdxWA==} + mdn-data@2.35.0: + resolution: {integrity: sha512-k+1+dEIm2z/BEfdvYzT/fIyAGeDBo/1AJNJI4ilFXJRYoxP/3Ds+GP8shAOIq8jYXY9N5nGB3Ag104NsagmLAA==} minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} @@ -1070,8 +1070,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - node-releases@2.0.55: - resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==} + node-releases@2.0.56: + resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==} engines: {node: '>=18'} non-error@0.1.0: @@ -1164,8 +1164,8 @@ packages: resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} engines: {node: '>=18'} - rolldown@1.2.8: - resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} + rolldown@1.2.9: + resolution: {integrity: sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1245,8 +1245,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@5.9.0: - resolution: {integrity: sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==} + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} engines: {node: '>=20'} typescript@7.0.2: @@ -1254,8 +1254,8 @@ packages: engines: {node: '>=16.20.0'} hasBin: true - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} undici@8.10.2: resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} @@ -1497,9 +1497,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/css-tree@4.1.0': + '@eslint/css-tree@4.1.1': dependencies: - mdn-data: 2.34.0 + mdn-data: 2.35.0 source-map-js: 1.2.1 '@eslint/object-schema@3.0.5': {} @@ -1542,7 +1542,7 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@oxc-project/types@0.149.0': {} + '@oxc-project/types@0.150.0': {} '@oxfmt/binding-android-arm-eabi@0.68.0': optional: true @@ -1601,49 +1601,49 @@ snapshots: '@oxfmt/binding-win32-x64-msvc@0.68.0': optional: true - '@rolldown/binding-android-arm-eabi@1.2.8': + '@rolldown/binding-android-arm-eabi@1.2.9': optional: true - '@rolldown/binding-android-arm64@1.2.8': + '@rolldown/binding-android-arm64@1.2.9': optional: true - '@rolldown/binding-darwin-arm64@1.2.8': + '@rolldown/binding-darwin-arm64@1.2.9': optional: true - '@rolldown/binding-darwin-x64@1.2.8': + '@rolldown/binding-darwin-x64@1.2.9': optional: true - '@rolldown/binding-freebsd-x64@1.2.8': + '@rolldown/binding-freebsd-x64@1.2.9': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.8': + '@rolldown/binding-linux-arm64-gnu@1.2.9': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.8': + '@rolldown/binding-linux-arm64-musl@1.2.9': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.8': + '@rolldown/binding-linux-ppc64-gnu@1.2.9': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.8': + '@rolldown/binding-linux-s390x-gnu@1.2.9': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.8': + '@rolldown/binding-linux-x64-gnu@1.2.9': optional: true - '@rolldown/binding-linux-x64-musl@1.2.8': + '@rolldown/binding-linux-x64-musl@1.2.9': optional: true - '@rolldown/binding-openharmony-arm64@1.2.8': + '@rolldown/binding-openharmony-arm64@1.2.9': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.8': + '@rolldown/binding-win32-arm64-msvc@1.2.9': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.8': + '@rolldown/binding-win32-x64-msvc@1.2.9': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -1673,9 +1673,9 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@26.4.0': + '@types/node@26.6.1': dependencies: - undici-types: 8.3.0 + undici-types: 8.9.0 '@types/toposort@2.0.7': {} @@ -1745,14 +1745,14 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/mocker@5.0.1(vite@8.3.0(@types/node@26.4.0)(yaml@2.9.1))': + '@vitest/mocker@5.0.1(vite@8.3.0(@types/node@26.6.1)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.1 estree-walker: 3.0.3 magic-string: 1.4.1 optionalDependencies: - vite: 8.3.0(@types/node@26.4.0)(yaml@2.9.1) + vite: 8.3.0(@types/node@26.6.1)(yaml@2.9.1) '@vitest/spy@5.0.1': {} @@ -1779,7 +1779,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.24: {} + baseline-browser-mapping@2.11.25: {} brace-expansion@5.0.12: dependencies: @@ -1787,13 +1787,13 @@ snapshots: browserslist@4.29.0: dependencies: - baseline-browser-mapping: 2.11.24 + baseline-browser-mapping: 2.11.25 caniuse-lite: 1.0.30001810 - electron-to-chromium: 1.5.430 - node-releases: 2.0.55 + electron-to-chromium: 1.5.431 + node-releases: 2.0.56 update-browserslist-db: 1.3.3(browserslist@4.29.0) - builtin-modules@5.3.0: {} + builtin-modules@5.4.0: {} cacheable@2.5.0: dependencies: @@ -1846,7 +1846,7 @@ snapshots: detect-libc@2.1.2: {} - electron-to-chromium@1.5.430: {} + electron-to-chromium@1.5.431: {} emoji-regex@10.6.0: {} @@ -1861,7 +1861,7 @@ snapshots: eslint-plugin-unicorn@75.0.0(eslint@10.10.0): dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0) - '@eslint/css-tree': 4.1.0 + '@eslint/css-tree': 4.1.1 browserslist: 4.29.0 change-case: 5.4.4 ci-info: 4.4.0 @@ -1986,7 +1986,7 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.6.0: {} + get-east-asian-width@1.7.0: {} glob-parent@6.0.2: dependencies: @@ -2016,7 +2016,7 @@ snapshots: is-builtin-module@5.0.0: dependencies: - builtin-modules: 5.3.0 + builtin-modules: 5.4.0 is-extglob@2.1.1: {} @@ -2106,7 +2106,7 @@ snapshots: mdn-data@2.27.1: {} - mdn-data@2.34.0: {} + mdn-data@2.35.0: {} minimatch@10.2.6: dependencies: @@ -2118,7 +2118,7 @@ snapshots: natural-compare@1.4.0: {} - node-releases@2.0.55: {} + node-releases@2.0.56: {} non-error@0.1.0: {} @@ -2207,33 +2207,33 @@ snapshots: reserved-identifiers@1.2.0: {} - rolldown@1.2.8: + rolldown@1.2.9: dependencies: - '@oxc-project/types': 0.149.0 + '@oxc-project/types': 0.150.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.8 - '@rolldown/binding-android-arm64': 1.2.8 - '@rolldown/binding-darwin-arm64': 1.2.8 - '@rolldown/binding-darwin-x64': 1.2.8 - '@rolldown/binding-freebsd-x64': 1.2.8 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.8 - '@rolldown/binding-linux-arm64-gnu': 1.2.8 - '@rolldown/binding-linux-arm64-musl': 1.2.8 - '@rolldown/binding-linux-ppc64-gnu': 1.2.8 - '@rolldown/binding-linux-s390x-gnu': 1.2.8 - '@rolldown/binding-linux-x64-gnu': 1.2.8 - '@rolldown/binding-linux-x64-musl': 1.2.8 - '@rolldown/binding-openharmony-arm64': 1.2.8 - '@rolldown/binding-win32-arm64-msvc': 1.2.8 - '@rolldown/binding-win32-x64-msvc': 1.2.8 + '@rolldown/binding-android-arm-eabi': 1.2.9 + '@rolldown/binding-android-arm64': 1.2.9 + '@rolldown/binding-darwin-arm64': 1.2.9 + '@rolldown/binding-darwin-x64': 1.2.9 + '@rolldown/binding-freebsd-x64': 1.2.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.9 + '@rolldown/binding-linux-arm64-gnu': 1.2.9 + '@rolldown/binding-linux-arm64-musl': 1.2.9 + '@rolldown/binding-linux-ppc64-gnu': 1.2.9 + '@rolldown/binding-linux-s390x-gnu': 1.2.9 + '@rolldown/binding-linux-x64-gnu': 1.2.9 + '@rolldown/binding-linux-x64-musl': 1.2.9 + '@rolldown/binding-openharmony-arm64': 1.2.9 + '@rolldown/binding-win32-arm64-msvc': 1.2.9 + '@rolldown/binding-win32-x64-msvc': 1.2.9 semver@7.8.5: {} serialize-error@13.0.1: dependencies: non-error: 0.1.0 - type-fest: 5.9.0 + type-fest: 5.10.0 shebang-command@2.0.0: dependencies: @@ -2252,12 +2252,12 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.6.0 + get-east-asian-width: 1.7.0 strip-ansi: 7.2.0 string-width@8.2.2: dependencies: - get-east-asian-width: 1.6.0 + get-east-asian-width: 1.7.0 strip-ansi: 7.2.0 strip-ansi@7.2.0: @@ -2290,7 +2290,7 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@5.9.0: + type-fest@5.10.0: dependencies: tagged-tag: 1.0.0 @@ -2317,7 +2317,7 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 - undici-types@8.3.0: {} + undici-types@8.9.0: {} undici@8.10.2: {} @@ -2335,22 +2335,22 @@ snapshots: optionalDependencies: typescript: 7.0.2 - vite@8.3.0(@types/node@26.4.0)(yaml@2.9.1): + vite@8.3.0(@types/node@26.6.1)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 postcss: 8.5.28 - rolldown: 1.2.8 + rolldown: 1.2.9 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.4.0 + '@types/node': 26.6.1 fsevents: 2.3.3 yaml: 2.9.1 - vitest@5.0.1(@types/node@26.4.0)(vite@8.3.0(@types/node@26.4.0)(yaml@2.9.1)): + vitest@5.0.1(@types/node@26.6.1)(vite@8.3.0(@types/node@26.6.1)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@26.4.0)(yaml@2.9.1)) + '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@26.6.1)(yaml@2.9.1)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -2361,10 +2361,10 @@ snapshots: tinybench: 6.1.4 tinyexec: 1.3.0 tinyglobby: 0.2.17 - vite: 8.3.0(@types/node@26.4.0)(yaml@2.9.1) + vite: 8.3.0(@types/node@26.6.1)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.4.0 + '@types/node': 26.6.1 transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ce2fa27..a64964d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,7 @@ +minimumReleaseAgeExcludePrune: true minimumReleaseAgeExclude: - - "@block65/rest-client" - - '@block65/oxlint-plugin@0.6.0' - - '@block65/shared-config@0.3.1' + - "@block65/oxlint-plugin@0.6.0" + - "@block65/shared-config@0.3.1" overrides: oxlint: npm:@block65/oxlint@>=1.82.0 diff --git a/vitest.config.ts b/vitest.config.ts index e4095b3..d6e59b9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,8 +2,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - // no timeouts: a wrong mock should fail loudly on assertion or hang - // visibly, not get masked by a timer racing retry backoff + // a wrong mock should fail loudly on assertion or hang visibly, where a + // timer racing retry backoff would mask it testTimeout: 0, hookTimeout: 0, },