fix(spectral): catch oneOf without discriminator at all schema depths#521
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This stack of pull requests is managed by Graphite. Learn more about stacking. |
✱ Stainless preview builds for gridThis PR will update the cli csharp go kotlin openapi php python ruby typescript ✅ grid-typescript studio · code
✅ grid-ruby studio · code
|
Greptile SummaryThis PR strengthens the
Confidence Score: 4/5Safe to merge — the logic changes are correct and the schema fix is consistent across all three copies of the spec. The rule extension and schema fix work as intended, and a spot-check of the existing
|
| Filename | Overview |
|---|---|
| .spectral.yaml | Extended oneOf-must-have-discriminator from top-level components only to all schema depths via $..*; escalated severity from warn to error. The logic is correct, but $..* on a resolved document may produce duplicate errors and adds traversal overhead on large specs. |
| openapi/components/schemas/cards/Card.yaml | Corrected stateReason from oneOf to anyOf for a nullable field — the right fix since anyOf is the proper construct for "value or null" when no discriminator is needed. |
| openapi.yaml | Compiled spec updated in sync with the Card.yaml component source; the single oneOf → anyOf change for stateReason is correct and consistent. |
| mintlify/openapi.yaml | Mintlify copy of the compiled spec updated identically to openapi.yaml; change is correct and in sync. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Spectral lint run] --> B[Apply oneOf-must-have-discriminator rule]
B --> C["$..*[?(@ and @.oneOf)]"]
C -->|Match at any depth| D[Check for discriminator field]
D --> E{Has discriminator?}
E -->|Yes| F[Pass]
E -->|No| G[Error - was warn]
H[Card.stateReason oneOf nullable] --> I[Changed to anyOf]
I --> F
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
.spectral.yaml:71
**`$..*` traversal on the resolved document may produce duplicate violations**
Without `resolved: false`, Spectral applies this rule to the fully inlined document — every `$ref` is replaced with its full schema content before the JSONPath runs. `$..*` then traverses the expanded tree, so a single `oneOf` that is referenced in many places (e.g. `ExternalAccountInfoOneOf` or `CustomerOneOf`) will be matched once per resolved copy. If any such site lacks a discriminator, the same error will fire multiple times for what is logically one definition. Adding `resolved: false` keeps the JSONPath anchored to the literal YAML structure and avoids that duplication.
### Issue 2 of 2
.spectral.yaml:71
**Recursive descent `$..*` can be slow on large resolved specs**
The spec already contains ~92 `oneOf` usages across thousands of nodes. `$..*` visits every node in the (resolved, fully-inlined) document to apply the filter, whereas the previous `$.components.schemas[?(@.oneOf)]` only visited the top-level schema map. For large specs this can add measurable overhead to each Spectral run. Consider a more targeted path such as `$..[oneOf]` or explicit prefixes covering the locations you care about (`$.components.schemas.*`, `$.paths..schema`, etc.) if lint run time becomes an issue.
Reviews (1): Last reviewed commit: "fix(spectral): catch oneOf without discr..." | Re-trigger Greptile
ca8e445 to
f8ea7ca
Compare
bd7bf74 to
6d31e9c
Compare
6d31e9c to
dc08535
Compare
dc08535 to
4cc01d1
Compare
f8ea7ca to
ffe06d6
Compare
…ation (#525) ## Summary Bumps the Stainless edition and syncs several resource definitions to match the current API surface and codegen needs. ### Edition - Bump root `edition: 2025-10-10` → `2026-05-06`. - Drop the now-redundant pinned `kotlin.2025-10-08` edition; the new root edition covers it. ### Webhook discrimination - `webhooks.unwrap`: add `discriminator: type` so the unwrap codegen dispatches on the payload's `type` field. - New `openapi.transforms` entry: strip `type` from `BaseWebhook.properties` *and* `BaseWebhook.required`. Without this, every generated `*WebhookEvent` carries the full `WebhookType` enum on its `type` field via `BaseWebhook`, defeating the discriminator — variants that share the same `data` shape (e.g. multiple Card-related webhooks) become indistinguishable and the deserializer picks the first one declared. Same pattern already applied to customer / account / source / destination / auth base schemas in this file. ### Card refund (Kotlin keyword fix carry-over) - Keep `refund: post /sandbox/cards/{id}/simulate/return` (the method key was renamed from `return` in #520 to avoid the Kotlin reserved keyword; preserved here through the edition bump). ### Resource cleanup — `customers` - Drop the `customers.models` block. Customer / kyc / internal-account types are now exposed via `$shared.models` and resource subresources directly. - Drop the standalone `create_kyc_link` method definition; the API surface is covered by `generate_kyc_link` (renamed earlier in #518). - Simplify `update_internal_account` to the short form (the `body_param_name` was redundant). ### Resource cleanup — `auth` - Move `auth_session` model alias from `sessions` → `credentials` (the canonical location for credential-related types). - Drop the per-variant `*CredentialCreateRequest` / `*CredentialVerifyRequest` model aliases (`email_otp_*`, `oauth_*`, `passkey_*`) for the verify side. Stainless collapses these schemas to their `Fields` siblings after the existing `type`-strip transforms; exposing the wrapper aliases generated broken imports in the TS SDK (`TS2724` / `TS2552`). See #518 for the original diagnosis. - Drop `auth_session_refresh_request` and the explicit `body_param_name` on `sessions.refresh`; covered by the new edition's defaults. ### New `$shared` models Surface a few schemas that consumers need to reference directly: - `individual_customer`, `business_customer`, `beneficial_owner`, `business_info_update`, `swift_beneficiary` ### `agents` - Add `agent_account_rule` model alias. ## Stack Stacked on top of #521.

Summary
Two changes that together close a hole in our spec hygiene: a Spectral rule that catches every
oneOfmissing a discriminator (not just top-level schemas), and the one existing violation (Card.stateReason).Why
Stainless's Java codegen emitted the diagnostic:
Tracking it down found a single offender:
Card.stateReason, which usedoneOf: [$ref CardStateReason, type: 'null'].oneOfimplies a tagged union — meaningful only with a discriminator — whereas this is really just a nullable enum. The idiomatic primitive for that isanyOf.The existing spectral rule (
oneOf-must-have-discriminator) didn't catch it for two reasons:$.components.schemas[?(@.oneOf)]— i.e. top-level schemas. NestedoneOf(insideproperties,allOf, etc.) was invisible.warn, so wouldn't have failed CI even if it had matched.Changes
openapi/components/schemas/cards/Card.yamlChange
stateReasonfromoneOf→anyOf. No semantic change for consumers; just signals to codegen that this is a nullable value, not a tagged union..spectral.yamlBroaden
oneOf-must-have-discriminator:$.components.schemas[?(@.oneOf)]→$..*[?(@ && @.oneOf)]. Recursive descent with a null-safe filter so it catchesoneOfat any depth (nested properties, allOf members, array items, etc.) without crashing on JSON null literals (type: 'null'etc.).warn→error. The only existing violation is fixed in this PR, so this won't regress CI.anyOffor nullable-value cases.Validation
make lintexits 0 with the fix applied.Card.yamlback tooneOfmakes spectral fail with:Stack
Stacked on top of #520.