Skip to content

fix: guard against parser field drift, and close the gaps the guard found - #416

Merged
erraggy merged 4 commits into
mainfrom
fix/field-accounting-drift-guard
Jul 31, 2026
Merged

fix: guard against parser field drift, and close the gaps the guard found#416
erraggy merged 4 commits into
mainfrom
fix/field-accounting-drift-guard

Conversation

@erraggy

@erraggy erraggy commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add internal/driftguard: a behavioral guard that requires every parser struct field to be handled by the marshal, hash, and equality paths
  • Fix the nine fields it found missing from the hand-built MarshalJSON path, including four OAS 3.2 features previously recorded as implemented
  • Fix the thirteen structural fields the hasher ignored, plus three unframed collisions
  • Fix the twenty-seven fields the joiner's deep comparison ignored, six of which were merging non-equivalent schemas today

The guard

A field on a parser type has to be handled in several independent places, and nothing connected them. internal/driftguard reflects over each type, sets one field at a time, and requires the behavior under test to notice it:

Guard Asks
TestMarshalSlowPathEmitsEveryField does the field survive with an x- extension present?
TestMarshalFastPathEmitsEveryField control — does it survive without one?
TestHashReadsEveryStructuralSchemaField does the structural hash move?
TestHashFramesAdjacentValues do neighboring values stay distinguishable?
TestDeepComparisonReadsEveryStructuralSchemaField does the joiner see a difference?
TestEqualsReadsEveryStructuralSchemaField does parser.Equals?

Asking whether the output changes rather than whether the field appears in some switch statement is what makes it honest — a source-scanning check would have passed on every bug below.

It lives in one internal package with no exported symbols, so the reflection helper it shares is not a maintained API for anything else, and each deliberate exclusion sits beside the check that honors it with its reason.

Writing it first meant it named the gaps instead of my triaging 65 fields by hand — and it found more than the audit in #414 did.

What it found

Marshal — 9 fields. Present in the struct tags, absent from the map builder, so dropped from any object also carrying an extension. Four are the OAS 3.2 features #397 recorded as covered ($self, components.mediaTypes, query, additionalOperations) — implemented for the fast path only. Five are JSON Schema 2020-12 keywords (unevaluatedProperties, unevaluatedItems, contentEncoding, contentMediaType, contentSchema), so OAS 3.1 documents were affected too.

Hash — 13 fields plus 3 collisions. Never read: $schema, $id, $anchor, $dynamicRef, $dynamicAnchor, $vocabulary, default, collectionFormat, and the same five content keywords. $id and $dynamicRef decide what a reference resolves to; collectionFormat decides an array's wire format. Separately, required: ["ab"] hashed identically to ["a", "b"], and the same for enum and dependentRequired — length-framed now, as xml and discriminator already were.

Equivalence — 27 fields. Deep comparison read 38 of Schema's 65. Twenty-four structural ones are now compared; $comment, externalDocs and deprecated join the documentation set so they follow EquivalenceDocsIgnore. For six, the hash was silent too, so these were merged outright:

Differing only in Was
collectionFormat csv and pipes consolidated — different wire format
default different default values consolidated
$schema, $id, $dynamicRef different dialects and identities consolidated
deprecated a deprecated schema merged with a live one

parser's own Equals needed no change — the guard confirms it already covered every field but the deliberately excluded StringForm.

Breaking

Structural hashes change for most non-trivial schemas. The hasher reads thirteen more fields and length-frames every value it writes. Nothing in oastools persists a hash across runs — the type documents it as an in-process grouping key, not a stable identifier — but treat any stored hash as invalidated. Recorded in the pending release note.

joiner and builder semantic deduplication now keep apart schemas they used to consolidate. That was wrong output; expect a larger schema count when joining documents using the affected fields.

Testing

  • The guard fails when a fix is reverted, verified by removing one and watching Components/MediaTypes fail with an actionable message
  • End-to-end deduplication regression covering all six previously-merged pairs
  • All tests pass (make check) — 10203 tests, up from 9378
  • Linter clean at the pinned version

