Design notes

Pipeline

load_spec(base)  --\
                    >-- diff_specs(old, new) --> [Change] --> apply_policy --> advise --> report
load_spec(head)  --/

Each Change carries a catalogue id (which fixes its severity and changelog category), a message, a JSON pointer, and the operation it belongs to.

Loading and $ref resolution

loader.py parses the root document (JSON if it starts with {, else YAML with timestamps kept as strings), then walks it replacing every $ref with the resolved target:

The differ guards against cycles with the set of (id(old), id(new), direction) pairs currently being compared; meeting a pair again means we are inside a recursive schema and the branch has already been compared.

Matching operations

A path template is normalised by replacing every {name} with {}. Old and new operations are paired by (method, normalised template):

old new result
/pets/{id} /pets/{petId} same endpoint, one path-param-renamed (info)
/pets/{id} /animals/{id} operation-removed + operation-added

Path parameters are matched by position in the template, so renaming id to petId does not show up as a removed and an added required parameter. Other parameters match on (in, name) (header names case-insensitively). A leftover removed/added pair sharing a name is reported as one parameter-location-changed. Path-level parameters are merged under operation-level ones first.

Directions

SchemaDiff.diff(a, b, pointer, direction) is called with "request" for request bodies and parameters, and "response" for response bodies and headers. The direction becomes a prefix of the change id, and the catalogue assigns it the right severity. Worked example, Payment.status enum [pending, succeeded, failed] becoming [pending, succeeded, failed, refunded]:

readOnly properties are invisible in requests and writeOnly ones in responses, so adding a required id that is readOnly is not a breaking request change.

Normalising schemas

Before comparing, each schema pair goes through:

  1. allOf flattening. Members are merged: properties are unioned (a property defined by two members becomes allOf of both and is flattened when recursed into), required is unioned, other keywords take the first value. allOf: [A, B] and the inlined equivalent compare equal.
  2. Type normalisation. types_of returns (set of non-null types, nullable). OpenAPI 3.0 nullable: true, 3.1 type: [string, "null"] and null inside an enum all produce nullable = True. A schema with no type infers one from its keywords (properties means object, items means array, enum values give their Python types) and is otherwise unconstrained. integer is a subtype of number.
  3. Constraint folding. 3.0 boolean exclusiveMaximum: true + maximum: 9 becomes the 3.1 numeric exclusiveMaximum: 9. const is treated as a one-value enum.

Type changes are classified by containment: new type set contains the old = widened, contained by the old = narrowed, neither = changed (and the comparison stops there, since nested differences would be noise).

oneOf / anyOf

Members have no names, so they are paired by similarity (schema_util.match_members):

Pairs are taken greedily best-first. Matched pairs are compared recursively; unmatched old members are *-member-removed, unmatched new members *-member-added. If only one side uses a combinator, the other side is treated as a one-member list, so Pet turning into oneOf: [Pet, Wallet] yields just a member-added change and not a pile of spurious property removals.

Worked example: v1 Payment.method is oneOf: [Card, BankTransfer], v2 adds Wallet. Card and BankTransfer pair with their v2 versions (identical type constants), Wallet is left over: response-oneof-member-added (potentially breaking), plus response-discriminator-mapping-added for the new wallet tag. Card.brand also gained an enum, matched through the paired member and reported at .../method/oneOf/0/properties/brand/enum.

Security

Effective requirements per operation are operation.security if present, else the document's security. An empty list, or a requirement {}, means anonymous access is allowed.

Scheme definitions are compared separately (type, HTTP scheme, API key location, OAuth flows, URLs, scopes).

Policy

Rules are evaluated per change in file order; the first live match wins. A rule matches when the id glob matches and the path glob matches any of: the path template, METHOD path, or the JSON pointer. A rule whose expires date has passed no longer matches, and a warning is attached to the result so the expiry is visible in the report instead of silently turning CI red.

Semver advice

condition bump
any breaking change major
any potentially-breaking change, or any change in the "Added" category minor
only other changes (deprecations, tightened responses, ...) patch
nothing none

The suggested number is computed from the base spec's info.version when it is X.Y.Z. Allowed changes still count; policy affects CI status, not the truth about the API.