Skip to content

fix(parser): accept OAS 2.0 string discriminator, reject cross-dialect forms - #398

Merged
erraggy merged 3 commits into
mainfrom
fix/oas2-string-discriminator
Jul 28, 2026
Merged

fix(parser): accept OAS 2.0 string discriminator, reject cross-dialect forms#398
erraggy merged 3 commits into
mainfrom
fix/oas2-string-discriminator

Conversation

@erraggy

@erraggy erraggy commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Summary

  • OAS 2.0 spells a Schema Object's discriminator as a bare string; OAS 3.0+ spells it as an object. parser.Schema modeled only the 3.x shape, so the two versions were handled exactly backwards for 2.0 input — conformant documents were rejected outright, and the illegal object form was silently accepted.
  • The fix spans four packages, because parsing the string form is only useful if serialization, validation, and conversion all agree on which dialect a document is written in.
  • Adds Discriminator.StringForm, the first regression fixtures for a 2.0 discriminator in either form, and dialect enforcement in the validator.

Changes

Parser

All three decode paths mishandled the string form, not only the one in the report:

Path Before
encoding/json errored, naming the internal parser.Alias type
YAML struct decode errored differently — cannot construct !!str
generated decodeFromMap (ResolveRefs) silently dropped the fieldm["discriminator"].(map[string]any) merely failed its assertion
  • Discriminator gains StringForm bool (json:"-" yaml:"-") recording which dialect spelled it.
  • New parser/schema_yaml.go adds UnmarshalYAML/MarshalYAML, which the type previously lacked entirely. It follows anchors so discriminator: *anchor is classified by the node it points at, and reports an unexpected node kind by its constant name rather than yaml's default message about the unexported alias type.
  • decodeDiscriminator handles both spellings on the decodeFromMap path.

Validator

Parsing is now deliberately permissive, so validateDiscriminatorForm is the only thing enforcing the dialect. It rejects the object form in 2.0 documents — the silent false-accept half of the issue — and the bare string in 3.x, reusing the existing v.oasVersion context already used for the OAS 3.0 type: "null" check. Unrecognized versions are skipped, since they say nothing about which form is correct.

Converter

convert -target 3.0 was emitting the string form into a 3.0 document, faithfully preserving a spelling illegal at the target. Both directions now re-spell the discriminator, and a dropped 3.x mapping is reported rather than silently lost. walkSchemas is extracted from walkSchemaRefs so this reuses the existing traversal instead of adding a third copy of it.

Codegen

parser/zz_generated_decode.go is regenerated, not hand-edited. The generator gains a stringOrObjectFields escape hatch mirroring the existing polymorphicFields one, plus a Discriminator entry in the type-check overlay stub. go run ./internal/codegen/decode -check passes.

Context

Why a struct field rather than just a permissive unmarshaler. The issue proposed accepting both forms on decode and left the marshaling decision open. That decision turns out to be load-bearing: the CLI writes fix/join output by marshaling the typed document (cmd/oastools/commands/common.go:182), not from the preserved source node. A permissive unmarshaler alone would have parsed discriminator: petType and written back {"propertyName": "petType"} — trading a false rejection for silent corruption of a valid 2.0 spec. StringForm is additive and exported, so DeepCopy (which does *out = *in) needed no regeneration.

Why StringForm is excluded from equality. equalDiscriminator deliberately ignores it. The flag records which dialect spelled the discriminator, not what it means — both forms select the same property, so comparing it would make every cross-version diff report a phantom change. internal/schemautil/hash.go already hashes only PropertyName and Mapping, so the two stay consistent.

Scope note. The issue suggested a separate OAS2Schema type as the alternative. That models the dialects more honestly but is a large, API-breaking change; this keeps the exported shape of Discriminator intact and needs no migration for library consumers.

Testing

  • Fixtures use the canonical "Models with Polymorphism Support" example from the OAS 2.0 specification, in both JSON and YAML, plus a companion asserting the object form is rejected in a 2.0 document — the testdata/ gap the issue identified.

  • Coverage spans all three decode paths, both round trips, YAML anchors and invalid node kinds, deep copy, equality, and both conversion directions.

  • Corpus error counts are unchanged (DigitalOcean 128, Asana 9, Plaid 93, GitHub 115), confirming the new validator error does not fire on real-world specs.

  • Every new function reports 100% statement coverage; validateDiscriminatorForm 100%, walkSchemas 100%, decodeDiscriminator 100%, yamlKindName 100%.

  • All tests pass (make check) — 8901 tests

  • Coverage reviewed (go tool cover) — parser 80.6%, validator 91.3%, converter 88.2%

  • Security scan clean (govulncheck ./... — no vulnerabilities found)

  • Local code review passed