Related Issues

Fixes #414
Fixes #410

A field on a parser type has to be handled in several independent places: the
hand-built slow MarshalJSON path, the structural hasher, each type's equality
function, and the joiner's schema comparison. Nothing connected them, so a field
could be added, tested and shipped while silently vanishing from JSON documents
carrying an extension, or while leaving two different schemas looking identical.

internal/driftguard closes the loop. It reflects over each type, sets one field
at a time, and requires the behavior under test to notice: the field survives
both marshal paths, moves the hash, and makes both equality checks disagree.
Asking whether the output changes rather than whether the field appears in some
switch is what makes it honest. Deliberate exclusions are listed with a reason
beside the check that honors them.

Written first, it named the gaps rather than leaving them to be triaged by hand,
and it found more than the audit did.

Marshal: nine fields reached the struct tags and not the map builder, so they
were dropped from any object that also carried an x- extension. Four are the OAS
3.2 features #397 recorded as already implemented ($self, components.mediaTypes,
query, additionalOperations); five are JSON Schema 2020-12 keywords, so OAS 3.1
documents were affected too.

Hash: thirteen structural fields were never read, including $id, $dynamicRef and
$schema, which decide what a reference resolves to, and collectionFormat, which
decides an array's wire format. Three list-valued fields also ran together
unframed, so required ["ab"] hashed identically to ["a", "b"]; they are
length-framed now, as xml and discriminator already were.

Equivalence: joiner's deep comparison read 38 of Schema's 65 fields. Twenty-four
structural ones are now compared, and $comment, externalDocs and deprecated join
the documentation set so they follow EquivalenceDocsIgnore. For six fields the
hash was silent too, so schemas differing only in collectionFormat, default,
$schema, $id, $dynamicRef or deprecated were being merged outright.

parser's own Equals needed no change; the guard confirms it already covered every
field but the deliberately excluded StringForm.

Structural hashes therefore change for most non-trivial schemas. The pending
release note says so and explains why.

Fixes #414
Fixes #410
@coderabbitai

coderabbitai Bot commented Jul 31, 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: 197876cf-a896-48d7-ba62-27b16d4beba0

📥 Commits

Reviewing files that changed from the base of the PR and between c355840 and 07789f6.

📒 Files selected for processing (2)
  • joiner/equivalence.go
  • joiner/equivalence_test.go
📝 Walkthrough

Walkthrough

The PR adds missing JSON marshaling fields, expands schema hashing and deep equivalence checks, prevents framing collisions, and adds reflection-based drift guards for parser schema coverage.

Changes

Schema consistency

