diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 6cba0d3..666e417 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -45,12 +45,16 @@ jobs: components: clippy, rustfmt - name: Install dependencies run: bun ci - - name: ESLint + - name: Lint run: bun run lint + - name: Typecheck + run: bun run typecheck + - name: Snapshot fixture coverage + run: bun run check-fixture-coverage - name: Cargo fmt run: cargo fmt -- --check - name: Clippy - run: cargo clippy + run: cargo clippy --all-targets - name: Cargo test run: cargo test --all-targets test-rust-cross-os: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f8cf5e4..c78d536 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -116,8 +116,9 @@ jobs: gitHubToken: ${{ secrets.GITHUB_TOKEN }} workingDirectory: website command: versions upload --tag main-${{ steps.gate.outputs.short }} --message "main@${{ steps.gate.outputs.short }}" + # A pull request from a fork gets no secrets, so wrangler cannot run. - name: Upload preview version (PR) - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/Cargo.toml b/Cargo.toml index e94a1e4..6db7c91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,9 +17,10 @@ napi-derive = "=3.5.10" regex = "1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -# Pinned: 0.0.13 swapped its YAML backend and dropped error line/column plus -# duplicate-mapping-key rejection, which the duplicate-schema-name and -# mapping-expansion-exceeded diagnostics both read out of serde_yml errors. +# Pinned: 0.0.13 swapped its YAML backend and dropped two things decode +# errors carry — the line/column suffix every `E_INPUT_INVALID` message +# forwards, and the `field.path: ` prefix `decode_failure` routes +# `duplicate-schema-name` on. serde_yml = "=0.0.12" [build-dependencies] diff --git a/__test__/angular-consumer/src/form-non-json-proof.ts b/__test__/angular-consumer/src/form-non-json-proof.ts index 97ee2ef..59593b7 100644 --- a/__test__/angular-consumer/src/form-non-json-proof.ts +++ b/__test__/angular-consumer/src/form-non-json-proof.ts @@ -1,17 +1,5 @@ -// Compile-time proofs for the request bodies and non-JSON responses -// surfaced by Phase 7. Lives next to service-proof.ts (the petstore-rich -// JSON proof) and compiles against a separate combined fixture -// (`consumer-forms-and-non-json.openapi.yaml`) generated into -// `__test__/angular-consumer/generated/` by the matching ava test. -// -// Each block asserts: -// 1. The request type accepts the right field shapes (Blob | File, -// number[], etc.). -// 2. `.observable(...)` and `.resource(...)` carry the right Response -// generic through to `Observable` / `HttpResourceRef<...>`. -// -// A regression that collapses any of these to `any` or rejects a valid -// call-site shape fails this file under `tsc --noEmit`. +// Compile-time proofs for form request bodies and non-JSON responses, +// against the fixture `consumer-forms-and-non-json.openapi.yaml`. import type { HttpResourceRef } from '@angular/common/http'; import type { Observable } from 'rxjs'; diff --git a/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts b/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts index a955a56..85b4106 100644 --- a/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts +++ b/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts @@ -1,13 +1,4 @@ -// This file is INTENDED TO FAIL TypeScript compilation. -// It exists so the test suite catches type-soundness regressions on the -// multipart form-body surface: the binary field's request-interface -// type MUST stay `Blob | File`, never widen to `string`/`any`. If a -// future change accidentally collapses the binary field type, this -// assignment would succeed and tsc would exit 0 — causing the -// negative-compile test to fail and alerting us. -// -// Expected error: TS2322 — `'string-not-blob'` (a literal string) is not -// assignable to `Blob | File`. +// Must not compile: a multipart binary field stays `Blob | File`. import type { UpdatePetAvatarParams } from '../../generated/rest/pet.rest'; // Construct an UpdatePetAvatarParams whose `avatar` field is a string, diff --git a/__test__/angular-consumer/src/negative-proof/negative.ts b/__test__/angular-consumer/src/negative-proof/negative.ts index 81a11ab..a173b56 100644 --- a/__test__/angular-consumer/src/negative-proof/negative.ts +++ b/__test__/angular-consumer/src/negative-proof/negative.ts @@ -1,15 +1,7 @@ -// This file is INTENDED TO FAIL TypeScript compilation. -// It exists so the test suite catches type-soundness regressions -// (e.g. if a future change accidentally collapses a tagged union to `any`). -// -// Expected error: TS2322 — the `kind` literal type 'dog' is not assignable to -// 'cat', so assigning an object with `kind: 'dog'` to a Cat-typed slot fails. -// If the union ever degrades to `any`, this assignment would succeed and tsc -// would exit 0 — causing the negative-compile test to fail and alerting us. +// Must not compile: a discriminated union keeps its `kind` literal types. import type { Cat } from '../../generated/model'; -// Construct an object whose `kind` discriminant is 'dog', not 'cat'. -// This is structurally compatible with Cat except for the literal type on `kind`. +// Structurally a Cat but for the `kind` literal. const dogKind = { kind: 'dog' as const, lives: 9 }; export const shouldFail: Cat = dogKind; diff --git a/__test__/angular-consumer/src/negative-proof/validate-rejects-bad-debounce.ts b/__test__/angular-consumer/src/negative-proof/validate-rejects-bad-debounce.ts index 701da1f..20eae48 100644 --- a/__test__/angular-consumer/src/negative-proof/validate-rejects-bad-debounce.ts +++ b/__test__/angular-consumer/src/negative-proof/validate-rejects-bad-debounce.ts @@ -1,13 +1,5 @@ -// This file is INTENDED TO FAIL TypeScript compilation. -// It exists so the test suite catches type-soundness regressions on the -// `debounce` option, which `RestValidatorOptions` inherits from Angular's own -// `AsyncValidatorOptions` via `Omit`. That indirection is what keeps the emitted -// template compiling on @angular/forms 21 (where the key is absent), but it -// would also silently swallow a bad value if the inherited type ever widened to -// `any`. A string is not a `DebounceTimer`, so tsc must reject it. -// -// Expected error: TS2322 — `string` is not assignable to -// `DebounceTimer` (i.e. `number` or a function). +// Must not compile: `debounce` keeps the `DebounceTimer` type it inherits +// from Angular's `AsyncValidatorOptions`. import { schema } from '@angular/forms/signals'; import type { PetRest } from '../../generated/rest/pet.rest'; import { validateRest } from '../../generated/rest.validate'; diff --git a/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-request.ts b/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-request.ts index 040f0b6..c9aebdc 100644 --- a/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-request.ts +++ b/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-request.ts @@ -1,30 +1,11 @@ -// This file is INTENDED TO FAIL TypeScript compilation. -// It exists so the test suite catches type-soundness regressions on the -// validateRest surface: the `request` callback's return type MUST stay -// pinned to the endpoint's Request shape (here, UpdatePetParams), never -// widen to `any`/`unknown`. If the typing widened, the -// `{ wrong: 'value' }` literal below would be accepted and tsc would -// exit 0 — causing the negative-compile test to fail and alerting us. -// -// Expected error: TS2322 — `{ wrong: string }` is not assignable to -// `UpdatePetParams | undefined`. -// -// Note: we let `TRequest` be inferred from `service.updatePet` (which -// pins it to `UpdatePetParams`) rather than supplying it explicitly, -// so the type conflict surfaces as a TS2322 assignability error on the -// `request` property of the option-bag — exactly the surface this -// proof is meant to lock down — instead of a TS2345 argument-type -// error on the `service.updatePet` position. +// Must not compile: `validateRest`'s `request` callback stays pinned to +// the endpoint's request shape. import { schema } from '@angular/forms/signals'; import type { PetRest } from '../../generated/rest/pet.rest'; import { validateRest } from '../../generated/rest.validate'; declare const service: PetRest; -// The mismatched value is annotated with an unrelated interface so the -// failure becomes an unambiguous TS2322 assignability error (named-type -// vs named-type) rather than the more specialised TS2739 "missing -// properties from object literal" diagnostic. interface WrongRequest { wrong: string; } diff --git a/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-response.ts b/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-response.ts index a04536a..3a97c31 100644 --- a/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-response.ts +++ b/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-response.ts @@ -1,13 +1,5 @@ -// This file is INTENDED TO FAIL TypeScript compilation. -// It exists so the test suite catches type-soundness regressions on the -// validateRest surface: the `onSuccess` callback's `result` parameter -// MUST be typed as the endpoint's Response (here, Pet), never widen to -// `any`/`unknown`. If `result` were widened, the `result.nonExistentField` -// access below would be accepted and tsc would exit 0 — causing the -// negative-compile test to fail and alerting us. -// -// Expected error: TS2339 — property 'nonExistentField' does not exist on -// type 'Pet'. +// Must not compile: `onSuccess`'s `result` stays typed as the endpoint's +// response. import { schema } from '@angular/forms/signals'; import type { PetRest, UpdatePetParams } from '../../generated/rest/pet.rest'; import type { Pet } from '../../generated/model.ts'; diff --git a/__test__/browser.spec.ts b/__test__/browser.spec.ts index 8d22bbc..8666a77 100644 --- a/__test__/browser.spec.ts +++ b/__test__/browser.spec.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { GenerateOptions } from '../index.js'; import { generate as nativeGenerate } from '../lib/index.js'; @@ -19,6 +20,9 @@ type BrowserEntry = { createGenerate: (load: () => Promise) => GenerateFn; GenerateError: { isGenerateError: (value: unknown) => boolean }; EmitTarget: { Models: string; Angular: string }; + Layout: { Services: string; Operations: string }; + InputFormat: { Json: string; Yaml: string }; + ResponseType: { Json: string; Blob: string; Text: string; ArrayBuffer: string }; }; type TypedError = { code?: string; subcode?: string | null; message: string }; @@ -44,7 +48,7 @@ const petstoreOptions = { inputContents: petstore, displayPath: 'petstore-minimal.openapi.yaml', emit: ['models', 'angular'], -}; +} satisfies GenerateOptions; wasiTest( 'browser generate through the WASI binding matches the native output', @@ -112,7 +116,14 @@ test('browser generate maps a binding without generateNative to E_UNSUPPORTED_RU t.regex(err.message, /generateNative/); }); -test('browser entry exports EmitTarget mirror', t => { - t.is(browserEntry.EmitTarget.Models, 'models'); - t.is(browserEntry.EmitTarget.Angular, 'angular'); +test('browser entry exports the runtime enum mirrors', t => { + t.deepEqual(browserEntry.EmitTarget, { Models: 'models', Angular: 'angular' }); + t.deepEqual(browserEntry.Layout, { Services: 'services', Operations: 'operations' }); + t.deepEqual(browserEntry.InputFormat, { Json: 'json', Yaml: 'yaml' }); + t.deepEqual(browserEntry.ResponseType, { + Json: 'json', + Blob: 'blob', + Text: 'text', + ArrayBuffer: 'arrayBuffer', + }); }); diff --git a/__test__/cli-parse.spec.ts b/__test__/cli-parse.spec.ts index 2b8f9ad..509438f 100644 --- a/__test__/cli-parse.spec.ts +++ b/__test__/cli-parse.spec.ts @@ -20,6 +20,7 @@ type ParseModule = { loadConfigFile(configPath: string): Promise>; normalizeMappedTypes(items: unknown): unknown[] | null; normalizeEmit(value: unknown): string[] | null; + normalizeLayout(value: unknown): string[] | null; mergeConfig( fileConfig: Record, cliFlags: Record, diff --git a/__test__/cli.spec.ts b/__test__/cli.spec.ts index b052c66..ac09bf5 100644 --- a/__test__/cli.spec.ts +++ b/__test__/cli.spec.ts @@ -178,10 +178,7 @@ test('cli generate writes 3 artifacts for fixture without operations', t => { // ── Verbose: warnings ────────────────────────────────────────────────────── test('cli generate suppresses warnings without --verbose', t => { - // cookie-param emits a non-fatal warning (cookies aren't surfaced in the - // generated service contract — browsers manage cookies via the cookie - // store). header-param used to share this behaviour but headers are now - // first-class. + // cookie-param warns: the generated contract does not surface cookies. const result = runCli(['generate', '--input', fixture('cookie-param.openapi.yaml')]); t.is(result.status, 0); t.is(result.stderr, ''); @@ -318,8 +315,6 @@ test('cli with no args prints help to stdout and exits 2', t => { }); test('cli --help still exits 0', t => { - // Pin the existing behaviour so the "no args" change does not bleed - // into the explicit-help path. const result = runCli(['--help']); t.is(result.status, 0); }); diff --git a/__test__/generate.snapshot.spec.ts b/__test__/generate.snapshot.spec.ts index 6269c66..dc0329a 100644 --- a/__test__/generate.snapshot.spec.ts +++ b/__test__/generate.snapshot.spec.ts @@ -4,10 +4,15 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -// Use the wrapper so caught errors are real `GenerateError` instances -// (the failure snapshots pin `warnings`/`path` from the upgraded class -// shape). -import { generate } from '../lib/index.js'; +import { generate, isGenerateError } from '../scripts/lib/engine.ts'; +import type { GenerateOptions } from '../scripts/lib/engine.ts'; +import { + BANNER_RE, + FAILURE_FIXTURES, + SNAPSHOT_EMIT, + STATIC_TEMPLATE_PATHS, + SUCCESS_FIXTURES, +} from '../scripts/lib/snapshot-layout.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.join(__dirname, '..'); @@ -21,21 +26,6 @@ function readJsonSnapshot(name: string) { return JSON.parse(fs.readFileSync(snapshot(name), 'utf8')); } -// Strip the per-version banner from artifact contents so snapshots -// survive version bumps. Keep in sync with BANNER_RE in -// scripts/regen-snapshots.mjs. -const BANNER_RE = - /^\/\/ Generated by openapi-ng v[^\n]*\n\/\/ Source: [^\n]*\n\/\/ DO NOT EDIT[^\n]*\n\n/u; - -// Paths whose contents are byte-identical across every success fixture. -// Listed by path in each per-fixture snapshot but the file bodies live -// once under __test__/snapshots/generate-native/static-template/ — see -// scripts/regen-snapshots.mjs for the storage layout. -const STATIC_TEMPLATE_PATHS = new Set([ - 'rest.model.ts', - 'rest.util.ts', - 'rest.validate.ts', -]); const STATIC_TEMPLATE_DIR = path.join( repoRoot, '__test__', @@ -122,209 +112,33 @@ async function successResult(name: string, options: Record = {} ); } -async function failurePayload(name: string, options: Record = {}) { +async function failurePayload(name: string, options: Partial = {}) { try { await generate({ inputPath: fixture(name), - emit: ['models', 'angular'], + emit: [...SNAPSHOT_EMIT], ...options, }); - throw new Error(`Expected ${name} to fail.`); } catch (thrown) { - const e = thrown as any; + if (!isGenerateError(thrown)) { + throw new Error(`${name} failed with a non-generator error`, { cause: thrown }); + } return { - code: e.code, - message: e.message, - path: e.path ?? null, - warnings: e.warnings ?? [], + code: thrown.code, + message: thrown.message, + path: thrown.path, + warnings: thrown.warnings, }; } + throw new Error(`Expected ${name} to fail.`); } -const successFixtures = [ - 'petstore-minimal.openapi.yaml', - 'petstore-minimal.openapi.json', - 'petstore-rich.openapi.yaml', - 'petstore-rich.openapi.json', - 'oneof-anyof-composition.openapi.yaml', - 'oneof-anyof-composition.openapi.json', - 'allof-composition.openapi.yaml', - 'additional-properties.openapi.yaml', - // additional-properties: false combined with declared `properties` - // emits the named interface unchanged — OpenAPI semantics treat - // `false` as "no extras beyond what's declared", which is the - // default for TypeScript interfaces, so the field is structurally - // a no-op for our emit. (The `true` form remains rejected — see - // failureFixtures below.) - 'additional-properties-false.openapi.yaml', - 'recursive-model.openapi.yaml', - 'single-entry-composition.openapi.yaml', - 'empty-shapes.openapi.yaml', - 'inline-model.openapi.yaml', - 'nullable-optional.openapi.yaml', - // header-param emits a non-fatal warning for the unsupported `header` - // location; the snapshot pins the full warning shape (code/stage/ - // severity/fatal/message/path) so multi-warning regressions surface - // here, not via a separate substring assertion (E12). - 'header-param.openapi.yaml', - // large-enum exercises both branches of the D8 enum-rendering width - // threshold: 50 values forces multi-line, 2 values stays inline. - 'large-enum.openapi.yaml', - // multi-tag-operation pins the (currently silent) policy that secondary - // tags are dropped — operation_grouper.rs uses tags.first() only. If - // someone later wires multi-tag emission, this snapshot surfaces the - // change. - 'multi-tag-operation.openapi.yaml', - // nullable-oneof exercises apply_nullable over composition: a oneOf - // with `nullable: true` (both at top level and as an inline property - // shape). Pins how the nullable wrapper combines with union semantics. - 'nullable-oneof.openapi.yaml', - // security-schemes confirms that components.securitySchemes blocks are - // silently accepted (no error, no warning) — generation proceeds as if - // the block were not present. If we ever wire auth-aware emission this - // snapshot surfaces the change. - 'security-schemes.openapi.yaml', - // circular-allof exercises the E4 recursion-guard happy path: 5 layers - // of allOf composition that bottom out at BaseAuditFields. Catches any - // off-by-one in MAX_NORMALIZE_DEPTH that would falsely fail legit - // deeply-nested specs. - 'circular-allof.openapi.yaml', - // discriminated-union pins the TS surface for `oneOf` + `discriminator:` - // (D11). - 'discriminated-union.openapi.yaml', - // bench-large is a large realistic spec (100+ schemas, 40+ operations). - // Adding it to the snapshot suite catches regressions that only surface - // at scale — sort order changes, buffer sizing issues, etc. - 'bench-large.openapi.yaml', - // reserved-prop-names pins the identifier-escaping policy: reserved - // words like `class` stay unquoted (valid TS property names), while - // digit-first / kebab / dotted / space-containing names get - // single-quoted via safe_property_name. - 'reserved-prop-names.openapi.yaml', - // jsdoc-descriptions pins JSDoc preservation: schema description on - // interface/type alias/enum, per-property description, and - // operation summary+description merged onto the service member. - 'jsdoc-descriptions.openapi.yaml', - // multi-warning triggers two normalize-stage cookie-parameter warnings - // in one operation. Pins the warning order (sessionId before - // trackingId) so a regression that reorders or coalesces the - // pipeline's pre-fatal diagnostics surfaces here, not as a silent - // change. - 'multi-warning.openapi.yaml', - // deprecated-fields exercises OpenAPI `deprecated: true` mapping to - // `@deprecated` JSDoc on the emitted operation, top-level type alias - // (enum), and per-property declaration. Pins the JSDoc emission so a - // regression that drops the tag surfaces here. - 'deprecated-fields.openapi.yaml', - // recursive-oneof closes the cycle-handling coverage matrix. The - // existing fixtures cover cycles via plain `$ref` properties - // (recursive-model) and bounded deep `allOf` chains (circular-allof); - // this one pins cycles routed through `oneOf` composition, where the - // back-edge lands as an unresolved Ref inside a union member. - 'recursive-oneof.openapi.yaml', - // string-formats exercises the format-dropped warning policy: every - // schema-level `format` hint (uuid, email, uri, date, date-time) - // surfaces as an E_UNSUPPORTED_SEMANTIC warning with subcode - // 'format-dropped'. Pins both the warning text/order and the fact that - // generation still proceeds with the base type. - 'string-formats.openapi.yaml', - // discriminator-mapping pins the `discriminator.mapping` honoring policy: - // when a oneOf carries an explicit wire-value mapping, the narrowed - // literal on each member must use the mapping key (e.g. `feline`, - // `canine`) rather than the lowercased schema name. Regressions that - // ignore `mapping` would emit `'cat'`/`'dog'` here. - 'discriminator-mapping.openapi.yaml', - // discriminator-allof exercises the `oneOf` + `allOf`-shaped members - // path: each member composes a shared base (Animal) with a - // variant-specific tail that redeclares the discriminator property. - // The Intersection walk must reach into the inline part to narrow - // `kind` to a single string literal on Cat/Dog. - 'discriminator-allof.openapi.yaml', - // anchor-modest pins the accept-side boundary of the - // mapping-expansion-exceeded guard: a small anchor (Audit) reused 3× - // across composed schemas — the kind of legitimate anchor pattern - // hand-written specs commonly use. The re-serialised byte ratio stays - // well under the 50× cap, so this fixture proves the guard does not - // regress modest anchor use. The reject-side boundary lives in - // `failureFixtures` as `anchor-fanout.openapi.yaml`. - 'anchor-modest.openapi.yaml', - // body-multipart-mixed-fields exercises the comprehensive multipart - // form-body walker: scalar + array-of-scalar + binary + array-of-binary - // + optional scalar. Pins the FormData IIFE shape (`fd.append(...)` - // per field, `String(...)` cast for scalars, raw passthrough for - // binaries, `if (... !== undefined)` guards for optional fields) and - // the request-interface field types (`Blob | File` and `(Blob | File)[]`). - 'body-multipart-mixed-fields.openapi.yaml', - // body-multipart-ref-to-named-object verifies the flattened_body_ref - // import suppression: when a multipart body schema is a top-level $ref - // to a named object (UploadForm), its properties are inlined as - // form-fields and the now-redundant UploadForm import is omitted. - 'body-multipart-ref-to-named-object.openapi.yaml', - // body-urlencoded-scalar-and-array exercises the urlencoded form-body - // walker: scalar + array-of-scalar. Pins the URLSearchParams IIFE - // shape (distinct from FormData) and the absence of binary fields - // (rejected upstream in normalize for urlencoded). - 'body-urlencoded-scalar-and-array.openapi.yaml', - // response-blob-via-pdf verifies the default response-kind classifier - // routes `application/pdf` to `Blob` using the `requestFactory.blob<…>(…)` variant. - 'response-blob-via-pdf.openapi.yaml', - // response-text-via-text-plain verifies the default response-kind - // classifier routes `text/plain` to `string` using the `requestFactory.text<…>(…)` variant. - 'response-text-via-text-plain.openapi.yaml', - // response-problem-json verifies the `*+json` suffix rule: a media - // type like `application/problem+json` classifies as Json (not Blob), - // so the response is emitted as a typed JSON shape via the default - // `requestFactory<…>(…)` (no non-JSON variant). - 'response-problem-json.openapi.yaml', - // Operations named `default` and `index` are legal class properties - // under the default layout; the `operations` layout rejects them (see - // the reserved-identifier failure snapshots below). - 'default-method-name.openapi.yaml', - 'index-method-name.openapi.yaml', -] as const; - -// Option-parameterised success cases. `label` names the snapshot files: -// `