Reviewer notes

Two things found while working on this were filed separately rather than folded in:

Related Issues

Fixes #394
Related to #396
Related to #397

erraggy added 2 commits July 27, 2026 18:32
…t forms

In OAS 2.0 a Schema Object's `discriminator` is a bare string naming the
property; in OAS 3.0+ it is an object with `propertyName`. `parser.Schema`
modeled only the 3.x shape, so the two versions were handled exactly
backwards for 2.0 input: spec-conformant documents were rejected outright,
while the illegal object form was silently accepted.

Fixes #394

## Parser

All three decode paths handled the string form, not just the one in the
report:

- `encoding/json` — errored with a message naming the internal `parser.Alias`
- YAML struct decode — errored differently (`cannot construct !!str`)
- generated `decodeFromMap` (`ResolveRefs`) — **silently dropped** the field,
  since `m["discriminator"].(map[string]any)` merely failed its assertion

`Discriminator` gains `StringForm bool` (`json:"-" yaml:"-"`) recording which
dialect spelled it. This is load-bearing rather than cosmetic: the CLI writes
`fix`/`join` output by marshaling the typed document, so without it a valid
2.0 spec would round-trip into the invalid 3.x object form. Both marshalers
now reproduce the form they were given.

`StringForm` is excluded from `equalDiscriminator` on purpose — the two forms
select the same property, so counting them as different would make every
cross-version diff report a phantom change.

## Validator

Parsing is now deliberately permissive, so `validateDiscriminatorForm` is the
only thing enforcing the dialect. It rejects the object form in 2.0 documents
(the silent false-accept half of the issue) and the string form in 3.x,
reusing the existing `v.oasVersion` context. Unknown versions are skipped,
since they say nothing about which form is correct.

## Converter

`convert -target 3.0` was emitting the string form into a 3.0 document —
faithfully preserving a spelling that is illegal at the target. Both
directions now re-spell the discriminator, and a dropped 3.x `mapping` is
reported rather than silently lost. `walkSchemas` is extracted from
`walkSchemaRefs` so the normalization reuses that traversal instead of
adding a third copy of it.

## Codegen

`zz_generated_decode.go` is driven by a new `stringOrObjectFields` escape
hatch in the decode generator, mirroring the existing `polymorphicFields`
one. The generated file is not hand-edited; `go run ./internal/codegen/decode
-check` passes.

## Tests

Fixtures use the canonical "Models with Polymorphism Support" example from
the OAS 2.0 spec, in both JSON and YAML, plus a companion asserting the
object form is rejected in a 2.0 document. Coverage spans all three decode
paths, both round trips, YAML anchors, deep copy, and both conversion
directions. Corpus error counts are unchanged.
Close the two branches the first commit left untested:

- validateDiscriminatorForm's early return for an unrecognized OAS version,
  which guards a hand-assembled ParseResult that never set one
- yamlKindName's full constant mapping, which error messages depend on
  staying in step with yaml.Kind

Both new helpers now report 100% statement coverage.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@erraggy, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4b9e1b79-9091-41ed-ac78-4de99851a57c

📥 Commits

Reviewing files that changed from the base of the PR and between 658939b and c3a96c5.

📒 Files selected for processing (2)
  • converter/schema_convert.go
  • converter/schema_discriminator_test.go
📝 Walkthrough

Walkthrough

The change adds dual-form discriminator parsing and serialization, generated decoding support, OAS-version validation, schema-wide discriminator normalization during conversion, reference traversal reuse, conversion issue reporting, and regression fixtures and tests for JSON, YAML, nested schemas, and round trips.

Changes

Discriminator parsing and serialization

Layer / File(s) Summary
Parser discriminator forms
parser/schema.go, parser/schema_json.go, parser/schema_yaml.go, parser/decode_helpers.go, parser/zz_generated_decode.go, internal/codegen/decode/main.go, parser/schema_equals.go, parser/schema_discriminator_test.go
Discriminators now retain whether they originated as OAS 2.0 strings, decode from either strings or objects, serialize in the corresponding form, and preserve behavior through generated decoding, copying, equality, and parser tests.