Layer / File(s) Summary
JSON marshaling coverage
parser/*_json.go
Custom marshaling now emits $self, mediaTypes, OAS 3.2 path fields, and JSON Schema unevaluated/content keywords.
Structural hash coverage
internal/schemautil/hash.go, internal/schemautil/hash_test.go, internal/driftguard/hash_test.go
Schema identity, value semantics, and content keywords are hashed. $ref siblings remain included. Enum, required, and dependent-required values use length-framed encoding.
Schema equivalence and deduplication
joiner/equivalence.go, joiner/equivalence_test.go, joiner/joiner_dedupe_test.go
Deep comparison now covers structural fields, documentation, nested maps, discriminators, conditional schemas, typed-nil values, and field-specific diagnostics. Deduplication tests preserve distinct schemas.
Reflective drift guards
internal/driftguard/*.go, Makefile
Reflection helpers and regression tests verify parser fields across equality, deep comparison, hashing, and custom JSON marshaling. Cross-package test coverage is enabled.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the drift guard and the parser field handling fixes.
Description check ✅ Passed The description directly explains the drift guard, detected omissions, fixes, breaking hash changes, and test results.
Linked Issues check ✅ Passed The changes satisfy the coding objectives in issues [#414] and [#410], including drift protection, marshal fixes, hash framing, hashing, and deep comparison.
Out of Scope Changes check ✅ Passed The changes remain within the linked issue scope, including related documentation, regression tests, and coverage configuration for the new guards.
Docstring Coverage ✅ Passed Docstring coverage is 86.67% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/field-accounting-drift-guard

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.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.08602% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.62%. Comparing base (e528210) to head (07789f6).

Files with missing lines Patch % Lines
joiner/equivalence.go 91.33% 9 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #416      +/-   ##
==========================================
+ Coverage   85.12%   86.62%   +1.50%     
==========================================
  Files         200      200              
  Lines       28606    28778     +172     
==========================================
+ Hits        24350    24930     +580     
+ Misses       2864     2549     -315     
+ Partials     1392     1299      -93     
Files with missing lines Coverage Δ
internal/schemautil/hash.go 97.90% <100.00%> (+41.81%) ⬆️
parser/oas3_json.go 97.05% <100.00%> (+34.55%) ⬆️
parser/paths_json.go 88.70% <100.00%> (+0.12%) ⬆️
parser/schema_json.go 84.76% <100.00%> (+0.52%) ⬆️
joiner/equivalence.go 93.18% <91.33%> (+16.76%) ⬆️

... and 19 files 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: 7

🤖 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 `@internal/driftguard/equivalence_test.go`:
- Around line 67-69: Remove the unreachable f.name == "StringForm" conditional
and its t.Skip call from the fieldsOf[parser.Schema]() equivalence test, leaving
the surrounding field comparison logic unchanged.

In `@internal/driftguard/fields_test.go`:
- Around line 94-98: Update the reflect.Interface branch in the relevant test
helper to assign marker only when the destination interface is empty or
otherwise accepts the marker value; for named non-empty interfaces, return false
instead of calling fv.Set and allowing a panic. Preserve the existing true
result for fields declared as any.

In `@internal/driftguard/marshal_test.go`:
- Around line 107-110: Update the marshal assertion in the test’s value-encoding
loop to unmarshal encoded into map[string]json.RawMessage and assert that
f.jsonKey exists as a top-level key, replacing the raw JSON assert.Contains
check while preserving the existing error and diagnostic context.

In `@internal/schemautil/hash.go`:
- Around line 416-441: Rename or restructure hashContentKeywords so its name
accurately reflects that it hashes default, collectionFormat, and
content-related keywords. Prefer moving the two content keywords into a separate
helper or renaming the function to hashValueAndSerialization, and update its doc
comment and all call sites accordingly.
- Around line 394-406: Update the $vocabulary hashing loop in hashIdentity to
write each strconv.FormatBool(schema.Vocabulary[k]) value through h.writeLabeled
with an appropriate label, rather than h.writeString, so the boolean is
length-framed consistently with other hashed values. Include this hash-format
change in the existing release note for structural hash changes.

In `@joiner/equivalence.go`:
- Line 604: Update the comparison call in the additional-items handling to
provide an item-specific path, rather than allowing
compareAdditionalPropertiesSchemas to record differences under
additionalProperties. Split or parameterize compareAdditionalPropertiesSchemas
so its path label is additionalItems for this call while preserving the existing
additionalProperties behavior elsewhere.
- Around line 507-516: Add a nil-value guard at the start of compareDeep, before
any operand dereference. Treat two nil operands as equal, record the appropriate
mismatch when only one operand is nil, and return without dereferencing either
value; keep the existing deep comparison unchanged when both values are non-nil.
🪄 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: ebb27dad-b641-4fb7-8360-e8e03cffbb1d

📥 Commits

Reviewing files that changed from the base of the PR and between e528210 and 5bb0d0a.

📒 Files selected for processing (11)
  • internal/driftguard/doc.go
  • internal/driftguard/equivalence_test.go
  • internal/driftguard/fields_test.go
  • internal/driftguard/hash_test.go
  • internal/driftguard/marshal_test.go
  • internal/schemautil/hash.go
  • joiner/equivalence.go
  • joiner/joiner_dedupe_test.go
  • parser/oas3_json.go
  • parser/paths_json.go
  • parser/schema_json.go

Comment thread internal/driftguard/equivalence_test.go Outdated
Comment thread internal/driftguard/fields_test.go
Comment thread internal/driftguard/marshal_test.go Outdated
Comment thread internal/schemautil/hash.go
Comment thread internal/schemautil/hash.go Outdated
Comment thread joiner/equivalence.go
Comment thread joiner/equivalence.go Outdated
…rd nil map values

CodeRabbit review on #416. Two of the seven were defects in this branch.

compareAdditionalPropertiesSchemas hardcodes its own path segment, so reusing it
for AdditionalItems reported every item difference under additionalProperties.
The comparison was right and the diagnostic lied about which keyword differed.
The keyword is a parameter now, in compareSchemaOrBool, and the old function is a
one-line wrapper, so a future reuse cannot misname a field either.

compareSchemaMaps handed map values straight to compareDeep, which dereferences
both operands with no nil guard. A map may hold a nil value for a present key —
`patternProperties: {"^a": }` parses that way — and comparing one against a
present schema panicked. The pre-existing Properties and DependentSchemas loops
share the hazard; this fixes the two maps the new helper added.

The rest are hygiene in the same spirit as the guard itself:

- the marshal guard matched its key as a substring of the encoded JSON, which a
  nested value could satisfy. It decodes and checks for a top-level key now: it
  exposed nothing, but a guard that passes falsely is worth nothing.
- populate panicked on a named interface field rather than reporting it
  uncovered. A panic aborts the whole guard, which is how the pointer bug during
  development reported one failure instead of nine.
- the $vocabulary boolean was the one unframed write in a function whose point is
  injectivity, so the next value type added there would have copied it.
- hashContentKeywords also hashed default and collectionFormat, which are not
  content keywords; hashValueSemantics now holds those two and both names are
  honest.
- dropped an unreachable skip for Discriminator.StringForm in a guard that only
  ever iterates Schema fields.
@erraggy

erraggy commented Jul 31, 2026

Copy link
Copy Markdown
Owner Author

All seven applied in c1e455c. Two were defects in this branch, and I confirmed both by reproduction before changing anything.

Confirmed and fixed

additionalItems reported under the wrong keyword. compareAdditionalPropertiesSchemas hardcodes its own path segment, so reusing it labelled item differences as object ones:

before: additionalItems difference reported at "additionalProperties.type"
after:  "additionalItems.type"

The comparison was correct; the diagnostic lied about which keyword differed. Extracted compareSchemaOrBool(field, …) so the keyword is a parameter, with compareAdditionalPropertiesSchemas now a one-line wrapper — a future reuse cannot misname a field either.

Nil map value panicked. Reproduced with patternProperties: {"^a": } against a present schema:

before: runtime error: invalid memory address or nil pointer dereference
after:  no panic, reports an entry presence mismatch

Guarded in compareSchemaMaps. Worth noting the pre-existing Properties and DependentSchemas loops share the hazard — this fixes the two maps the new helper introduced, not the class.

Also applied

  • Substring key match → decodes and checks for a top-level key. It exposed nothing (809 pass either way), but a guard that can pass falsely is worth nothing, so the stricter check is the right one regardless.
  • Interface assignment panic → returns "no distinctive value" instead. This one matters more than trivially: a panic aborts the entire guard. A pointer bug during development did exactly that and reported 1 failure where there were 9.
  • $vocabulary boolean framed — the one raw write in a function whose whole point is injectivity, so the next value type added there would have copied it. Folded into the existing release note.
  • hashContentKeywords split — it also hashed default and collectionFormat. hashValueSemantics holds those two now and both names are honest.
  • Unreachable skip dropped — correct, StringForm is on Discriminator and the guard only iterates Schema fields.

make check: 10203 tests, lint clean at the pinned version.

@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: 2

🤖 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 `@internal/schemautil/hash.go`:
- Around line 267-270: Update Hash and its $ref handling so reference objects
include supported sibling keywords—Default, $id, $anchor, $dynamicRef,
ContentEncoding, ContentMediaType, and ContentSchema—rather than returning after
hashing only $ref. Reuse the existing identity, value-semantics, and
content-keyword hashing helpers as appropriate, and add regression coverage for
each sibling-field class while preserving grouping for schemas with identical
reference and sibling values.

In `@joiner/equivalence.go`:
- Around line 1250-1255: Update the schema branch in compareDeep to handle
typed-nil leftSchema and rightSchema after the type assertions: record a
presence mismatch when exactly one is nil, return without descending when both
are nil, and only call compareDeep for two non-nil schemas. Add coverage for
typed-nil Schema values supplied through AdditionalItems or
AdditionalProperties.
🪄 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: f4d120f6-4a57-4a2a-8e58-c32d69300e6d

📥 Commits

Reviewing files that changed from the base of the PR and between 5bb0d0a and c1e455c.

📒 Files selected for processing (5)
  • internal/driftguard/equivalence_test.go
  • internal/driftguard/fields_test.go
  • internal/driftguard/marshal_test.go
  • internal/schemautil/hash.go
  • joiner/equivalence.go
💤 Files with no reviewable changes (1)
  • internal/driftguard/equivalence_test.go

Comment thread internal/schemautil/hash.go
Comment thread joiner/equivalence.go
…package coverage

Review follow-ups on #416.

Every skipped case in the drift guard was a deliberate exclusion, which is the
wrong way to express one: a skip checks nothing, so hashing Title by accident, or
emitting StringForm, would have gone unnoticed while the guard kept reporting
SKIP. Each is now an assertion that the exclusion holds, and a field populate
cannot build a value for fails rather than skipping, so an unhandled type is
visible instead of silently uncovered. Zero skips, more assertions.

Converting them surfaced two things. Deep comparison never compared $ref, so two
schemas pointing at different targets were reported equivalent; it compares them
now and the exclusion list is empty. And StringForm does not drop a key, it
re-spells the Discriminator as the OAS 2.0 bare string, so there is no object to
decode: that case is checked on the raw bytes, and its recorded reason says what
it actually does.

Two findings from the second CodeRabbit pass, both verified by reproduction. A
typed nil in a schema-or-bool field passed the interface nil checks and asserted
cleanly, then panicked in compareDeep. And hashSchema returned as soon as it had
written $ref, so {$ref: X, default: 1} and {$ref: X, default: 2} hashed alike,
though the comparison did split them, making it a wasted comparison rather than a
bad merge. Both fixed, both with the tests the previous round should have had.

Coverage: go test attributes coverage per package, so the guards in
internal/driftguard exercised joiner and internal/schemautil thoroughly and
registered as almost none of it, which is why the patch check failed at 31%.
-coverpkg=./... credits a test to the package it exercises. codecov.yml already
notes the same limitation where it ignores internal/testutil. Overall coverage
rises 83.7% to 85.6% because it stops discarding real coverage; the workflow
needs no change, since it uploads whatever coverage.txt make check produced.
@erraggy

erraggy commented Jul 31, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review
Now that the rate limit has passed, please continue.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

@erraggy I will continue the review for PR #416.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
joiner/equivalence.go (1)

563-565: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider maps.Equal for the $vocabulary comparison.

Vocabulary is a concrete map[string]bool. reflect.DeepEqual works but is not the most idiomatic choice for a typed map comparison; the standard library maps.Equal (Go 1.21+) expresses the same check without reflection.

♻️ Optional refactor
-	if !reflect.DeepEqual(left.Vocabulary, right.Vocabulary) {
+	if !maps.Equal(left.Vocabulary, right.Vocabulary) {
 		result.record(path, "$vocabulary", left.Vocabulary, right.Vocabulary, "$vocabulary mismatch")
 	}

As per coding guidelines, "Use Go 1.25+ syntax and features."

🤖 Prompt for 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.

In `@joiner/equivalence.go` around lines 563 - 565, Update the $vocabulary
comparison in the equivalence logic to use the standard-library maps.Equal
function for left.Vocabulary and right.Vocabulary instead of reflect.DeepEqual,
preserving the existing mismatch recording behavior.

Source: Coding guidelines

🤖 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.

Outside diff comments:
In `@joiner/equivalence.go`:
- Around line 563-565: Update the $vocabulary comparison in the equivalence
logic to use the standard-library maps.Equal function for left.Vocabulary and
right.Vocabulary instead of reflect.DeepEqual, preserving the existing mismatch
recording behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d2a60a73-545a-4547-bac3-fabde80b537b

📥 Commits

Reviewing files that changed from the base of the PR and between c1e455c and c355840.

📒 Files selected for processing (8)
  • Makefile
  • internal/driftguard/equivalence_test.go
  • internal/driftguard/hash_test.go
  • internal/driftguard/marshal_test.go
  • internal/schemautil/hash.go
  • internal/schemautil/hash_test.go
  • joiner/equivalence.go
  • joiner/equivalence_test.go

parser documents "Nil and empty maps are considered equal" and uses maps.Equal
throughout. The comparisons added in this branch used reflect.DeepEqual, which
splits those two, so the packages contradicted each other about the same pair of
schemas: a discriminator declaring `mapping: {}` differed from one declaring no
mapping under joiner and matched under parser.

$vocabulary and the discriminator mapping now use maps.Equal, and externalDocs is
compared field by field rather than by DeepEqual on the pointer, whose comparison
of Extra had the same split.

Extra keeps maps.EqualFunc with reflect.DeepEqual rather than maps.Equal, which
is the pairing parser uses: specification extensions hold arbitrary JSON, and
maps.Equal compares values with ==, which panics on a slice.

The duplication behind all of this is filed as #418, along with a second
divergence it exposed: parser compares `required` order-sensitively while joiner
and the hasher both treat it as a set.
@erraggy
erraggy merged commit 9c28590 into main Jul 31, 2026
8 checks passed
@erraggy
erraggy deleted the fix/field-accounting-drift-guard branch July 31, 2026 04:47
erraggy added a commit that referenced this pull request Jul 31, 2026
`oastools join --semantic-dedup` panicked on a valid document. `properties:
{name:}` is legal YAML and parses to a present key with a nil value, and
`compareDeep` dereferenced both operands immediately.

`CompareSchemasWithOptions` guards its own two arguments, but nothing
re-guarded the recursion, so every one of the fourteen nested walks that can
produce a nil was exposed. #416 patched two of them individually; that
approach needed each new nested walk to remember.

Guard once at the top of `compareDeep` instead, and revert the two spot fixes
to plain calls. A nil against a present schema is recorded as a difference at
the caller's path, which already names the field or entry, so the diagnostics
land where they did before. Two nils are equivalent.

The `*Schema` pointer fields (`not`, `if`, `then`, `else`, `contains`,
`propertyNames`, `contentSchema`) each carry their own presence check with a
field-specific message and are left alone; the central guard is a backstop
under them, not a replacement.

A driftguard case populates each of the twenty nested-schema fields with a nil
in turn, so the class cannot come back by way of a field added later. Thirteen
of those twenty fail without this fix.

Fixes #417
erraggy added a commit that referenced this pull request Jul 31, 2026
`oastools join --semantic-dedup` panicked on a valid document. `properties:
{name:}` is legal YAML and parses to a present key with a nil value, and
`compareDeep` dereferenced both operands immediately.

`CompareSchemasWithOptions` guards its own two arguments, but nothing
re-guarded the recursion, so every one of the fourteen nested walks that can
produce a nil was exposed. #416 patched two of them individually; that
approach needed each new nested walk to remember.

Guard once at the top of `compareDeep` instead, and revert the two spot fixes
to plain calls. A nil against a present schema is recorded as a difference at
the caller's path, which already names the field or entry, so the diagnostics
land where they did before. Two nils are equivalent.

The `*Schema` pointer fields (`not`, `if`, `then`, `else`, `contains`,
`propertyNames`, `contentSchema`) each carry their own presence check with a
field-specific message and are left alone; the central guard is a backstop
under them, not a replacement.

A driftguard case populates each of the twenty nested-schema fields with a nil
in turn, so the class cannot come back by way of a field added later. Thirteen
of those twenty fail without this fix.

Fixes #417
erraggy added a commit that referenced this pull request Jul 31, 2026
…pes (#424)

* fix(converter): report a Link Object's server name, and cover mediaTypes

`detectOAS32Features` walked only the document's own `servers`, so the one
Server Object that is not part of a servers list went unreported: converting a
3.2 document to 3.0 said nothing about a named server inside a Link. The
validator reports it as of #411, so the two disagreed — the same inconsistency
#411 set out to close, pointing the other way.

The section walk also reported `components.mediaTypes` as a container without
walking into it, so a 3.2 field on a media type defined there was lost.

`testdata/oas32-all-fields.yaml` claimed to exercise every fixed field 3.2
added, but had no `components.mediaTypes` — the field arrived with #416 and
the fixture was never extended. Adding it, and a Link with a named server,
gives both `parser/oas32_roundtrip_test.go` and the converter's feature test
something to check; adding the media types section is what exposed the
container gap above.

Both fixture files are regenerated from one source, so they stay the same
document, and `stripOAS32Extensions` learned the two new objects.

TestDownconvertReportsOAS32Fields checks field names, which cannot separate a
`name` on a Link's server from one on the document's own — it passed for the
whole time the Link went unreported. A second test pins the location for every
field name that more than one object carries.

Converting the fixture to 3.0.3 and validating the result now report the same
inventory. The only fields the converter reports and the validator does not
are `defaultMapping`, `nodeType` and `in: "querystring"`, which reach the
validator through their own rules.

Fixes #420

* fix(converter): report 3.2 fields in a deterministic order

detectOAS32Features walks the Components sections by ranging over their maps,
and Go randomizes that order, so the same document reported its issues in four
distinct orderings across eight runs of the full-field fixture. Anything
diffing conversion output between runs saw changes that were not there.

The file already had the concern, solved locally: the Tag fields are an
ordered slice rather than a map literal, with a comment saying why. The
section walks were never given the same treatment.

Sorting what the pass appended keeps the walks readable and costs one sort of
a short slice, where ordering every map's keys would allocate on documents
that report nothing. Same approach as the validator's gate in #411, so
validate and convert now agree on order as well as on inventory.

Pre-existing, but the Link and mediaTypes walks in the previous commit add two
more map ranges to it, so it is fixed here rather than filed.

* fix(testdata): use a legal component name for the media type fixture

`application/jsonl` is not a legal Components key. The spec's Components
Object requires every fixed field it declares — mediaTypes included — to use
keys matching `^[a-zA-Z0-9\.\-_]+$`, and a slash is not in that allowlist. The
key names a reusable component, not a media type.

oastools validate reported it. Nothing ran the validator on the fixture: the
parser and converter tests both reach it through the parser's structure
validation, which does not check the Components key charset, so an invalid
document satisfied every test in both packages. A test in validator now
asserts the fixture validates clean, since parser cannot import validator.

Also from review:

- the location assertions matched against one joined blob, where every path is
  a prefix of another, so a nested report could satisfy its parent's
  assertion; matched per issue against a path-and-field prefix instead
- the round-trip test checked that the new objects exist rather than what they
  hold, which is the opposite of its purpose — it exists to catch values lost
  or mutated across the two formats. It now pins itemSchema's `$ref`, the
  link's operationId, and its server URL

https://spec.openapis.org/oas/v3.2.0.html#components-object

* test(converter): cover the response-level Link, not only the component one

detectOAS32ResponseFeatures and the Components walk reach Link Objects
separately, but the fixture carried a link only under components, so removing
the response-level walk left every test green — half the fix in this branch
was unverified.

The same field-name-versus-location trap this branch already called out in
TestDownconvertReportsOAS32Fields, reintroduced in the code that fixed it.

Adds a link with a named server to the existing 200 response, asserts its path
in the converter test and its value in the round-trip test, and teaches
stripOAS32Extensions the two new objects so the fast marshal path still runs
clean.
erraggy added a commit that referenced this pull request Aug 1, 2026
* docs: correct instructions and field lists that this release invalidated

AGENTS.md and .github/copilot-instructions.md told contributors to install
golangci-lint v2.1.0. #415 pinned the version in the Makefile as
GOLANGCI_VERSION and made `make lint` refuse to run against anything else, so
following either file left you unable to run `make check` at all. Both now
point at `make lint-install` and name no version, since the Makefile is the
source of truth and a hardcoded copy drifts the moment the pin moves.

AGENTS.md also claimed `make test` runs with race detection. It does not — the
target uses -covermode=atomic, and #415 added -coverpkg=./... for cross-package
coverage attribution.

joiner/doc.go and joiner/deep_dive.md listed four documentation fields that
schema equivalence keeps apart. #416 made it seven: $comment, externalDocs and
deprecated joined title, description, example and examples.

parser/doc.go, parser/deep_dive.md and .claude/docs/oas-concepts.md listed
$self, query, additionalOperations and mediaTypes as the OAS 3.2.0 additions.
All four already existed at v1.58.0, so the lists named exactly the fields
#412 did not add and none of the ~19 it did. oas-concepts.md matters most of
the three: it is the agent-facing reference, and the validator now errors on
any 3.2 field in a pre-3.2 document, so a fixture written from the stale list
is rejected.

The documentation these changes need adding rather than correcting —
validator/doc.go on the version gate and the widened schema traversal,
converter/doc.go on 3.2 to 3.0 downgrade warnings, and the matching deep dives
— is filed as a follow-up.

* fix(converter): walk inside query and additionalOperations when downconverting

detectOAS32PathItemFeatures took its operations from parser.GetOperations,
which is version-aware and omits query and additionalOperations below 3.2.
This pass runs only when the target is below 3.2, and convertOAS3ToOAS3 sets
the document's version to the target before the pass runs — so the accessor
dropped exactly the two operations whose contents it needed to walk.

Converting 3.2 to 3.1 reported `query` itself and nothing inside it. A user
told that `query` is 3.2-only removes it, never learning that a `summary`
nested in its responses would also have been lost. Under-reporting is the
class of defect #423 and #424 set out to close.

The 3.x to 2.0 path was correct only by accident: it passes the source
document, whose version is still 3.2.

Operations are now listed explicitly, as validator/oas32_gate.go already does
for exactly this reason, and the version parameter is gone rather than left
present and ignored. additionalOperations paths also stop being fabricated:
GetOperations flattens custom methods into the operations map, so the old code
emitted `paths./p.PURGE`, a path the document does not contain. It now emits
`paths./p.additionalOperations.PURGE`, matching the validator.

Found by the pre-release quality gate. The regression test covers all three
targets; the two 3.x ones fail without this fix.

* docs: scope the 3.2 field list, and separate empty-schema from equivalence

Both from a CodeRabbit CLI pass on the release branch.

The "OAS 3.2+ Only" heading in .claude/docs/oas-concepts.md promised a 3.2
changelog while listing only the fixed fields. 3.2 also adjusted serialization
behavior, so the heading now says what the list covers and notes it is not
exhaustive.

joiner/doc.go described empty-schema detection as ignoring four metadata
fields, which read as a list. isEmptySchema tests structural constraints and
reads no documentation field at all, so a schema carrying nothing but
documentation is excluded before equivalence is ever consulted. The two rules
are now separate paragraphs rather than one sentence that conflated them.

* chore: bump plugin version to 1.59.0

* chore: add benchmark results for v1.59.0

Generated by CI benchmark workflow on chore/v1.59.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

1 participant