Skip to content

fix: only treat schemas on a reference loop as circular - #667

Merged
daveshanley merged 1 commit into
pb33f:mainfrom
josephaxisa:fix/662-circular-check-per-schema
Oct 24, 2026
Merged

daveshanley merged 1 commit into
pb33f:mainfrom
josephaxisa:fix/662-circular-check-per-schema

Conversation

@josephaxisa

@josephaxisa josephaxisa commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #662 .

CheckSchemaProxyForCircularRefs returned true for every schema proxy once a document had a single circular reference anywhere: the journey check kept one dup map across all results, and every journey repeats its loop point. That had two effects:

  • CompareSchemas returned nil in its $ref branches, so $ref changes to schemas that are not on any loop were dropped (the repro in $ref changes are dropped when a document has any circular reference #662).
  • With UseSchemaQuickHash (which openapi-changes turns on), every inline schema proxy hashed to 0, so changes inside inline schemas were skipped. For example, an inline name changing from string to integer is not reported at all if the document also contains A -> B -> A.

The check now asks whether this schema is on a loop: the part of each journey from its loop point on, matched against the node the proxy's reference resolves to. Matching by node is needed because a relative reference such as b.yaml#/B never equals the journey's absolute definitions. Inline schemas are not references, so they are never circular. If a loop point only appears at the end of a journey (the resolver's depth limit), the whole journey counts, as before. The existing LoopPoint check and the string match against journey entries are kept, so TestCheckSchemaProxyForCircularRefs_JourneyCheck passes unchanged.

Comparing the $ref string only with ref.Journey would hang on cross-file cycles, because the quick hash path recurses through any reference the check misses. TestCompareOpenAPIDocuments_CrossFileCircularReference covers that case. The resolver records one loop per loop point, so in A -> B -> A plus A -> C -> A only one is listed. C isn't reported as circular, but anything that follows C reaches A, which is, so recursion still stops there
(covered by TestCompareOpenAPIDocuments_UnrelatedCircularReference).

This PR doesn't change what CompareSchemas does with a schema that really is on a loop: its $ref change is still dropped by the return nil in those branches. The PR for #663 changes that to keep the recorded change. I kept it out of this PR so the two don't edit the same lines.

Tests (each fails on main):

  • TestCheckSchemaProxyForCircularRefs_JourneyCheck_LeadIn: a schema that only leads into a loop.
  • TestCheckSchemaProxyForCircularRefs_JourneyCheck_DepthLimit: the loop point only ends the journey.
  • TestCheckSchemaProxyForCircularRefs_ResolvesReference: references and child proxies resolved against absolute journey definitions.
  • TestCheckSchemaProxyForCircularRefs_OnlyLoopMembers: on a real document.
  • TestCompareSchemas_RefToInline_UnrelatedCircularReference: the $ref changes are dropped when a document has any circular reference #662 repro.
  • TestCompareOpenAPIDocuments_UnrelatedCircularReference: inline change with quick hash, in a document with two loops through the same schema.
  • TestCompareOpenAPIDocuments_CrossFileCircularReference: a.yaml <-> b.yaml with quick hash, new fixtures in test_specs/circular_cross_file.

circ_check.go is at 100% coverage. The full suite passes apart from FuzzAppendJSONString, which also fails on main with Go 1.27. openapi-changes' tests pass against this branch.

Stripe's spec3.json (it has circular references), compared with quick hash on:

Versions main this PR main, quick hash off
2025-11-12 → 2025-12-08 79 changes, 7 breaking 1,169, 14 breaking 1,168, 14 breaking
2024-01-22 → 2026-09-29 2,071, 87 breaking 7,516, 407 breaking 7,469, 407 breaking

Without a file path, results now match quick hash off: identical on three of five version pairs, and on the other two the only difference is 1 and 47 extra non-breaking $ref entries, which come from the quick hash itself. When the document is loaded with SpecFilePath set, as openapi-changes does, quick hash reports a change behind an identical $ref at each place it's referenced. Documents without cycles already work this way on main, and Stripe now does too: 7,799 changes for the first pair, and about 1 s and 1.3 GB instead of 0.26 s and 320 MB. Whether that per-reference reporting is wanted is a separate question, so I didn't change it here.

Related: #663, which needs this to work in documents that contain a cycle.

CheckSchemaProxyForCircularRefs returned true for every schema proxy as
soon as a document had one circular reference: the journey check kept a
single dup map across all results, and every journey repeats its loop
point. Inline schemas then hashed to 0 with UseSchemaQuickHash, so their
changes were skipped, and CompareSchemas dropped $ref changes.

Check membership of the loop instead: the part of each journey from the
loop point on (all of it when the loop point only ends the journey, as
at the resolver's depth limit), matched by the node the reference
resolves to, since a relative reference such as 'b.yaml#/B' never
matches the journey's absolute definitions. Inline schemas are not
references and are never circular.

Fixes pb33f#662
@codecov

codecov Bot commented Oct 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (e0e2f84) to head (b873701).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #667   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          309       309           
  Lines        39178     39197   +19     
=========================================
+ Hits         39178     39197   +19     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

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

@daveshanley daveshanley left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Great find, and a great write-up. I ran test_specs/stripe.yaml against a copy with 300 type: string → type: integer edits:

changes breaking time alloc
main, quick hash on 0 0 35ms 47MB
this PR, quick hash on 300 300 157ms 254MB
quick hash off 300 300 97ms 171MB

Main reports nothing on a document with a cycle when quick hash is on. Ouch. The extra cost is real hashing (the profile puts it in hashHashable, the circular check itself doesn't show up), so I'm happy with it. Quick hash now loses to no quick hash on cyclic docs, which is worth a follow-up.

Good to go. This needs to land before #668.

@daveshanley
daveshanley merged commit 3a936ec into pb33f:main Oct 24, 2026
4 checks passed
@josephaxisa
josephaxisa deleted the fix/662-circular-check-per-schema branch October 25, 2026 03:21
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.

$ref changes are dropped when a document has any circular reference

2 participants