Schema traversal and conversion

Layer / File(s) Summary
Schema traversal and conversion
converter/ref_rewrite.go, converter/schema_convert.go, converter/schema_discriminator_test.go
Shared schema traversal rewrites references and discriminator mappings across nested schemas; conversions normalize discriminator forms and report dropped OAS 3.x mappings when targeting OAS 2.0.

Version validation

Layer / File(s) Summary
Version-specific validation
validator/schema.go, validator/schema_discriminator_test.go
Validation enforces string discriminators for OAS 2.0 and object discriminators for OAS 3.x, including nested schemas and unknown-version handling.

OAS 2.0 fixtures

Layer / File(s) Summary
OAS 2.0 discriminator fixtures
testdata/discriminator-2.0.json, testdata/discriminator-2.0.yaml, testdata/discriminator-object-form-2.0.yaml
Fixtures cover valid OAS 2.0 string-form discriminators and an object-form discriminator in an OAS 2.0 document.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Document
  participant Parser
  participant Converter
  participant Validator
  Document->>Parser: parse discriminator string or object
  Parser-->>Converter: decoded schema discriminator
  Converter->>Converter: normalize form for target OAS version
  Converter-->>Validator: converted document
  Validator->>Validator: validate discriminator form
  Validator-->>Document: validation result
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: accepting OAS 2.0 string discriminators and rejecting mismatched dialect forms.
Description check ✅ Passed The description is directly about the discriminator dialect fix and the supporting parser, validator, converter, and codegen changes.
Linked Issues check ✅ Passed The changes satisfy #394 by parsing OAS 2.0 string discriminators, rejecting OAS 2.0 object forms, and preserving round-trip behavior.
Out of Scope Changes check ✅ Passed The extra validator, converter, YAML, and generator updates all support the discriminator dialect fix and are not out of scope.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/oas2-string-discriminator

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@erraggy erraggy self-assigned this Jul 28, 2026
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.93939% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.12%. Comparing base (5dba259) to head (c3a96c5).

Files with missing lines Patch % Lines
parser/schema_yaml.go 85.00% 3 Missing and 3 partials ⚠️
parser/schema_json.go 77.77% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #398      +/-   ##
==========================================
+ Coverage   85.05%   85.12%   +0.07%     
==========================================
  Files         196      197       +1     
  Lines       27739    27845     +106     
==========================================
+ Hits        23593    23703     +110     
+ Misses       2813     2809       -4     
  Partials     1333     1333              
