diff --git a/README.md b/README.md index 188be4d..ea565ff 100644 --- a/README.md +++ b/README.md @@ -67,20 +67,30 @@ whose query parameters need two of them stops generation. ## Linting generated output The generated JSDoc is transcribed from the OpenAPI document, so the prose is -the document author's and the comment rules judge it unfairly. The package -ships an override for that, scoped to wherever the output lives: +the document author's and the comment rules judge it unfairly. Query parameter +names, schema nesting and the optionality of inputs are the document's contract +too. -```ts -import { defineConfig } from "@block65/shared-config/oxlint"; -import * as codegen from "@block65/openapi-codegen/oxlint"; +After writing, the generator runs your project's own oxlint over the output, +with your config. Each file gets a directive that turns off only the rules on +that exemption list which fired in it: -export default defineConfig({ - overrides: [...codegen.defineOverrides("src/generated/*")], -}); +```ts +// oxlint-disable block65/no-narrative-comment, block65/prefer-exact-optional ``` -It turns off comment rules only. Anything else the generated output trips is a -bug in the generator, so report it rather than adding it to the override. +An object schema the document leaves open is never exempt: it stays a lint +error until the document sets `additionalProperties: false`. A header schema is +the exception, because a request carries headers the document does not name. +Where the rule fires on one, the generator brackets that declaration with a +disable and enable naming the reason. + +No lint config is needed for generated code. Anything else the output trips is +a bug in the generator, so report it rather than turning the rule off. + +Every run relints every file, so the directives follow your current lint +config. Without oxlint in the project, or with a config that fails to load, +the files get no directive. ## Object strictness comes from the document diff --git a/__tests__/codegen-regressions.test.ts b/__tests__/codegen-regressions.test.ts index 774b2f7..034e90d 100644 --- a/__tests__/codegen-regressions.test.ts +++ b/__tests__/codegen-regressions.test.ts @@ -1,7 +1,5 @@ -import path from "node:path"; import type { oas31 } from "openapi3-ts"; import { assert, expect, test } from "vitest"; -import { generatedFiles } from "../lib/oxlint.ts"; import { processOpenApiDocument } from "../lib/process-document.ts"; const respOk = { @@ -182,21 +180,6 @@ test("AllInputs union carries every command that takes an input", async () => { expect(carried).toEqual([]); }); -// `generatedFiles` limits the shipped lint override, so an emitted module -// absent from that list would lint unscoped at every consumer -test("the shipped lint override names every file the generator emits", async () => { - const result = await processOpenApiDocument( - "/tmp/generated-file-set", - docWithSchema("Thing", { type: "object", properties: {} }), - ); - - const emitted = Object.values(result) - .map((file) => path.basename(file.getFilePath())) - .toSorted(); - - expect(emitted).toStrictEqual([...generatedFiles].toSorted()); -}); - // An empty schema permits any value, so the keys outside `properties` are // unconstrained and the object is loose test("additionalProperties chooses the object schema", async () => { diff --git a/__tests__/fixtures/docker/.openapi-codegen-manifest.json b/__tests__/fixtures/docker/.openapi-codegen-manifest.json index 89d70c1..5bb2570 100644 --- a/__tests__/fixtures/docker/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/docker/.openapi-codegen-manifest.json @@ -1,10 +1,10 @@ { - "#generator": "d9c25059b8f56e84cd41d51232262509", - "commands.ts": "20f63fd7ad412eb1f03ae5cfc4b81df9", - "types.ts": "72a44fac13cd4872db19c1a64b11a6ea", + "#generator": "25c3995293e37099b578d8abd616d258", + "commands.ts": "137aa1b06856ca44a0c780958480af81", + "types.ts": "6b85d6261f52a556a0dc6a584b3da2db", "main.ts": "0f596fab7f6e9bb140fcd133caccb38d", - "valibot.ts": "822492c19be03028e145c1819bc12f83", - "hono.ts": "8cc50e47f0a5be0ec4b87f3229cb653e", + "valibot.ts": "2c62b6192664e3a42e1b44700d430df7", + "hono.ts": "4dee43760e6f999bf0da89d4c063bcd4", "commands-validated.ts": "c6066f3a4ead3dadfad70443e923a265", - "enums.ts": "fc732c1471b27b2a8fa327ed82a0a6c6" + "enums.ts": "6d2420ab590db68349e2e8463a1b026d" } diff --git a/__tests__/fixtures/docker/commands.ts b/__tests__/fixtures/docker/commands.ts index 4c5658c..71656b1 100644 --- a/__tests__/fixtures/docker/commands.ts +++ b/__tests__/fixtures/docker/commands.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/max-comment-lines, block65/no-comment-history, block65/no-comment-list, block65/no-comment-punctuation, block65/no-narrative-comment, block65/no-negated-comment, block65/no-padded-comment, block65/no-single-character-declaration, unicorn-unported/comment-content + import { Command, stripUndefined, diff --git a/__tests__/fixtures/docker/enums.ts b/__tests__/fixtures/docker/enums.ts index 76acbf8..58bcd14 100644 --- a/__tests__/fixtures/docker/enums.ts +++ b/__tests__/fixtures/docker/enums.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/no-comment-list, block65/no-comment-punctuation, block65/no-narrative-comment, block65/no-trailing-comment-punctuation + /** * Kind of change * diff --git a/__tests__/fixtures/docker/hono.ts b/__tests__/fixtures/docker/hono.ts index dd4a1e2..4eaa526 100644 --- a/__tests__/fixtures/docker/hono.ts +++ b/__tests__/fixtures/docker/hono.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/declaration-comments, block65/no-comment-history, block65/no-narrative-comment, block65/no-trailing-comment-punctuation + import { PublicValidationError } from "@block65/rest-client"; import { sValidator } from "@hono/standard-validator"; import type { ValidationTargets } from "hono"; diff --git a/__tests__/fixtures/docker/types.ts b/__tests__/fixtures/docker/types.ts index ae6b765..180b553 100644 --- a/__tests__/fixtures/docker/types.ts +++ b/__tests__/fixtures/docker/types.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/max-comment-lines, block65/no-absence-comment, block65/no-comment-history, block65/no-comment-list, block65/no-comment-overclaim, block65/no-comment-punctuation, block65/no-figurative-comment, block65/no-narrative-comment, block65/no-negated-comment, block65/no-padded-comment, block65/no-trailing-comment-punctuation, unicorn-unported/comment-content + import type { Jsonifiable, UndefinedOnPartialDeep } from "type-fest"; /** An open port on a container */ diff --git a/__tests__/fixtures/docker/valibot.ts b/__tests__/fixtures/docker/valibot.ts index 08c8efc..9d858aa 100644 --- a/__tests__/fixtures/docker/valibot.ts +++ b/__tests__/fixtures/docker/valibot.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/max-comment-lines, block65/no-absence-comment, block65/no-assumption-comment, block65/no-banned-comment-words, block65/no-comment-history, block65/no-comment-list, block65/no-comment-overclaim, block65/no-comment-punctuation, block65/no-figurative-comment, block65/no-narrative-comment, block65/no-negated-comment, block65/no-padded-comment, block65/no-trailing-comment-punctuation, block65/prefer-exact-optional, unicorn-unported/comment-content + import * as v from "valibot"; /** diff --git a/__tests__/fixtures/openai/.openapi-codegen-manifest.json b/__tests__/fixtures/openai/.openapi-codegen-manifest.json index 3b3664a..a2bb01a 100644 --- a/__tests__/fixtures/openai/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/openai/.openapi-codegen-manifest.json @@ -1,10 +1,10 @@ { - "#generator": "d9c25059b8f56e84cd41d51232262509", - "commands.ts": "7d2187eb106fc582735b22176033ea28", - "types.ts": "e2ca3f6a2e1e1a382f4d11f187b341df", + "#generator": "25c3995293e37099b578d8abd616d258", + "commands.ts": "82d7434078d4f7ba525c36db35c4c33b", + "types.ts": "605fb801d1203d2e523cbcb63cb8d0e6", "main.ts": "5ba91c2efb44e3e5c5bd1e1a1b90bb51", - "valibot.ts": "1ce0f05f51117be27a3719d479c82790", - "hono.ts": "f5768dde31ada252b89074452fe2f549", + "valibot.ts": "9d670c8ddc0f9bb81a5dd1a80efd5eee", + "hono.ts": "54130437a23164b228769eb3ec967e4e", "commands-validated.ts": "e4929484f6a64b784ff7b03327f58a3a", - "enums.ts": "350bddfda5b5eb357bbf6cff60f1808f" + "enums.ts": "87a5ccd7e57cc937104b2d256e8d67b1" } diff --git a/__tests__/fixtures/openai/commands.ts b/__tests__/fixtures/openai/commands.ts index 1b97501..995eb42 100644 --- a/__tests__/fixtures/openai/commands.ts +++ b/__tests__/fixtures/openai/commands.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/max-comment-lines, block65/no-comment-punctuation, block65/no-narrative-comment, block65/no-trailing-comment-punctuation + import { Command, stripUndefined, jsonStringify } from "@block65/rest-client"; import type { Except, UndefinedOnPartialDeep } from "type-fest"; import type { diff --git a/__tests__/fixtures/openai/enums.ts b/__tests__/fixtures/openai/enums.ts index 864ca2c..51658d7 100644 --- a/__tests__/fixtures/openai/enums.ts +++ b/__tests__/fixtures/openai/enums.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/no-comment-punctuation, block65/no-narrative-comment, block65/no-trailing-comment-punctuation + /** The role of the author of a message */ export const chatCompletionRole = [ "system", diff --git a/__tests__/fixtures/openai/hono.ts b/__tests__/fixtures/openai/hono.ts index 89538ca..b4147bf 100644 --- a/__tests__/fixtures/openai/hono.ts +++ b/__tests__/fixtures/openai/hono.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/declaration-comments, block65/no-comment-history, block65/no-narrative-comment, block65/no-trailing-comment-punctuation + import { PublicValidationError } from "@block65/rest-client"; import { sValidator } from "@hono/standard-validator"; import type { ValidationTargets } from "hono"; diff --git a/__tests__/fixtures/openai/types.ts b/__tests__/fixtures/openai/types.ts index 4a8663b..46ea6fa 100644 --- a/__tests__/fixtures/openai/types.ts +++ b/__tests__/fixtures/openai/types.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/max-comment-lines, block65/no-absence-comment, block65/no-comment-list, block65/no-comment-punctuation, block65/no-figurative-comment, block65/no-hedging-comment, block65/no-jargon-comment, block65/no-narrative-comment, block65/no-negated-comment, block65/no-padded-comment, block65/no-trailing-comment-punctuation, unicorn-unported/comment-content + import type { Jsonifiable, LiteralUnion, diff --git a/__tests__/fixtures/openai/valibot.ts b/__tests__/fixtures/openai/valibot.ts index b1f198f..a68fffc 100644 --- a/__tests__/fixtures/openai/valibot.ts +++ b/__tests__/fixtures/openai/valibot.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/max-comment-lines, block65/no-absence-comment, block65/no-comment-list, block65/no-comment-punctuation, block65/no-figurative-comment, block65/no-hedging-comment, block65/no-jargon-comment, block65/no-narrative-comment, block65/no-negated-comment, block65/no-padded-comment, block65/no-trailing-comment-punctuation, block65/prefer-exact-optional, unicorn-unported/comment-content + import * as v from "valibot"; export const inputDeleteModelResponseSchema = v.looseObject({ diff --git a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json index 36c5336..2717dd8 100644 --- a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json @@ -1,9 +1,9 @@ { - "#generator": "d9c25059b8f56e84cd41d51232262509", - "commands.ts": "608af748764e3adf1fd212532dfabd10", + "#generator": "25c3995293e37099b578d8abd616d258", + "commands.ts": "69c6a9f2924568fc08fd489508c9df34", "types.ts": "ea65c3e67352d4e22b97af80085727b4", "main.ts": "64edb526dcbcbd345e631ccff959f11d", - "valibot.ts": "e33769c41dc720ddafa6a22cd1c09dc2", - "hono.ts": "cea6c9d1b1305028c67d24cddfaa4f30", + "valibot.ts": "2383058357d21e704546455f4e073d67", + "hono.ts": "be7061d9a002b7d6f42bbdfc53727ec7", "commands-validated.ts": "90b52a2210f1b9cc0e123225210fe74a" } diff --git a/__tests__/fixtures/petstore/commands.ts b/__tests__/fixtures/petstore/commands.ts index 7e6e19f..719a191 100644 --- a/__tests__/fixtures/petstore/commands.ts +++ b/__tests__/fixtures/petstore/commands.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/max-comment-lines, block65/no-narrative-comment, block65/no-trailing-comment-punctuation + import { Command, stripUndefined, jsonStringify } from "@block65/rest-client"; import type { UndefinedOnPartialDeep } from "type-fest"; import type { diff --git a/__tests__/fixtures/petstore/hono.ts b/__tests__/fixtures/petstore/hono.ts index ee82f50..033222b 100644 --- a/__tests__/fixtures/petstore/hono.ts +++ b/__tests__/fixtures/petstore/hono.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/declaration-comments, block65/no-comment-history, block65/no-narrative-comment, block65/no-trailing-comment-punctuation + import { PublicValidationError } from "@block65/rest-client"; import { sValidator } from "@hono/standard-validator"; import type { ValidationTargets } from "hono"; diff --git a/__tests__/fixtures/petstore/valibot.ts b/__tests__/fixtures/petstore/valibot.ts index 39bb276..4971f65 100644 --- a/__tests__/fixtures/petstore/valibot.ts +++ b/__tests__/fixtures/petstore/valibot.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/prefer-exact-optional + import * as v from "valibot"; export const inputErrorSchema = v.looseObject({ diff --git a/__tests__/fixtures/test1.json b/__tests__/fixtures/test1.json index 95d9295..0f073a0 100644 --- a/__tests__/fixtures/test1.json +++ b/__tests__/fixtures/test1.json @@ -93,7 +93,10 @@ }, "EventSource": { "oneOf": [ - { "type": "string", "enum": ["native"] }, + { + "type": "string", + "enum": ["native"] + }, { "type": "string", "pattern": "^https://embed\\.example\\.com/.+$", @@ -140,7 +143,8 @@ } } } - } + }, + "additionalProperties": false }, "LongRunningOperationSuccess": { "type": "object", @@ -272,7 +276,8 @@ } } } - ] + ], + "additionalProperties": false }, "BillingAccountCreateRequest": { "type": "object", @@ -491,7 +496,8 @@ } } } - ] + ], + "additionalProperties": false }, "UpdatePaymentMethodRequest": { "type": "object", @@ -668,7 +674,8 @@ } } } - ] + ], + "additionalProperties": false }, "CreateBillingSubscriptionRequest": { "type": "object", diff --git a/__tests__/fixtures/test1/.openapi-codegen-manifest.json b/__tests__/fixtures/test1/.openapi-codegen-manifest.json index 2366456..b1d5c83 100644 --- a/__tests__/fixtures/test1/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/test1/.openapi-codegen-manifest.json @@ -1,10 +1,10 @@ { - "#generator": "d9c25059b8f56e84cd41d51232262509", + "#generator": "25c3995293e37099b578d8abd616d258", "commands.ts": "19fd590dfc5cc8616a70dcf78326c027", - "types.ts": "a5a7fef55f948f68f724f7e50ba282ca", + "types.ts": "2fa39af3c41c92051312d9ba51ee2a5a", "main.ts": "1e2091a697e1aa9d8b770d777c172bf8", - "valibot.ts": "70182c02f4c6ba0d6d3574c0bb1227d1", - "hono.ts": "f303b24e8f1d5b1e9c168fdb913d3636", + "valibot.ts": "d7e166b84040d92d2eefc8b2f986e5a1", + "hono.ts": "26885b9a53e8c0343a2fdb21f4eb8a37", "commands-validated.ts": "cc5f84090799e163e229aabd160311f8", "enums.ts": "26ade5ccad3bf30118ce8691b99b7c91" } diff --git a/__tests__/fixtures/test1/hono.ts b/__tests__/fixtures/test1/hono.ts index abf4195..73e6b9f 100644 --- a/__tests__/fixtures/test1/hono.ts +++ b/__tests__/fixtures/test1/hono.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/declaration-comments, block65/no-comment-history, block65/no-narrative-comment, block65/no-trailing-comment-punctuation + import { PublicValidationError } from "@block65/rest-client"; import { sValidator } from "@hono/standard-validator"; import type { ValidationTargets } from "hono"; diff --git a/__tests__/fixtures/test1/types.ts b/__tests__/fixtures/test1/types.ts index fcdcac0..fab8006 100644 --- a/__tests__/fixtures/test1/types.ts +++ b/__tests__/fixtures/test1/types.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/no-narrative-comment + import type { Jsonifiable, UndefinedOnPartialDeep } from "type-fest"; export type PromoCode = string; diff --git a/__tests__/fixtures/test1/valibot.ts b/__tests__/fixtures/test1/valibot.ts index 1ee3151..438f1c1 100644 --- a/__tests__/fixtures/test1/valibot.ts +++ b/__tests__/fixtures/test1/valibot.ts @@ -4,6 +4,8 @@ * Do not edit directly */ +// oxlint-disable block65/no-narrative-comment, block65/prefer-exact-optional + import * as v from "valibot"; export const inputPromoCodeSchema = v.pipe( @@ -112,7 +114,7 @@ export const inputBillingSubscriptionIdentifiersSchema = v.strictObject({ }); export const billingSubscriptionIdentifiersSchema = inputBillingSubscriptionIdentifiersSchema; -export const inputBillingSubscriptionSchema = v.looseObject({ +export const inputBillingSubscriptionSchema = v.strictObject({ ...inputBillingSubscriptionIdentifiersSchema.entries, accountId: v.optional(inputIdSchema), planSku: inputPlanSkuSchema, @@ -123,7 +125,7 @@ export const inputBillingSubscriptionSchema = v.looseObject({ createdTime: inputDateTimeSchema, updatedTime: v.optional(inputDateTimeSchema), }); -export const billingSubscriptionSchema = v.looseObject({ +export const billingSubscriptionSchema = v.strictObject({ ...billingSubscriptionIdentifiersSchema.entries, accountId: v.exactOptional(idSchema), planSku: planSkuSchema, @@ -140,7 +142,7 @@ export const inputBillingSubscriptionsSchema = v.array( export const billingSubscriptionsSchema = inputBillingSubscriptionsSchema; export const inputUuidSchema = v.pipe(v.string(), v.uuid()); export const uuidSchema = inputUuidSchema; -export const inputLongRunningOperationFailSchema = v.looseObject({ +export const inputLongRunningOperationFailSchema = v.strictObject({ operationId: inputUuidSchema, done: v.literal(true), result: v.strictObject({ @@ -247,14 +249,14 @@ export const inputPaymentMethodIdentifiersSchema = v.strictObject({ }); export const paymentMethodIdentifiersSchema = inputPaymentMethodIdentifiersSchema; -export const inputPaymentMethodSchema = v.looseObject({ +export const inputPaymentMethodSchema = v.strictObject({ ...inputPaymentMethodIdentifiersSchema.entries, label: inputNameSchema, expireTime: inputDateTimeSchema, humanId: inputStringU8Schema, brand: v.optional(inputPaymentMethodBrandSchema), }); -export const paymentMethodSchema = v.looseObject({ +export const paymentMethodSchema = v.strictObject({ ...paymentMethodIdentifiersSchema.entries, label: nameSchema, expireTime: dateTimeSchema, @@ -403,7 +405,7 @@ export const inputBillingAccountIdentifiersSchema = v.strictObject({ }); export const billingAccountIdentifiersSchema = inputBillingAccountIdentifiersSchema; -export const inputBillingAccountSchema = v.looseObject({ +export const inputBillingAccountSchema = v.strictObject({ ...inputBillingAccountIdentifiersSchema.entries, name: inputNameSchema, email: inputEmailSchema, @@ -419,7 +421,7 @@ export const inputBillingAccountSchema = v.looseObject({ timeZone: v.optional(inputTimeZoneSchema), defaultPaymentMethodId: v.optional(inputIdSchema), }); -export const billingAccountSchema = v.looseObject({ +export const billingAccountSchema = v.strictObject({ ...billingAccountIdentifiersSchema.entries, name: nameSchema, email: emailSchema, @@ -508,6 +510,7 @@ export const inputImportBillingDataCommandParamsSchema = v.strictObject({ export const importBillingDataCommandParamsSchema = v.strictObject({ billingAccountId: idSchema, }); +// oxlint-disable block65/prefer-strict-object -- a request carries headers the document does not name; stripping them is the point export const inputImportBillingDataCommandHeaderSchema = v.object({ "content-type": v.picklist([ "application/json", @@ -517,6 +520,8 @@ export const inputImportBillingDataCommandHeaderSchema = v.object({ "content-length": v.bigint(), "x-idempotency-key": v.optional(v.pipe(v.string(), v.uuid())), }); +// oxlint-enable block65/prefer-strict-object +// oxlint-disable block65/prefer-strict-object -- a request carries headers the document does not name; stripping them is the point export const importBillingDataCommandHeaderSchema = v.object({ "content-type": v.picklist([ "application/json", @@ -530,6 +535,7 @@ export const importBillingDataCommandHeaderSchema = v.object({ ]), "x-idempotency-key": v.exactOptional(v.pipe(v.string(), v.uuid())), }); +// oxlint-enable block65/prefer-strict-object export const inputGetOperationCommandResponseSchema = inputLongRunningOperationSchema; export const getOperationCommandResponseSchema = longRunningOperationSchema; diff --git a/__tests__/lint-directives.test.ts b/__tests__/lint-directives.test.ts new file mode 100644 index 0000000..79d9187 --- /dev/null +++ b/__tests__/lint-directives.test.ts @@ -0,0 +1,209 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { assert, expect, test } from "vitest"; +import { build } from "../lib/build.ts"; + +const document = { + openapi: "3.1.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/thing": { + get: { + operationId: "getThingCommand", + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Thing" }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Thing: { + type: "object", + properties: { name: { type: "string" } }, + }, + }, + }, +}; + +const withHeader = { + ...document, + paths: { + "/thing": { + get: { + ...document.paths["/thing"].get, + parameters: [ + { name: "x-request-id", in: "header", schema: { type: "string" } }, + ], + }, + }, + }, +}; + +async function buildIn(parent: string, source: object = document) { + const dir = await mkdtemp(path.join(parent, "lint-directives-")); + const input = path.join(dir, "document.json"); + + await writeFile(input, JSON.stringify(source)); + await build(input, dir); + + return { + dir, + valibot: await readFile(path.join(dir, "valibot.ts"), "utf8"), + main: await readFile(path.join(dir, "main.ts"), "utf8"), + }; +} + +// Output inside this repository finds its oxlint +test("a file names the exempt rules that fire in it", async () => { + const { dir, valibot, main } = await buildIn(import.meta.dirname); + + await rm(dir, { recursive: true }); + + expect(valibot).toContain( + "// oxlint-disable block65/prefer-exact-optional\n", + ); + expect(main).not.toContain("oxlint-disable"); +}); + +// An open object can let a peer's unnamed keys through, so its error reaches +// the consumer +test("an open object schema stays a lint error", async () => { + const { dir, valibot } = await buildIn(import.meta.dirname); + + await rm(dir, { recursive: true }); + + expect(valibot).toContain("v.looseObject("); + expect(valibot).not.toContain("prefer-strict-object"); +}); + +async function strictObjectErrors(dir: string) { + const oxlint = path.join( + import.meta.dirname, + "..", + "node_modules", + ".bin", + "oxlint", + ); + const stdout = await promisify(execFile)(oxlint, ["--format=json", dir]).then( + (result) => result.stdout, + (error: unknown) => + typeof error === "object" && error !== null && "stdout" in error + ? String(error.stdout) + : "", + ); + const report: unknown = JSON.parse(stdout); + + assert( + typeof report === "object" && + report !== null && + "diagnostics" in report && + Array.isArray(report.diagnostics), + ); + + return report.diagnostics.filter( + (diagnostic: unknown) => + typeof diagnostic === "object" && + diagnostic !== null && + "code" in diagnostic && + diagnostic.code === "block65(prefer-strict-object)", + ).length; +} + +// Stripping undeclared headers is the point of a header schema, so only the +// open body object reaches the consumer's lint +test("a header schema is bracketed and an open body object still errors", async () => { + const { dir, valibot } = await buildIn(import.meta.dirname, withHeader); + const errors = await strictObjectErrors(dir); + + await rm(dir, { recursive: true }); + + expect(valibot).toMatch( + /^\/\/ oxlint-disable block65\/prefer-strict-object -- a request carries headers .*\nexport const inputGetThingCommandHeaderSchema = /mu, + ); + expect(valibot).toMatch( + /^\/\/ oxlint-disable block65\/prefer-strict-object -- .*\nexport const getThingCommandHeaderSchema = /mu, + ); + + // input and wire variants of the open `Thing` + expect(errors).toBe(2); +}); + +// An upgrade from 12, or a changed lint config, leaves a file its manifest +// records as current without the directives it now needs +test("a regeneration writes directives the recorded files lack", async () => { + const { dir } = await buildIn(import.meta.dirname); + const manifestPath = path.join(dir, ".openapi-codegen-manifest.json"); + const manifestText = await readFile(manifestPath, "utf8"); + const manifest: unknown = JSON.parse(manifestText); + + assert(typeof manifest === "object" && manifest !== null); + + const entries = await readdir(dir); + const names = entries.filter((name) => name.endsWith(".ts")); + const revisions = await Promise.all( + names.map(async (name) => { + const text = await readFile(path.join(dir, name), "utf8"); + const bare = text.replace(/^\/\/ oxlint-disable .*\n\n/mu, ""); + + await writeFile(path.join(dir, name), bare); + + return [ + name, + createHash("sha256").update(bare).digest("hex").slice(0, 32), + ]; + }), + ); + + await writeFile( + manifestPath, + JSON.stringify({ ...manifest, ...Object.fromEntries(revisions) }), + ); + await build(path.join(dir, "document.json"), dir); + + const valibot = await readFile(path.join(dir, "valibot.ts"), "utf8"); + + await rm(dir, { recursive: true }); + + expect(valibot).toContain( + "// oxlint-disable block65/prefer-exact-optional\n", + ); +}); + +// Plugin 0.11.0 deleted the rule, so a directive naming it would be dead +test("no fixture directive names snake-case-wire-keys", async () => { + const fixtures = path.join(import.meta.dirname, "fixtures"); + const entries = await readdir(fixtures, { recursive: true }); + const modules = entries.filter((entry) => entry.endsWith(".ts")); + const directives = await Promise.all( + modules.map(async (entry) => { + const text = await readFile(path.join(fixtures, entry), "utf8"); + + const directive = text.match(/^\/\/ oxlint-disable .*$/mu); + + return directive ? [directive[0]] : []; + }), + ).then((found) => found.flat()); + + expect(directives).not.toHaveLength(0); + expect(directives.join("\n")).not.toContain("snake-case-wire-keys"); +}); + +test("output outside any oxlint project gets no directive", async () => { + const { dir, valibot } = await buildIn(tmpdir()); + + await rm(dir, { recursive: true }); + + expect(valibot).not.toContain("oxlint-disable"); +}); diff --git a/__tests__/manifest.test.ts b/__tests__/manifest.test.ts index 505c31b..3e8f469 100644 --- a/__tests__/manifest.test.ts +++ b/__tests__/manifest.test.ts @@ -75,7 +75,6 @@ test("a stale emitter revision rewrites every file", async () => { await writeFile(target, "// edited by hand\n"); await writeFile( manifestPath, - // oxlint-disable-next-line block65/snake-case-wire-keys -- the manifest's own key, read back by this generator JSON.stringify({ ...manifest, "#generator": "0".repeat(32) }), ); diff --git a/__tests__/query-roundtrip.test.ts b/__tests__/query-roundtrip.test.ts index b17c70d..79eb594 100644 --- a/__tests__/query-roundtrip.test.ts +++ b/__tests__/query-roundtrip.test.ts @@ -197,3 +197,11 @@ test("an in: querystring parameter stops generation", async () => { ]), ).rejects.toThrow("uses `in: querystring`"); }); + +test("an in: cookie parameter stops generation", async () => { + await expect( + generateFor([ + { name: "session", in: "cookie", schema: { type: "string" } }, + ]), + ).rejects.toThrow('parameter "session" uses `in: cookie`'); +}); diff --git a/lib/build.ts b/lib/build.ts index 78c8e37..014cd5e 100644 --- a/lib/build.ts +++ b/lib/build.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import type { oas31 } from "openapi3-ts"; +import { firedExemptions, withDirectives } from "./oxlint.ts"; import { type CodegenOptions, processOpenApiDocument, @@ -136,7 +137,11 @@ export async function build( const stored = await readManifest(manifestPath); const generator = await generatorRevision(); const previous = stored[GENERATOR_KEY] === generator ? stored : {}; - const revisions = await Promise.all( + + // Every file is linted as emitted, so its directives follow the current + // lint config. A file that ends up at its recorded revision gets back the + // bytes it had on disk + const emitted = await Promise.all( files.map(async (file) => { try { file.formatText(); @@ -147,18 +152,34 @@ export async function build( // the blank line detaches the banner from the first import, which // oxfmt would otherwise move with that import when it sorts them const contents = `${BANNER}\n\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); - - if (previous[name] !== rev || !present) { - await writeFile(file.getFilePath(), contents); + const original = await readFile(file.getFilePath(), "utf8").catch( + () => {}, + ); + + await writeFile(file.getFilePath(), contents); + + return { + name: file.getBaseName(), + path: file.getFilePath(), + contents, + original, + }; + }), + ); + + const fired = await firedExemptions(emitted.map((file) => file.path)); + + const revisions = await Promise.all( + emitted.map(async ({ name, path: filePath, contents, original }) => { + const found = fired.get(filePath); + const final = found ? withDirectives(contents, BANNER, found) : contents; + const rev = createHash("sha256").update(final).digest("hex").slice(0, 32); + + const kept = + original !== undefined && previous[name] === rev ? original : final; + + if (kept !== contents) { + await writeFile(filePath, kept); } return { name, rev }; diff --git a/lib/oxlint.ts b/lib/oxlint.ts index a99244b..0d167d7 100644 --- a/lib/oxlint.ts +++ b/lib/oxlint.ts @@ -1,76 +1,213 @@ +import { execFile } from "node:child_process"; +import { access } from "node:fs/promises"; import path from "node:path"; -import * as oxlint from "oxlint"; - -// Every module the generator writes. `defineOverrides` limits the rules -// below to these, and a test compares this list with what a run emits -export const generatedFiles = [ - "commands.ts", - "commands-validated.ts", - "enums.ts", - "hono.ts", - "main.ts", - "types.ts", - "valibot.ts", -] as const; - -export function defineOverrides( - root: string, - overrides: T[] = [], -) { - return oxlint.defineConfig({ - overrides: [ - ...overrides, - { - files: generatedFiles.map((name) => path.join(root, name)), - - // the generated comments come from the spec file, so their content - // is the document author's - rules: { - "block65/no-jsdoc-on-statement": "off", - "block65/no-bare-block-comment": "off", - "block65/declaration-comments": "off", - "block65/no-comment-divider": "off", - "block65/no-comment-history": "off", - "block65/no-negated-comment": "off", - "block65/no-narrative-comment": "off", - "block65/no-jargon-comment": "off", - "block65/no-padded-comment": "off", - "block65/no-figurative-comment": "off", - "block65/no-absence-comment": "off", - "block65/no-comment-overclaim": "off", - "block65/no-hedging-comment": "off", - "block65/no-assumption-comment": "off", - "block65/no-overconfident-comment": "off", - "block65/no-placeholder-comment": "off", - "block65/no-banned-comment-words": "off", - "block65/no-comment-list": "off", - "block65/no-comment-punctuation": "off", - "block65/no-trailing-comment-punctuation": "off", - "block65/no-file-reference-in-comment": "off", - "block65/no-file-header-comment": "off", - "block65/max-comment-lines": "off", - "block65/require-comment-blank-line": "off", - "unicorn-unported/comment-content": "off", - - // a query parameter named `t` or `q` is the document's wire - // contract, so the generated code destructures that name - "block65/no-single-character-declaration": "off", - "unicorn/max-nested-calls": "off", - - // property spelling is the document's wire contract too - "block65/snake-case-wire-keys": "off", - - // an object schema is open unless the document sets - // `additionalProperties: false`, so `looseObject` accepts the - // unnamed keys a peer may send - "block65/prefer-strict-object": "off", - - // input schemas face TS callers, who may pass an explicit - // `undefined` for an absent member. The wire schemas use - // `exactOptional` - "block65/prefer-exact-optional": "off", - }, - }, - ], - }).overrides; +import { promisify } from "node:util"; + +// A file names only those that fire, as consumers report unused directives +const exemptRules = [ + // the generated comments come from the spec file, so their content is the + // document author's + "block65/no-jsdoc-on-statement", + "block65/no-bare-block-comment", + "block65/declaration-comments", + "block65/no-comment-divider", + "block65/no-comment-history", + "block65/no-negated-comment", + "block65/no-narrative-comment", + "block65/no-jargon-comment", + "block65/no-padded-comment", + "block65/no-figurative-comment", + "block65/no-absence-comment", + "block65/no-comment-overclaim", + "block65/no-hedging-comment", + "block65/no-assumption-comment", + "block65/no-overconfident-comment", + "block65/no-placeholder-comment", + "block65/no-banned-comment-words", + "block65/no-comment-list", + "block65/no-comment-punctuation", + "block65/no-trailing-comment-punctuation", + "block65/no-file-reference-in-comment", + "block65/no-file-header-comment", + "block65/max-comment-lines", + "block65/require-comment-blank-line", + "unicorn-unported/comment-content", + + // a query parameter named `t` or `q` is the document's wire contract, so + // the generated code destructures that name + "block65/no-single-character-declaration", + "unicorn/max-nested-calls", + + // input schemas face TS callers, who may pass an explicit `undefined` for + // an absent member. The wire schemas use `exactOptional` + "block65/prefer-exact-optional", +]; + +// oxlint reports `block65/rule` as `block65(rule)` +const exemptByCode = new Map( + exemptRules.flatMap((rule) => { + const [plugin, name] = rule.split("/"); + + return [ + [`${plugin}(${name})`, rule], + [`eslint-plugin-${plugin}(${name})`, rule], + ]; + }), +); + +type Fired = { rules: Set; openObjects: number[] }; + +// Found upward from the output, so the directives match the consumer's config +async function findOxlint(from: string): Promise { + const bin = path.join(from, "node_modules", ".bin", "oxlint"); + + if ( + await access(bin).then( + () => true, + () => false, + ) + ) { + return from; + } + + const parent = path.dirname(from); + + return parent === from ? undefined : findOxlint(parent); +} + +function isReport(value: unknown): value is { + diagnostics: { + code: string; + filename: string; + labels: { span: { line: number } }[]; + }[]; +} { + return ( + typeof value === "object" && + value !== null && + "diagnostics" in value && + Array.isArray(value.diagnostics) + ); +} + +function parseReport(stdout: string) { + try { + const report: unknown = JSON.parse(stdout); + + return isReport(report) ? report : undefined; + } catch { + return; + } +} + +/** + * Maps each file, as written, to the exempt rules that fire in it. The map is + * empty without oxlint, or when its config fails to load + */ +export async function firedExemptions(files: string[]) { + const fired = new Map(); + + const [first] = files; + const root = first && (await findOxlint(path.dirname(first))); + + if (!root) { + return fired; + } + + // oxlint exits non-zero when it reports anything, which rejects, and the + // report is still on stdout + const stdout = await promisify(execFile)( + path.join(root, "node_modules", ".bin", "oxlint"), + ["--format=json", ...files.map((file) => path.relative(root, file))], + { cwd: root, maxBuffer: 1024 * 1024 * 1024 }, + ).then( + (result) => result.stdout, + (error: unknown) => + typeof error === "object" && error !== null && "stdout" in error + ? String(error.stdout) + : "", + ); + + const report = parseReport(stdout); + + if (!report) { + console.warn(`oxlint in ${root} produced no report, so no lint directives`); + + return fired; + } + + for (const { code, filename, labels } of report.diagnostics) { + const rule = exemptByCode.get(code); + const file = path.join(root, filename); + const entry = fired.get(file) ?? { rules: new Set(), openObjects: [] }; + + if (rule) { + entry.rules.add(rule); + } + + if (code === "block65(prefer-strict-object)") { + entry.openObjects.push(...labels.map((label) => label.span.line)); + } + + fired.set(file, entry); + } + + return fired; +} + +const headerReason = + "a request carries headers the document does not name; stripping them is the point"; + +// `line` counts from one, and the span it returns from zero +function headerDeclaration(lines: string[], line: number) { + const start = lines.findLastIndex( + (text, index) => index < line && text.startsWith("export "), + ); + const first = lines[start]; + + if (first === undefined || !/^export const \w+HeaderSchema\b/u.test(first)) { + return; + } + + const end = first.endsWith(";") + ? start + : lines.findIndex((text, index) => index > start && /^\S/u.test(text)); + + return end === -1 ? undefined : { start, end }; +} + +/** + * Prefixes the file with its exempt rules, and brackets each header schema the + * strict-object rule fires on. An open object anywhere else stays an error + */ +export function withDirectives(contents: string, banner: string, fired: Fired) { + const lines = contents.split("\n"); + const spans = new Map( + fired.openObjects + .map((line) => headerDeclaration(lines, line)) + .filter((span) => span !== undefined) + .map((span) => [span.start, span]), + ); + + // bottom up, so an insertion leaves the earlier line numbers alone + for (const { start, end } of [...spans.values()].toSorted( + (a, b) => b.start - a.start, + )) { + lines.splice(end + 1, 0, "// oxlint-enable block65/prefer-strict-object"); + lines.splice( + start, + 0, + `// oxlint-disable block65/prefer-strict-object -- ${headerReason}`, + ); + } + + const text = lines.join("\n"); + + if (fired.rules.size === 0) { + return text; + } + + const directive = `// oxlint-disable ${[...fired.rules].toSorted().join(", ")}`; + + return text.replace(banner, `${banner}\n\n${directive}`); } diff --git a/lib/process-document.ts b/lib/process-document.ts index d96474d..d999956 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -626,6 +626,14 @@ function collectParameters( `${operationObject.operationId}: parameter "${resolvedParameter.name}" uses \`in: querystring\`, which this generator does not support. Declare the members as \`in: query\` parameters instead.`, ); } + + // no command or validator reads a cookie, so generating this + // operation would drop the parameter in silence + if (resolvedParameter.in === "cookie") { + throw new Error( + `${operationObject.operationId}: parameter "${resolvedParameter.name}" uses \`in: cookie\`, which this generator does not support.`, + ); + } } // Extract path parameters from URL pattern that weren't declared this diff --git a/oxlint.config.ts b/oxlint.config.ts index 1baecdd..dea004e 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,9 +1,18 @@ import { defineConfig } from "@block65/shared-config/oxlint"; -import * as codegenPlugin from "./lib/oxlint.ts"; export default defineConfig({ // a consumer's config enables the valibot group, so the fixtures lint under it groups: { vitest: "on", valibot: "on" }, - overrides: codegenPlugin.defineOverrides("__tests__/fixtures/*"), + overrides: [ + { + // third-party documents that leave their objects open + files: [ + "__tests__/fixtures/docker/valibot.ts", + "__tests__/fixtures/openai/valibot.ts", + "__tests__/fixtures/petstore/valibot.ts", + ], + rules: { "block65/prefer-strict-object": "off" }, + }, + ], }); diff --git a/package.json b/package.json index 7b25ec5..7e16f40 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,6 @@ ], "type": "module", "sideEffects": false, - "exports": { - "./oxlint": "./lib/oxlint.ts" - }, "scripts": { "preversion": "just check" }, @@ -33,7 +30,7 @@ "devDependencies": { "@block65/custom-error": "^14.1.0", "@block65/rest-client": "^15.0.0", - "@block65/shared-config": "^0.4.0", + "@block65/shared-config": "^0.5.0", "@block65/tsconfig": "^0.3.1", "@hono/standard-validator": "^0.4.0", "@types/node": "^26.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7795d20..97aff69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,8 +196,8 @@ importers: specifier: ^15.0.0 version: 15.0.0(valibot@1.5.0(typescript@7.0.2)) '@block65/shared-config': - specifier: ^0.4.0 - version: 0.4.0(@block65/oxlint-tsgolint@7.0.200200)(@block65/oxlint@1.83.0(@block65/oxlint-tsgolint@7.0.200200))(eslint@10.11.0)(oxfmt@0.68.0) + specifier: ^0.5.0 + version: 0.5.0(@block65/oxlint-tsgolint@7.0.200200)(@block65/oxlint@1.83.0(@block65/oxlint-tsgolint@7.0.200200))(eslint@10.11.0)(oxfmt@0.68.0) '@block65/tsconfig': specifier: ^0.3.1 version: 0.3.1 @@ -275,8 +275,8 @@ packages: os: [linux] libc: [glibc] - '@block65/oxlint-plugin@0.10.0': - resolution: {integrity: sha512-fgBpZdqTXtmIFjhppYvtFc7HnVOz7jh2IvG8GopZ5fSIuClwdB2YBm7cwOEOAYJI9q8nOPcFlZxHiBZ0E4UAgA==} + '@block65/oxlint-plugin@0.11.0': + resolution: {integrity: sha512-68MuNjQkloeKt4B4rQdW5jYoWK/e+6WB11uOmPC66R/sZdMMgi7yqA+YCG8wJRJPy4/NSK7zAdt1JiRoOBp+kA==} engines: {node: '>=24'} peerDependencies: oxlint: ^1.83.0 @@ -316,8 +316,8 @@ packages: valibot: optional: true - '@block65/shared-config@0.4.0': - resolution: {integrity: sha512-fpTjOSt+F+EVnI9r4hdUMTh1UAn9+BY8U+DtURs61Cej7zaXH13+Pz/Ns4hFt7Y8UA+Oc0nBlPT02b22CpHhUg==} + '@block65/shared-config@0.5.0': + resolution: {integrity: sha512-f7iXlaPc0GozRwUHzGxZ9hMrBGC6Ay07zTLt96A2zJ0YXsfyq0NkSOYe58nupTyu5P4rk/+lxjZPearGL/ORVg==} engines: {node: '>=24'} peerDependencies: fallow: ^3.27.0 @@ -1632,7 +1632,7 @@ snapshots: '@block65/oxlint-binding-linux-x64-gnu@1.83.0': optional: true - '@block65/oxlint-plugin@0.10.0(@block65/oxlint@1.83.0(@block65/oxlint-tsgolint@7.0.200200))(eslint@10.11.0)': + '@block65/oxlint-plugin@0.11.0(@block65/oxlint@1.83.0(@block65/oxlint-tsgolint@7.0.200200))(eslint@10.11.0)': dependencies: css-tree: 3.2.1 eslint-plugin-unicorn: 74.0.0(eslint@10.11.0) @@ -1666,9 +1666,9 @@ snapshots: optionalDependencies: valibot: 1.5.0(typescript@7.0.2) - '@block65/shared-config@0.4.0(@block65/oxlint-tsgolint@7.0.200200)(@block65/oxlint@1.83.0(@block65/oxlint-tsgolint@7.0.200200))(eslint@10.11.0)(oxfmt@0.68.0)': + '@block65/shared-config@0.5.0(@block65/oxlint-tsgolint@7.0.200200)(@block65/oxlint@1.83.0(@block65/oxlint-tsgolint@7.0.200200))(eslint@10.11.0)(oxfmt@0.68.0)': dependencies: - '@block65/oxlint-plugin': 0.10.0(@block65/oxlint@1.83.0(@block65/oxlint-tsgolint@7.0.200200))(eslint@10.11.0) + '@block65/oxlint-plugin': 0.11.0(@block65/oxlint@1.83.0(@block65/oxlint-tsgolint@7.0.200200))(eslint@10.11.0) optionalDependencies: oxfmt: 0.68.0 oxlint: '@block65/oxlint@1.83.0(@block65/oxlint-tsgolint@7.0.200200)' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3451a80..63597b6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,9 +6,9 @@ minimumReleaseAgeExclude: - "@block65/oxlint-tsgolint-linux-x64@7.0.200200" - "@block65/oxlint-tsgolint@7.0.200200" - "@block65/oxlint@1.83.0" - - "@block65/oxlint-plugin@0.10.0" + - "@block65/oxlint-plugin@0.11.0" - "@block65/rest-client@15.0.0" - - "@block65/shared-config@0.4.0" + - "@block65/shared-config@0.5.0" overrides: oxlint: npm:@block65/oxlint@>=1.83.0