SIGN IN SIGN UP

checker: report a breaking change released without a major version bump (#1133)

* checker: report a breaking change released without a major version bump

Closes the first case in #1007. A new rule, api-major-version-not-bumped,
compares info.version against the severity of the changes found: when a
breaking change ships and the major version did not increase, it says so.

Per the design discussion on the issue, it is a regular rule rather than
an opt-in flag, so it flows through --severity-levels, --fail-on,
--err-ignore and the action wrappers with no new surface. It defaults to
INFO, which means it never fails a build on its own; teams that want it
enforced raise it to ERR.

Being quiet by default is what makes that safe, and it required narrowing
the issue's second case. Reporting "the spec changed but the version was
not bumped" fires on nearly every real comparison: of the 174 spec pairs
in this repo's test data, three change info.version at all. An unchanged
version is not evidence of an oversight, it is the normal state of a spec
that doesn't use info.version as a release signal, and oasdiff can't tell
the two apart. So the rule speaks only when the version did change, which
is the team's own evidence that they track releases there. Non-semver
versions are skipped for the same reason: nothing defines a major bump.

A version moving backwards is left unjudged (recorded as a symmetry
waiver, since the mirror rule would be a version decrease): nothing was
bumped, so "the bump was too small" would be the wrong thing to say.

Below 1.0.0 a minor bump satisfies the policy, as semver gives the minor
the major's role there.

Mechanics: the rule runs after the checks, since its input is their
output, and before the level filter so it sees changes of every level.
Diff gains a Versions field carrying info.version from both specs,
because InfoDiff is nil exactly when the version is unchanged; it is set
only on a non-empty diff so Empty() keeps its meaning, and is excluded
from output. Findings use a new InfoChange type, the first rule in
AreaInfo: nothing in info can break a client, but info.version is the
declared shape of the change and can contradict it.

* checker: report every way a breaking change misses its major bump

Review feedback: the rule fired on a patch bump but stayed silent when
the version was not bumped at all, which is the worse violation. That
was incoherent, and it came from narrowing the policy to avoid noise.

The policy is one statement, a breaking change requires a major version
increase, with three ways to violate it, each its own id so teams can
tune or silence them separately:

  api-version-not-bumped        the version is unchanged
  api-version-decreased         the version moved backwards
  api-major-version-not-bumped  it moved, but not the major

Requiring a breaking change (rather than any change, as the issue first
proposed) is what keeps this quiet: a spec that never bumps its version
now hears about it only on breaking releases, not on every comparison.
Non-semver versions are still skipped, since nothing defines a major
bump for them.

api-version-decreased fills the mirror the symmetry guard wanted, so the
waiver added with the previous commit is removed.

Also from review: drop claimed changes before the policy runs rather
than re-checking the claimed flag inside it, so there is one definition
of what survives the pipeline and the policy cannot drift from it.

Test helpers switch the policy off by default: it runs on whatever the
checks found rather than on a spec element, so it would otherwise add a
finding to every test whose fixture carries a breaking change under an
unbumped version, which is most of them. Tests that exercise the policy
build their own config.

Docs cover the three ids, how to enforce them, and how to turn them off
with none.

* diff: carry info as Base/Revision instead of a bespoke VersionPair

Review feedback: the other diff structs that need the compared values
expose them as Base/Revision (PathsDiff, SchemaDiff, MethodDiff, ...),
so a new VersionPair type was the wrong shape. Diff now carries
BaseInfo/RevisionInfo *openapi3.Info, following that convention and
generalizing beyond info.version for any future info rule.

They hang off Diff rather than InfoDiff because InfoDiff is nil exactly
when info is unchanged, which is the case the versioning policy cares
about most. Populating InfoDiff instead was measured: it emits
"info": {} into every diff whose info did not change, a false signal to
every consumer of the diff output.

As before, the fields are set only on a non-empty diff, so Empty() and
--fail-on-diff keep their meaning, and are excluded from output.
R
Reuven Harrison committed
fb8babb92c123991e7cff4500bb35cafe97e5f7b
Parent: 94c30cf
Committed by GitHub <noreply@github.com> on 7/30/2026, 7:27:35 PM