Files with missing lines Coverage Δ
converter/ref_rewrite.go 61.42% <100.00%> (+3.17%) ⬆️
converter/schema_convert.go 73.65% <100.00%> (+4.20%) ⬆️
parser/decode_helpers.go 98.98% <100.00%> (+0.05%) ⬆️
parser/schema_equals.go 94.61% <ø> (ø)
validator/schema.go 70.62% <100.00%> (+5.23%) ⬆️
parser/schema_json.go 84.02% <77.77%> (+1.06%) ⬆️
parser/schema_yaml.go 85.00% <85.00%> (ø)

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@converter/schema_convert.go`:
- Around line 45-63: Update discriminatorToStringForm to detect non-empty
d.Extra before clearing it, and report a conversion issue through
c.addIssueWithContext analogous to the existing mapping-loss issue. Preserve the
existing d.Extra = nil and string-form conversion behavior while ensuring
discarded extensions are explicitly reported.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4e9dc1f8-9626-4db4-bd19-71f814ccf0b6

📥 Commits

Reviewing files that changed from the base of the PR and between 5dba259 and 658939b.

📒 Files selected for processing (16)
  • converter/ref_rewrite.go
  • converter/schema_convert.go
  • converter/schema_discriminator_test.go
  • internal/codegen/decode/main.go
  • parser/decode_helpers.go
  • parser/schema.go
  • parser/schema_discriminator_test.go
  • parser/schema_equals.go
  • parser/schema_json.go
  • parser/schema_yaml.go
  • parser/zz_generated_decode.go
  • testdata/discriminator-2.0.json
  • testdata/discriminator-2.0.yaml
  • testdata/discriminator-object-form-2.0.yaml
  • validator/schema.go
  • validator/schema_discriminator_test.go

Comment thread converter/schema_convert.go
…nversion

OAS 2.0 spells the discriminator as a bare string, so there is no object left
to carry x- extensions when converting down from 3.x. discriminatorToStringForm
cleared them without saying so, which was the only unreported Extra map
assignment in the converter package and contradicted the project rule to track
every lossy conversion (AGENTS.md).

The message names the dropped keys, sorted for stable output, and points at the
enclosing Schema Object, which does accept extensions in OAS 2.0. Extra is now
cleared only when there was something to clear, so a lossless discriminator no
longer runs the branch at all.

Raised by CodeRabbit on #398.
@erraggy
erraggy merged commit b0f07df into main Jul 28, 2026
14 checks passed
@erraggy
erraggy deleted the fix/oas2-string-discriminator branch July 28, 2026 02:07
erraggy added a commit that referenced this pull request Jul 28, 2026
* chore: prepare v1.57.0 release

Pre-release review found docs that #396 and #398 made inaccurate, the version
bumps those PRs did not carry, and the one gap in godoc coverage for the new
public API.

## Version bumps

plugin/.claude-plugin/plugin.json and .claude-plugin/marketplace.json still
read 1.56.0. Neither was touched by the three commits since that tag, so both
were genuinely outstanding — this is the plugin/binary version mismatch the
session-start check reports.

## Docs the promotion fix made false

#396 made every decode path promote Items, AdditionalProperties,
AdditionalItems, UnevaluatedItems and UnevaluatedProperties to *Schema, so a
parsed document no longer leaves map[string]any in any of them. Several docs
described the old behavior as intended:

- parser/deep_dive.md carried a `case map[string]any:` arm commented "when
  schema wasn't typed during parsing". That is the most actively misleading of
  the set: it teaches a branch that is now dead for parsed documents, and
  implies a $ref inside items: may still arrive unresolved — exactly the bug
  #396 fixed. Removed, with a note that only hand-constructed documents can
  produce the map form.
- walker/options.go listed "Documents are parsed from raw YAML/JSON without
  full schema resolution" as a reason WithMapRefTracking is needed. That bullet
  described the bug as a feature. The option still matters for documents
  oastools did not parse, so the other two reasons stand.
- parser/doc.go, walker/deep_dive.md, walker/example_test.go and
  docs/developer-guide.md carried smaller versions of the same claim.

## Docs missing the new public API

Discriminator.StringForm had no documentation outside its own godoc:

- CLAUDE.md gains Key Patterns entries for both the discriminator dialects and
  the schema-or-bool promotion, per the rule that new public API is documented
  there.
- validator/deep_dive.md was missing the new discriminator-form check from its
  checked-items list. This is user-visible: `oastools validate` now reports
  errors it did not in v1.56.0. Notes the escape hatch too, since it is not
  obvious that an unset OASVersion skips the check.
- converter/deep_dive.md's three fidelity tables did not mention the
  discriminator at all, in either direction, including the two new warnings for
  a dropped mapping and dropped extensions.
- parser/deep_dive.md gains a Discriminator Dialects section covering the
  round-trip guarantee, since that file is the source for the published
  parser package page.
- .claude/docs/oas-concepts.md and CONTRIBUTORS.md version-feature lists, plus
  a new pitfall: a hand-built OAS 2.0 fixture must set StringForm, or the
  validator rejects it as the 3.0+ object form.

## godoc examples

parser/example_test.go gains ExampleDiscriminator_stringForm, covering the
release's only new exported identifier including the dialect round trip, and
ExampleSchema_items, which documents the type-assertion contract #396 made
reliable — the any field types alone tell a reader nothing about what to
assert to.

## Verified

make check exit 0, 0 lint issues, 0 markdownlint issues. govulncheck clean.
Benchmarked v1.56.0 against HEAD on one machine with benchstat over 6 runs:
parser +4.80% allocs/op and +8.12% B/op, downstream packages +6.38% and
+10.84% geomean. Both are the accepted cost of #396 — validator, converter and
fixer now traverse the items: subtrees they previously skipped.

Corpus findings unrelated to this release are filed as #400, #401 and #402.

* chore: add benchmark results for v1.57.0

Generated by CI benchmark workflow on chore/v1.57.0-release-prep

🤖 Generated automatically

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parser rejects valid OAS 2.0 string discriminator, and accepts the invalid OAS 3.x object form in 2.0 documents

1 participant