]...
+```
+
+## Options
+
+| Option | Type | Description |
+| --------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| base | string | **REQUIRED.** Path, URL, or [alias](../configuration/reference/apis.md) of the older API description. |
+| revision | string | **REQUIRED.** Path, URL, or alias of the newer API description. |
+| --check-version | boolean | Fail if `info.version` of the revision is lower than the version that the changes require. See [Check the version](#check-the-version). Default value is `false`. |
+| --config | string | Specify path to the [configuration file](../configuration/index.md). |
+| --fail-on | string | Exit with code `1` if a change has this impact or a higher impact.
**Possible values:** `major`, `minor`, `patch`, `none`. Default value is `major`. |
+| --format | string | Format for the output. See [Output formats](#output-formats).
**Possible values:** `stylish`, `json`, `markdown`, `html`, `github-actions`, `next-version`. Default value is `stylish`. |
+| --help | boolean | Show help. |
+| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. |
+| --output, -o | string | Write the report to this file. All formats support this option, except `github-actions`. |
+| --skip-rule | [string] | Turn off these [diff rules](../rules/diff-rules.md) for this run. |
+| --version | boolean | Show version number. |
+
+## How the command compares descriptions
+
+- The [diff rules](../rules/diff-rules.md) find the changes that can break a client and set their impact.
+ A change that no rule reports is `minor` if you add something, and `patch` in all other cases.
+- A new component is `patch`, because it has no effect until something uses it.
+- The command follows `$ref`s, also into other files.
+ It shows a change in a component at the first place that uses the component, with the line of the change in its file.
+- If you rename an item, such as a path, a property, or a parameter, and the new item is similar to the old item, the command shows one changed item.
+ It does not show a removed item and an added item.
+- A new order of list items, such as parameters or `oneOf` subschemas, is not a change.
+- The command applies the preprocessors from your configuration, but not the [decorators](../decorators.md).
+ To compare the descriptions that you publish, bundle them with your decorators first.
+- The diff rules support OpenAPI 3.x and AsyncAPI 3.
+ For other description types, the command lists the changes, but all changes are `minor` or `patch`.
+- The command cannot compare descriptions of different types, for example OpenAPI 2.0 and OpenAPI 3.1.
+
+## Output formats
+
+The command prints the report to stdout and all other messages to stderr.
+
+| Format | Output |
+| ---------------- | ----------------------------------------------------------------------------------------------------------- |
+| `stylish` | The changes grouped by path, webhook, channel, operation, or server, with rule messages and file locations. |
+| `json` | All data of each change. Use this format in scripts. |
+| `markdown` | A table of the changes for a pull request comment. |
+| `html` | A standalone page that you open in a browser. |
+| `github-actions` | An error annotation for each rule message of a `major` change. |
+| `next-version` | Only the next version, for example `2.0.0`. See [Get the next version](#get-the-next-version). |
+
+## Examples
+
+### Stop breaking changes in CI
+
+By default, the command exits with code `1` if it finds a `major` change:
+
+```bash
+redocly diff main/openapi.yaml openapi.yaml
+```
+
+To also fail on new features, use `--fail-on=minor`.
+To never fail, use `--fail-on=none`.
+
+### Check the version
+
+With `--check-version`, the command also fails if `info.version` of the revision is lower than the version that the changes require:
+
+```bash
+redocly diff main/openapi.yaml openapi.yaml --check-version
+```
+
+For example, if the base version is `1.2.0` and a change is `major`, the revision version must be `2.0.0` or higher:
+
+```text
+❌ info.version went 1.2.0 → 1.3.0, but these changes require a major bump (2.0.0).
+```
+
+- Below version `1.0.0`, semver allows a `minor` release to break the API.
+ Thus, a `major` change from `0.4.2` requires `0.5.0` or higher.
+- The command compares only the `major.minor.patch` numbers.
+ It ignores pre-release and build labels.
+- A change to `info.version` does not require a bump.
+- If the changes require a bump and `info.version` is not a semver string, the check fails.
+
+### Get the next version
+
+The `next-version` format prints the base version, bumped as the changes require:
+
+```bash
+NEXT_VERSION=$(redocly diff main/openapi.yaml openapi.yaml --format=next-version --fail-on=none)
+```
+
+If no change requires a bump, the command prints the base version.
+Use `--fail-on=none`, so that a `major` change does not stop your script.
+
+### Comment on a pull request
+
+Write the report to a file, and attach the file to a pull request comment:
+
+```bash
+redocly diff main/openapi.yaml openapi.yaml --format=markdown -o diff.md
+```
+
+To show each breaking change on the changed line, use the `github-actions` format in a GitHub Actions workflow:
+
+```bash
+redocly diff main/openapi.yaml openapi.yaml --format=github-actions
+```
+
+### Compare two APIs by their aliases
+
+If the `apis` section of `redocly.yaml` has both descriptions, use their aliases:
+
+```yaml
+apis:
+ cafe@v1:
+ root: v1/openapi.yaml
+ cafe@v2:
+ root: v2/openapi.yaml
+```
+
+```bash
+redocly diff cafe@v1 cafe@v2
+```
+
+### Change the impact of a rule
+
+To change the impact of a rule, or to turn it off, add a [`diff` section](../configuration/reference/diff.md) to `redocly.yaml`:
+
+```yaml
+extends:
+ - diff-recommended
+diff:
+ enum-values-added: minor
+```
+
+## Resources
+
+- [Diff rules](../rules/diff-rules.md)
+- [`diff` configuration](../configuration/reference/diff.md)
diff --git a/docs/@v2/commands/index.md b/docs/@v2/commands/index.md
index fa93d04a3e..2c1d02eaa7 100644
--- a/docs/@v2/commands/index.md
+++ b/docs/@v2/commands/index.md
@@ -24,6 +24,7 @@ Docs linting commands:
CI and delivery commands:
- [`bundle`](bundle.md) Bundle an API description.
+- [`diff`](diff.md) Compare two API descriptions and rate each change by the semver bump it requires [experimental feature].
- [`join`](join.md) Join API descriptions [experimental feature].
API testing and drift commands:
diff --git a/docs/@v2/configuration/index.md b/docs/@v2/configuration/index.md
index 5ba7d37a2f..0a559745ec 100644
--- a/docs/@v2/configuration/index.md
+++ b/docs/@v2/configuration/index.md
@@ -112,6 +112,19 @@ recheck:
The `lint` command ignores the Recheck presets and the `recheck` block, and the `recheck` command ignores API rulesets.
For more information, visit the [`recheck` block reference](./reference/recheck.md) and the [Markdown and prose linting](../recheck/index.md) section.
+### Configure `diff` rules
+
+In the `diff` section, set the impact that each [diff rule](../rules/diff-rules.md) gives to a change: `major`, `minor`, `patch`, or `off`.
+The [`diff` command](../commands/diff.md) uses these rules to compare two versions of an API.
+See the [`diff` reference](./reference/diff.md) for all options.
+
+```yaml
+extends:
+ - diff-recommended
+diff:
+ enum-values-added: minor
+```
+
### Configure OpenAPI features and documentation styles
diff --git a/docs/@v2/configuration/reference/diff.md b/docs/@v2/configuration/reference/diff.md
new file mode 100644
index 0000000000..fa0c4589fa
--- /dev/null
+++ b/docs/@v2/configuration/reference/diff.md
@@ -0,0 +1,69 @@
+# `diff`
+
+## Introduction
+
+The `diff` section sets the impact of each [diff rule](../../rules/diff-rules.md).
+The [`diff` command](../../commands/diff.md) uses these impacts.
+
+Use the `diff` section only at the root of a configuration file.
+[API-specific sections](./apis.md) do not support it.
+
+{% admonition type="warning" name="Experimental" %}
+The `diff` section is an experimental feature.
+It can change in future releases.
+{% /admonition %}
+
+## Options
+
+{% table %}
+
+- Option
+- Type
+- Description
+
+---
+
+- {rule name}
+- string
+- **REQUIRED**. The impact of the changes that the rule reports.
+ Possible values: `major`, `minor`, `patch`, `off`.
+
+{% /table %}
+
+To set an impact for one specification version, use one of these sections.
+They have the same options.
+
+- `oas3_0Diff`
+- `oas3_1Diff`
+- `oas3_2Diff`
+- `async3Diff`
+
+## Examples
+
+This example adds the `diff-recommended` ruleset, sets a lower impact for one rule, and turns off another rule:
+
+```yaml
+extends:
+ - diff-recommended
+diff:
+ enum-values-added: minor
+ schema-constraint-changed: off
+```
+
+This example changes the impact of a rule for OpenAPI 3.1 descriptions only:
+
+```yaml
+extends:
+ - diff-recommended
+oas3_1Diff:
+ schema-type-changed: minor
+```
+
+## Related options
+
+- [extends](./extends.md) adds the `diff-recommended` ruleset.
+
+## Resources
+
+- [Diff rules](../../rules/diff-rules.md)
+- [`diff` command](../../commands/diff.md)
diff --git a/docs/@v2/rules.md b/docs/@v2/rules.md
index 0ffcf2dbbd..40d0cf569b 100644
--- a/docs/@v2/rules.md
+++ b/docs/@v2/rules.md
@@ -23,6 +23,7 @@ Rulesets are groups of rules that are applied together, and APIs can be checked
- A [recommended](./rules/recommended.md) ruleset is our unabashedly opinionated recommendation of what we think a good API looks like. It's a great place to start, before adapting to your own context.
- A [recommended-strict](./rules/recommended.md#recommended-strict-ruleset) ruleset is identical to the `recommended`, except it elevates all warnings to errors. It's the ideal option for those who don't want to miss anything.
- A [minimal](./rules/minimal.md) ruleset is a good starting point for an existing API that doesn't currently conform to any standard. It has fewer rules that cause an error, with others either downgraded to a warning or turned off completely.
+- The [diff-recommended](./rules/diff-rules.md#diff-recommended-ruleset) ruleset holds the rules that the `diff` command uses to find breaking changes between two versions of an API.
Enable a ruleset by adding a block like this to the Redocly configuration file:
diff --git a/docs/@v2/rules/diff-rules.md b/docs/@v2/rules/diff-rules.md
new file mode 100644
index 0000000000..62b842e58b
--- /dev/null
+++ b/docs/@v2/rules/diff-rules.md
@@ -0,0 +1,141 @@
+---
+slug: /docs/cli/rules/diff-rules
+---
+
+# Diff rules
+
+The [`diff` command](../commands/diff.md) uses diff rules to find the changes that can break a client.
+Each rule finds one type of change, for example a removed operation.
+When a rule finds a change, the rule shows a message, and the change gets the impact that you set for the rule.
+
+{% admonition type="warning" name="Experimental" %}
+Diff rules are an experimental feature.
+Their names, messages, and configuration can change in future releases.
+{% /admonition %}
+
+## `diff-recommended` ruleset
+
+The `diff-recommended` ruleset turns on all diff rules with the `major` impact.
+The `diff` command uses this ruleset if you do not have a `redocly.yaml` file.
+
+If you have a `redocly.yaml` file, only the rules from that file run.
+To use the ruleset, add it to `extends`:
+
+```yaml
+extends:
+ - recommended
+ - diff-recommended
+```
+
+## Configure diff rules
+
+Set the impact of a rule in the [`diff` section](../configuration/reference/diff.md) of `redocly.yaml`.
+The impact is `major`, `minor`, `patch`, or `off`:
+
+```yaml
+extends:
+ - diff-recommended
+diff:
+ enum-values-added: minor
+ schema-constraint-changed: off
+```
+
+To turn off rules for one run, use the `--skip-rule` option:
+
+```bash
+redocly diff v1.yaml v2.yaml --skip-rule=enum-values-added
+```
+
+## Requests and responses
+
+Many rules check the direction of the data, because a change can break one direction only.
+For example, a new `enum` value is safe in a request, but it can break a client that reads a response.
+
+- OpenAPI: parameters and request bodies are requests, and responses are responses.
+ In `callbacks` and `webhooks`, the API sends the request, so the directions are opposite.
+- AsyncAPI 3: the messages of an operation with `action: receive` are requests, and the messages of an operation with `action: send` are responses.
+ A `reply` has the opposite direction.
+- A schema with `readOnly: true` is response data, and a schema with `writeOnly: true` is request data.
+- A component has the directions of all places that use it.
+ If a request and a response use the same schema, the rules check its changes in both directions.
+ A component that nothing uses has no direction.
+
+## Rules
+
+The rules apply to OpenAPI 3.x and AsyncAPI 3, except the rules that name one of them.
+
+### Paths, operations, and servers
+
+| Rule | Reports |
+| -------------------------- | -------------------------------------------------------------------------------------------- |
+| `path-removed` | A removed or renamed path, or a removed webhook, callback, or callback URL. OpenAPI only. |
+| `operation-removed` | A removed operation. |
+| `operation-id-changed` | A changed or removed `operationId`. Generated clients use it as a method name. OpenAPI only. |
+| `operation-action-changed` | A changed `action` of an operation. AsyncAPI only. |
+| `server-removed` | A removed server, or a changed `url`, `host`, `pathname`, or `protocol` of a server. |
+
+### Channels and messages
+
+These rules apply to AsyncAPI only.
+
+| Rule | Reports |
+| ------------------------------ | ------------------------------------- |
+| `channel-removed` | A removed channel. |
+| `channel-address-changed` | A changed `address` of a channel. |
+| `message-removed` | A removed message. |
+| `message-content-type-changed` | A changed `contentType` of a message. |
+
+### Parameters, request bodies, and responses
+
+These rules apply to OpenAPI only.
+
+| Rule | Reports |
+| --------------------------------- | ----------------------------------------------------------------------------------- |
+| `parameter-removed` | A removed or renamed parameter, or a changed `in` of a parameter. |
+| `parameter-became-required` | A new required parameter, or a parameter that became required. |
+| `parameter-serialization-changed` | A changed `style`, `explode`, `allowReserved`, or `allowEmptyValue` of a parameter. |
+| `request-body-removed` | A removed request body. |
+| `request-body-became-required` | A request body that became required. |
+| `response-removed` | A removed response, or a changed status code of a response. |
+| `response-header-removed` | A removed or renamed response header. |
+| `media-type-removed` | A removed or renamed media type. |
+
+### Schemas
+
+| Rule | Reports |
+| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `property-removed` | A removed or renamed property in a response, or in a request whose schema rejects unlisted properties (`additionalProperties: false` or `unevaluatedProperties: false`). |
+| `required-properties-added` | Properties that became required in a request. |
+| `required-properties-removed` | Properties that are no longer required in a response. |
+| `enum-values-removed` | Removed `enum` values, or a new `enum`, in a request. |
+| `enum-values-added` | New `enum` values, or a removed `enum`, in a response. |
+| `schema-type-changed` | A changed `type` or `nullable` that accepts fewer types in a request, or more types in a response. |
+| `schema-constraint-changed` | A changed constraint that accepts fewer values in a request, or more values in a response. |
+| `schema-combinator-changed` | An added or removed subschema of `allOf`, `anyOf`, or `oneOf`, or an added or removed list of them, that accepts fewer values in a request, or more values in a response. OpenAPI only. |
+
+`schema-constraint-changed` checks these keywords:
+
+- Limits: `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `minLength`, `maxLength`, `minItems`, `maxItems`, `minProperties`, `maxProperties`.
+- Values: `multipleOf`, `pattern`, `format`, `const`, `contentEncoding`, `contentMediaType`.
+ The rule cannot compare two values of these keywords, so it reports a changed value in both directions.
+- Flags: `uniqueItems: true`, and `false` for `additionalProperties`, `items`, `unevaluatedItems`, or `unevaluatedProperties`.
+- Subschemas: `not`, `then`, `else`, `contains`, `propertyNames`, `items`, `prefixItems`, `additionalProperties`, `unevaluatedItems`, `unevaluatedProperties`, `dependentSchemas`, `dependentRequired`.
+
+### Security
+
+These rules apply to OpenAPI only.
+
+| Rule | Reports |
+| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
+| `security-requirement-changed` | A `security` change that a client with the old credentials cannot pass: new authentication, a new scheme or scope, or a removed requirement. |
+| `security-scheme-changed` | A removed security scheme, or a changed `type`, `scheme`, `in`, `name`, `bearerFormat`, or `openIdConnectUrl` of a security scheme. |
+
+## Changes that the rules do not report
+
+These changes do not break a client:
+
+- A renamed path variable, for example `/menu/{id}` to `/menu/{itemId}`, and the renamed path parameter.
+- A renamed webhook. Its key is only a name for the documentation.
+- A changed case of a header name.
+- A removed `servers` list of a path or an operation. The path or operation then uses the servers of the document.
+- A new `operationId` where there was none.
diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml
index 534c941845..bfcdfeef93 100644
--- a/docs/@v2/v2.sidebars.yaml
+++ b/docs/@v2/v2.sidebars.yaml
@@ -14,6 +14,8 @@
page: commands/bundle.md
- label: check-config
page: commands/check-config.md
+ - label: diff
+ page: commands/diff.md
- label: drift
page: commands/drift.md
- label: eject
@@ -206,6 +208,7 @@
- page: rules/arazzo/workflow-dependsOn.md
- page: rules/arazzo/workflowId-unique.md
+ - page: rules/diff-rules.md
- page: rules/configure-rules.md
label: Rule configuration
- page: rules/configurable-rules.md
@@ -246,6 +249,7 @@
- page: configuration/reference/apis.md
- page: configuration/reference/client.md
- page: configuration/reference/decorators.md
+ - page: configuration/reference/diff.md
- page: configuration/reference/extends.md
- page: configuration/reference/plugins.md
- page: configuration/reference/preprocessors.md
diff --git a/package-lock.json b/package-lock.json
index da00fbcbbc..979868a72a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4455,12 +4455,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/js-levenshtein": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.1.tgz",
- "integrity": "sha512-qC4bCqYGy1y/NP7dDVr7KJarn+PbX1nSpwA7JXdu0HxT3QYjO8MJ+cntENtHFVy2dRAyBV23OZ6MxsW1AM1L8g==",
- "dev": true
- },
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
@@ -7039,6 +7033,15 @@
"fxparser": "src/cli/cli.js"
}
},
+ "node_modules/fastest-levenshtein": {
+ "version": "1.0.16",
+ "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
+ "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.9.1"
+ }
+ },
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -7950,6 +7953,7 @@
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
+ "dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13688,8 +13692,8 @@
"ajv": "npm:@redocly/ajv@^8.18.3",
"ajv-formats": "^3.0.1",
"colorette": "^1.2.0",
+ "fastest-levenshtein": "^1.0.16",
"graphql": "^16.14.1",
- "js-levenshtein": "^1.1.6",
"js-yaml": "^5.2.2",
"json-schema-to-ts": "^3.1.0",
"jsonpath-rfc9535": "1.3.0",
@@ -13698,7 +13702,6 @@
"yaml-ast-parser": "0.0.43"
},
"devDependencies": {
- "@types/js-levenshtein": "^1.1.0",
"@types/picomatch": "^4.0.2",
"@types/pluralize": "^0.0.29",
"typescript": "6.0.2"
diff --git a/packages/cli/src/__tests__/fixtures/config.ts b/packages/cli/src/__tests__/fixtures/config.ts
index a871382e36..8c138c29bc 100644
--- a/packages/cli/src/__tests__/fixtures/config.ts
+++ b/packages/cli/src/__tests__/fixtures/config.ts
@@ -16,7 +16,16 @@ export const configFixture: Config = {
skipPreprocessors: vi.fn(),
saveIgnore: vi.fn(),
skipDecorators: vi.fn(),
+ skipDiffRules: vi.fn(),
+ getDiffRulesForSpecVersion: vi.fn(() => []),
+ getDiffImpact: vi.fn(() => 'off' as const),
ignore: {},
+ diff: {
+ oas3_0: {},
+ oas3_1: {},
+ oas3_2: {},
+ async3: {},
+ },
decorators: {
oas2: {},
oas3_0: {},
diff --git a/packages/cli/src/commands/diff/__tests__/check-version.test.ts b/packages/cli/src/commands/diff/__tests__/check-version.test.ts
new file mode 100644
index 0000000000..8395de9a12
--- /dev/null
+++ b/packages/cli/src/commands/diff/__tests__/check-version.test.ts
@@ -0,0 +1,52 @@
+import { checkVersion, nextVersion } from '../check-version.js';
+
+describe('checkVersion', () => {
+ it('passes when the declared bump matches or exceeds the required one', () => {
+ expect(checkVersion({ base: '1.2.0', revision: '1.3.0', required: 'minor' })).toBeUndefined();
+ expect(checkVersion({ base: '1.2.0', revision: '2.0.0', required: 'patch' })).toBeUndefined();
+ });
+
+ it('fails when the version moved less than the changes require', () => {
+ expect(checkVersion({ base: '1.2.0', revision: '1.3.0', required: 'major' })).toBe(
+ '❌ info.version went 1.2.0 → 1.3.0, but these changes require a major bump (2.0.0).'
+ );
+ });
+
+ it('fails when the version did not move although something changed', () => {
+ expect(checkVersion({ base: '1.2.0', revision: '1.2.0', required: 'patch' })).toBe(
+ '❌ info.version stayed 1.2.0, but these changes require a patch bump (1.2.1).'
+ );
+ });
+
+ it('lets a minor stand in for a major while below 1.0', () => {
+ expect(checkVersion({ base: '0.3.1', revision: '0.4.0', required: 'major' })).toBeUndefined();
+ expect(checkVersion({ base: '0.3.1', revision: '0.3.2', required: 'major' })).toBe(
+ '❌ info.version went 0.3.1 → 0.3.2, but these changes require a minor bump (0.4.0) while below 1.0.'
+ );
+ });
+
+ it('fails when either version is not semver', () => {
+ expect(checkVersion({ base: 'v1', revision: '1.0.0', required: 'patch' })).toBe(
+ '❌ info.version is not a semver string, so --check-version cannot compare it.'
+ );
+ });
+
+ it('passes when nothing changed', () => {
+ expect(checkVersion({ base: 'v1', revision: 'v1', required: undefined })).toBeUndefined();
+ });
+});
+
+describe('nextVersion', () => {
+ it('should bump the base version as the changes require, a minor for a major below 1.0', () => {
+ expect(nextVersion('1.4.2', 'major')).toBe('2.0.0');
+ expect(nextVersion('0.4.2', 'major')).toBe('0.5.0');
+ });
+
+ it('should keep the base version when nothing requires a bump', () => {
+ expect(nextVersion('1.4.2', undefined)).toBe('1.4.2');
+ });
+
+ it('should give nothing for a base version that is not semver', () => {
+ expect(nextVersion('v1', 'patch')).toBeUndefined();
+ });
+});
diff --git a/packages/cli/src/commands/diff/__tests__/fail-on.test.ts b/packages/cli/src/commands/diff/__tests__/fail-on.test.ts
new file mode 100644
index 0000000000..78697fcb36
--- /dev/null
+++ b/packages/cli/src/commands/diff/__tests__/fail-on.test.ts
@@ -0,0 +1,24 @@
+import { getDiffFailure } from '../fail-on.js';
+
+const summary = { major: 2, minor: 1, patch: 3 };
+
+describe('getDiffFailure', () => {
+ it.each([
+ ['major', '❌ Diff failed with 2 major changes.'],
+ ['minor', '❌ Diff failed with 2 major and 1 minor changes.'],
+ ['patch', '❌ Diff failed with 2 major and 1 minor and 3 patch changes.'],
+ ['none', undefined],
+ ] as const)('with --fail-on=%s reports %s', (failOn, expected) => {
+ expect(getDiffFailure(summary, failOn)).toBe(expected);
+ });
+
+ it('passes when nothing reaches the threshold', () => {
+ expect(getDiffFailure({ major: 0, minor: 0, patch: 3 }, 'minor')).toBeUndefined();
+ });
+
+ it('uses the singular for one change', () => {
+ expect(getDiffFailure({ major: 1, minor: 0, patch: 0 }, 'major')).toBe(
+ '❌ Diff failed with 1 major change.'
+ );
+ });
+});
diff --git a/packages/cli/src/commands/diff/check-version.ts b/packages/cli/src/commands/diff/check-version.ts
new file mode 100644
index 0000000000..447a58ae96
--- /dev/null
+++ b/packages/cli/src/commands/diff/check-version.ts
@@ -0,0 +1,54 @@
+import type { Impact } from '@redocly/openapi-core';
+import semver from 'semver';
+
+// Pre-release and build metadata are accepted and ignored: only the three numbers are compared.
+function releaseOf(version: unknown): string | undefined {
+ const parsed = typeof version === 'string' ? semver.parse(version) : null;
+
+ return parsed ? `${parsed.major}.${parsed.minor}.${parsed.patch}` : undefined;
+}
+
+// Semver lets a 0.x line break in a minor release, so below 1.0 a minor stands in for a major.
+function bumpFor(base: string, required: Impact): Impact {
+ return semver.major(base) === 0 && required === 'major' ? 'minor' : required;
+}
+
+/**
+ * The version a release of these changes takes: the base version bumped as the changes require,
+ * or unchanged when they require nothing; `undefined` when the base version is not semver.
+ */
+export function nextVersion(
+ baseVersion: unknown,
+ required: Impact | undefined
+): string | undefined {
+ const base = releaseOf(baseVersion);
+ if (!base || required === undefined) return base;
+ // `base` is a valid release, so incrementing it cannot fail.
+ return semver.inc(base, bumpFor(base, required))!;
+}
+
+export function checkVersion(opts: {
+ base: unknown;
+ revision: unknown;
+ required: Impact | undefined;
+}): string | undefined {
+ const { required } = opts;
+ if (required === undefined) return undefined;
+
+ const base = releaseOf(opts.base);
+ const revision = releaseOf(opts.revision);
+
+ if (!base || !revision) {
+ return '❌ info.version is not a semver string, so --check-version cannot compare it.';
+ }
+
+ const bump = bumpFor(base, required);
+ const next = nextVersion(base, required)!;
+
+ if (semver.gte(revision, next)) return undefined;
+
+ const moved = base === revision ? `stayed ${base}` : `went ${base} → ${revision}`;
+
+ const belowOne = bump === required ? '' : ' while below 1.0';
+ return `❌ info.version ${moved}, but these changes require a ${bump} bump (${next})${belowOne}.`;
+}
diff --git a/packages/cli/src/commands/diff/fail-on.ts b/packages/cli/src/commands/diff/fail-on.ts
new file mode 100644
index 0000000000..b20283769a
--- /dev/null
+++ b/packages/cli/src/commands/diff/fail-on.ts
@@ -0,0 +1,18 @@
+import { impactRank, impacts, pluralize, type DiffSummary } from '@redocly/openapi-core';
+
+import type { DiffFailOn } from './types.js';
+
+export function getDiffFailure(summary: DiffSummary, failOn: DiffFailOn): string | undefined {
+ if (failOn === 'none') return undefined;
+
+ const failing = [...impacts]
+ .reverse()
+ .filter((impact) => impactRank(impact) >= impactRank(failOn) && summary[impact] > 0);
+
+ if (!failing.length) return undefined;
+
+ const total = failing.reduce((sum, impact) => sum + summary[impact], 0);
+ const counted = failing.map((impact) => `${summary[impact]} ${impact}`).join(' and ');
+
+ return `❌ Diff failed with ${counted} ${pluralize('change', total)}.`;
+}
diff --git a/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap b/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap
new file mode 100644
index 0000000000..4e8e367258
--- /dev/null
+++ b/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap
@@ -0,0 +1,449 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`htmlDiff > should render a self-contained page 1`] = `
+"
+
+
+
+API diff
+
+
+
+API diff: base.yaml → revision.yaml
+
+ 4 major
+ 0 minor
+ 2 patch
+ requires a major bump
+ OpenAPI 3.1
+
+
+
+
+ patch
+ modified
+ #/paths/~1menu/get · summary
+
+
+ {
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1menu/get/summary",
+ "line": 6,
+ "col": 16,
+ "value": "List the menu"
+ },
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/paths/~1menu/get/summary",
+ "line": 6,
+ "col": 16,
+ "value": "List the whole menu"
+ }
+}
+
+
+
+
+ major
+ modified
+ #/paths/~1menu/get/parameters/0 · required
+ \`limit\` query parameter became required. parameter-became-required
+
+ {
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1menu/get/parameters/0",
+ "line": 8,
+ "col": 11
+ },
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/paths/~1menu/get/parameters/0/required",
+ "line": 8,
+ "col": 47,
+ "value": true
+ }
+}
+
+
+
+
+ major
+ modified
+ #/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note · pattern
+ \`pattern\` of \`note\` changed from 'a' to 'a|b'. schema-constraint-changed
+
+ {
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/pattern",
+ "line": 15,
+ "col": 68,
+ "value": "a"
+ },
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/pattern",
+ "line": 15,
+ "col": 68,
+ "value": "a|b"
+ }
+}
+
+
+
+
+ major
+ removed
+ #/paths/~1orders/delete
+ Operation \`DELETE /orders\` was removed. operation-removed
+
+ {
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1orders/delete",
+ "line": 18,
+ "col": 7,
+ "value": {
+ "summary": "<script>alert(1)</script>",
+ "responses": {
+ "204": {
+ "description": "Cancelled"
+ }
+ }
+ }
+ }
+}
+
+
+
+
+ major
+ removed
+ #/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta
+ Property \`eta\` was removed. property-removed
+
+ {
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta",
+ "line": 26,
+ "col": 42,
+ "value": {
+ "type": "string"
+ }
+ }
+}
+
+
+
+
+ patch
+ added
+ #/components
+
+
+ {
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/components",
+ "line": 26,
+ "col": 3,
+ "value": {
+ "schemas": {
+ "Dessert": {
+ "type": "object"
+ }
+ }
+ }
+ }
+}
+
+
+"
+`;
+
+exports[`jsonDiff > should render the result with each change located in its file 1`] = `
+"{
+ "reportVersion": "1",
+ "files": {
+ "base": "base.yaml",
+ "revision": "revision.yaml"
+ },
+ "specVersions": {
+ "base": "oas3_1",
+ "revision": "oas3_1"
+ },
+ "infoVersions": {
+ "base": "1.0.0",
+ "revision": "1.0.0"
+ },
+ "summary": {
+ "major": 4,
+ "minor": 0,
+ "patch": 2
+ },
+ "bump": "major",
+ "changes": [
+ {
+ "key": "#/paths/~1menu/get",
+ "typeName": "Operation",
+ "kind": "modified",
+ "impact": "patch",
+ "verdicts": [],
+ "property": "summary",
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1menu/get/summary",
+ "line": 6,
+ "col": 16,
+ "value": "List the menu"
+ },
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/paths/~1menu/get/summary",
+ "line": 6,
+ "col": 16,
+ "value": "List the whole menu"
+ }
+ },
+ {
+ "key": "#/paths/~1menu/get/parameters/0",
+ "typeName": "Parameter",
+ "kind": "modified",
+ "impact": "major",
+ "verdicts": [
+ {
+ "ruleId": "parameter-became-required",
+ "impact": "major",
+ "message": "\`limit\` query parameter became required.",
+ "location": {
+ "file": "revision.yaml",
+ "pointer": "#/paths/~1menu/get/parameters/0/required",
+ "line": 8,
+ "col": 47
+ }
+ }
+ ],
+ "property": "required",
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1menu/get/parameters/0",
+ "line": 8,
+ "col": 11
+ },
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/paths/~1menu/get/parameters/0/required",
+ "line": 8,
+ "col": 47,
+ "value": true
+ }
+ },
+ {
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note",
+ "typeName": "Schema",
+ "kind": "modified",
+ "impact": "major",
+ "verdicts": [
+ {
+ "ruleId": "schema-constraint-changed",
+ "impact": "major",
+ "message": "\`pattern\` of \`note\` changed from 'a' to 'a|b'.",
+ "location": {
+ "file": "revision.yaml",
+ "pointer": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/pattern",
+ "line": 15,
+ "col": 68
+ }
+ }
+ ],
+ "property": "pattern",
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/pattern",
+ "line": 15,
+ "col": 68,
+ "value": "a"
+ },
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/pattern",
+ "line": 15,
+ "col": 68,
+ "value": "a|b"
+ }
+ },
+ {
+ "key": "#/paths/~1orders/delete",
+ "typeName": "Operation",
+ "kind": "removed",
+ "impact": "major",
+ "verdicts": [
+ {
+ "ruleId": "operation-removed",
+ "impact": "major",
+ "message": "Operation \`DELETE /orders\` was removed.",
+ "location": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1orders/delete",
+ "line": 18,
+ "col": 7
+ }
+ }
+ ],
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1orders/delete",
+ "line": 18,
+ "col": 7,
+ "value": {
+ "summary": "",
+ "responses": {
+ "204": {
+ "description": "Cancelled"
+ }
+ }
+ }
+ }
+ },
+ {
+ "key": "#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta",
+ "typeName": "Schema",
+ "kind": "removed",
+ "impact": "major",
+ "verdicts": [
+ {
+ "ruleId": "property-removed",
+ "impact": "major",
+ "message": "Property \`eta\` was removed.",
+ "location": {
+ "file": "base.yaml",
+ "pointer": "#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta",
+ "line": 26,
+ "col": 42
+ }
+ }
+ ],
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta",
+ "line": 26,
+ "col": 42,
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "key": "#/components",
+ "typeName": "Components",
+ "kind": "added",
+ "impact": "patch",
+ "verdicts": [],
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/components",
+ "line": 26,
+ "col": 3,
+ "value": {
+ "schemas": {
+ "Dessert": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ ]
+}"
+`;
+
+exports[`markdownDiff > should render one table row per change 1`] = `
+"## API diff: base.yaml → revision.yaml
+
+**4** major · **0** minor · **2** patch · requires a **major** bump
+
+| Impact | Change | Pointer | Location | Details |
+| --- | --- | --- | --- | --- |
+| ⚪ patch | modified | \`#/paths/~1menu/get · summary\` | revision.yaml:6:16 | |
+| 🔴 major | modified | \`#/paths/~1menu/get/parameters/0 · required\` | revision.yaml:8:47 | \\\`limit\\\` query parameter became required. \`parameter-became-required\` |
+| 🔴 major | modified | \`#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note · pattern\` | revision.yaml:15:68 | \\\`pattern\\\` of \\\`note\\\` changed from 'a' to 'a\\|b'. \`schema-constraint-changed\` |
+| 🔴 major | removed | \`#/paths/~1orders/delete\` | base.yaml:18:7 | Operation \\\`DELETE /orders\\\` was removed. \`operation-removed\` |
+| 🔴 major | removed | \`#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta\` | base.yaml:26:42 | Property \\\`eta\\\` was removed. \`property-removed\` |
+| ⚪ patch | added | \`#/components\` | revision.yaml:26:3 | |"
+`;
+
+exports[`nextVersionDiff > should bump the base version as far as the changes require 1`] = `"2.0.0"`;
+
+exports[`printGithubActions > should annotate each verdict of a breaking change as an error and skip the rest 1`] = `
+"::error title=parameter-became-required,file=revision.yaml,line=8,col=47,endLine=8,endColumn=51::\`limit\` query parameter became required.
+::error title=schema-constraint-changed,file=revision.yaml,line=15,col=68,endLine=15,endColumn=73::\`pattern\` of \`note\` changed from 'a' to 'a|b'.
+::error title=operation-removed,file=base.yaml,line=18,col=7,endLine=19,endColumn=55::Operation \`DELETE /orders\` was removed.
+::error title=property-removed,file=base.yaml,line=26,col=42,endLine=26,endColumn=58::Property \`eta\` was removed.
+"
+`;
+
+exports[`stylishDiff > should group AsyncAPI changes per channel, operation and server 1`] = `
+"server staging
+ ✖ major removed
+ Server \`staging\` was removed. (server-removed)
+ at base.yaml:5:12
+
+channel orderPlaced
+ ✖ major modified address
+ \`address\` of channel \`orderPlaced\` changed from 'orders/placed' to 'orders/new'. (channel-address-changed)
+ at revision.yaml:6:27
+
+operation sendOrderReady
+ ✖ major modified action
+ \`action\` of operation \`sendOrderReady\` changed from 'send' to 'receive'. (operation-action-changed)
+ at revision.yaml:8:29
+
+3 major, 0 minor, 0 patch."
+`;
+
+exports[`stylishDiff > should group the changes per endpoint and webhook, worst first, with their verdicts and location 1`] = `
+"GET /menu
+ ✖ major modified parameters/0 · required
+ \`limit\` query parameter became required. (parameter-became-required)
+ at revision.yaml:8:47
+ · patch modified summary
+ at revision.yaml:6:16
+
+POST /orders
+ ✖ major modified requestBody/content/application/json/schema/properties/note · pattern
+ \`pattern\` of \`note\` changed from 'a' to 'a|b'. (schema-constraint-changed)
+ at revision.yaml:15:68
+
+DELETE /orders
+ ✖ major removed
+ Operation \`DELETE /orders\` was removed. (operation-removed)
+ at base.yaml:18:7
+
+POST orderReady (webhook)
+ ✖ major removed requestBody/content/application/json/schema/properties/eta
+ Property \`eta\` was removed. (property-removed)
+ at base.yaml:26:42
+
+components
+ · patch added
+ at revision.yaml:26:3
+
+4 major, 0 minor, 2 patch."
+`;
diff --git a/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/base.yaml b/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/base.yaml
new file mode 100644
index 0000000000..b9cbd41248
--- /dev/null
+++ b/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/base.yaml
@@ -0,0 +1,27 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.0.0 }
+paths:
+ /menu:
+ get:
+ summary: List the menu
+ parameters:
+ - { name: limit, in: query, schema: { type: integer } }
+ responses: { '200': { description: OK } }
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { properties: { note: { type: string, pattern: 'a' } } }
+ responses: { '201': { description: Created } }
+ delete:
+ summary:
+ responses: { '204': { description: Cancelled } }
+webhooks:
+ orderReady:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { properties: { eta: { type: string } } }
+ responses: { '200': { description: OK } }
diff --git a/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/revision.yaml b/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/revision.yaml
new file mode 100644
index 0000000000..25c1690c30
--- /dev/null
+++ b/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/revision.yaml
@@ -0,0 +1,27 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.0.0 }
+paths:
+ /menu:
+ get:
+ summary: List the whole menu
+ parameters:
+ - { name: limit, in: query, required: true, schema: { type: integer } }
+ responses: { '200': { description: OK } }
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { properties: { note: { type: string, pattern: 'a|b' } } }
+ responses: { '201': { description: Created } }
+webhooks:
+ orderReady:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { properties: {} }
+ responses: { '200': { description: OK } }
+components:
+ schemas:
+ Dessert: { type: object }
diff --git a/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/base.yaml b/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/base.yaml
new file mode 100644
index 0000000000..83be825107
--- /dev/null
+++ b/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/base.yaml
@@ -0,0 +1,9 @@
+asyncapi: 3.0.0
+info: { title: Cafe kitchen, version: 1.0.0 }
+servers:
+ production: { host: kitchen.cafe.example, protocol: mqtt }
+ staging: { host: staging.cafe.example, protocol: mqtt }
+channels:
+ orderPlaced: { address: orders/placed }
+operations:
+ sendOrderReady: { action: send, channel: { $ref: '#/channels/orderPlaced' } }
diff --git a/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/revision.yaml b/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/revision.yaml
new file mode 100644
index 0000000000..5977a6a47c
--- /dev/null
+++ b/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/revision.yaml
@@ -0,0 +1,8 @@
+asyncapi: 3.0.0
+info: { title: Cafe kitchen, version: 1.0.0 }
+servers:
+ production: { host: kitchen.cafe.example, protocol: mqtt }
+channels:
+ orderPlaced: { address: orders/new }
+operations:
+ sendOrderReady: { action: receive, channel: { $ref: '#/channels/orderPlaced' } }
diff --git a/packages/cli/src/commands/diff/format/__tests__/format.test.ts b/packages/cli/src/commands/diff/format/__tests__/format.test.ts
new file mode 100644
index 0000000000..f7e066d113
--- /dev/null
+++ b/packages/cli/src/commands/diff/format/__tests__/format.test.ts
@@ -0,0 +1,123 @@
+import { createConfig, diffDocuments, makeDocumentFromString } from '@redocly/openapi-core';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { stripVTControlCharacters } from 'node:util';
+import { outdent } from 'outdent';
+
+import { printGithubActions } from '../github-actions.js';
+import { htmlDiff } from '../html.js';
+import { jsonDiff } from '../json.js';
+import { markdownDiff } from '../markdown.js';
+import { nextVersionDiff } from '../next-version.js';
+import { stylishDiff } from '../stylish.js';
+
+const documentOf = (fixture: string, side: 'base' | 'revision') =>
+ makeDocumentFromString(
+ readFileSync(join(import.meta.dirname, 'fixtures', fixture, `${side}.yaml`), 'utf8'),
+ `${side}.yaml`
+ );
+
+const config = await createConfig({ extends: ['diff-recommended'] });
+
+// Every shape a report has to survive: a removal found in the base document, changes found in
+// the revision, an addition, a change no rule judges, a webhook — and content that fights the
+// output format, so the escaping shows up in the snapshots.
+const cafe = await diffDocuments({
+ base: documentOf('cafe', 'base'),
+ revision: documentOf('cafe', 'revision'),
+ config,
+});
+
+const kitchen = await diffDocuments({
+ base: documentOf('kitchen', 'base'),
+ revision: documentOf('kitchen', 'revision'),
+ config,
+});
+
+// vitest runs with FORCE_COLOR=1, so the stylish report is snapshotted without its colours.
+describe('stylishDiff', () => {
+ it('should group the changes per endpoint and webhook, worst first, with their verdicts and location', () => {
+ expect(stripVTControlCharacters(stylishDiff(cafe))).toMatchSnapshot();
+ });
+
+ it('should group AsyncAPI changes per channel, operation and server', () => {
+ expect(stripVTControlCharacters(stylishDiff(kitchen))).toMatchSnapshot();
+ });
+
+ it('should head the changes of a path by its path when its $ref points to another path item', async () => {
+ const menu = (pathItem: string, side: 'base' | 'revision') =>
+ makeDocumentFromString(
+ outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu: { $ref: '#/components/pathItems/${pathItem}' }
+ components:
+ pathItems:
+ Menu:
+ get: { responses: { '200': { description: OK } } }
+ delete: { responses: { '204': { description: Removed } } }
+ Dishes:
+ get: { responses: { '200': { description: OK } } }
+ `,
+ `${side}.yaml`
+ );
+
+ const result = await diffDocuments({
+ base: menu('Menu', 'base'),
+ revision: menu('Dishes', 'revision'),
+ config,
+ });
+
+ expect(stripVTControlCharacters(stylishDiff(result))).toMatchInlineSnapshot(`
+ "DELETE /menu
+ ✖ major removed
+ Operation \`DELETE /menu\` was removed. (operation-removed)
+ at base.yaml:9:15
+
+ 1 major, 0 minor, 0 patch."
+ `);
+ });
+});
+
+describe('markdownDiff', () => {
+ it('should render one table row per change', () => {
+ expect(markdownDiff(cafe)).toMatchSnapshot();
+ });
+});
+
+describe('htmlDiff', () => {
+ it('should render a self-contained page', () => {
+ const output = htmlDiff(cafe);
+
+ expect(output).toMatchSnapshot();
+ // The report is opened straight from disk, so it must pull in nothing.
+ expect(output).not.toMatch(/src="http|href="http/);
+ });
+});
+
+describe('jsonDiff', () => {
+ it('should render the result with each change located in its file', () => {
+ expect(jsonDiff(cafe)).toMatchSnapshot();
+ });
+});
+
+describe('printGithubActions', () => {
+ it('should annotate each verdict of a breaking change as an error and skip the rest', () => {
+ let output = '';
+ vi.spyOn(process.stdout, 'write').mockImplementation((text: string | Uint8Array) => {
+ output += text;
+ return true;
+ });
+
+ printGithubActions(cafe);
+
+ expect(output).toMatchSnapshot();
+ });
+});
+
+describe('nextVersionDiff', () => {
+ it('should bump the base version as far as the changes require', () => {
+ expect(nextVersionDiff(cafe)).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/commands/diff/format/github-actions.ts b/packages/cli/src/commands/diff/format/github-actions.ts
new file mode 100644
index 0000000000..1eee4a22e1
--- /dev/null
+++ b/packages/cli/src/commands/diff/format/github-actions.ts
@@ -0,0 +1,42 @@
+import {
+ displaySide,
+ formatProblems,
+ getTotals,
+ type DiffResult,
+ type Impact,
+ type JudgedChange,
+ type NormalizedProblem,
+ type ProblemSeverity,
+} from '@redocly/openapi-core';
+
+// Only a breaking change is annotated, as an error: a GitHub warning would flag a compatible
+// change as something to fix. This is what lets the format come from core's formatProblems.
+const SEVERITIES: Partial> = { major: 'error' };
+
+export function printGithubActions(result: DiffResult): void {
+ const problems: NormalizedProblem[] = result.changes.flatMap((change) => {
+ // A change no rule judged is reported on its own, under the command's name.
+ const verdicts: JudgedChange['verdicts'] = change.verdicts.length
+ ? change.verdicts
+ : [
+ {
+ ruleId: 'diff',
+ impact: change.impact,
+ message: `${change.kind} ${change.key}`,
+ location: displaySide(change).location,
+ },
+ ];
+
+ return verdicts.flatMap(({ ruleId, impact, message, location }) => {
+ const severity = SEVERITIES[impact];
+ if (!severity) return [];
+ return [{ message, ruleId, severity, location: [location], suggest: [] }];
+ });
+ });
+
+ formatProblems(problems, {
+ format: 'github-actions',
+ totals: getTotals(problems),
+ maxProblems: problems.length,
+ });
+}
diff --git a/packages/cli/src/commands/diff/format/html.ts b/packages/cli/src/commands/diff/format/html.ts
new file mode 100644
index 0000000000..fa581d2648
--- /dev/null
+++ b/packages/cli/src/commands/diff/format/html.ts
@@ -0,0 +1,91 @@
+import type { DiffResult, JudgedChange, SpecVersion } from '@redocly/openapi-core';
+
+import { formatPath } from '../../../utils/miscellaneous.js';
+import { toJsonChange } from './json.js';
+
+const SPEC_NAMES: Record = {
+ oas2: 'OpenAPI 2.0',
+ oas3_0: 'OpenAPI 3.0',
+ oas3_1: 'OpenAPI 3.1',
+ oas3_2: 'OpenAPI 3.2',
+ async2: 'AsyncAPI 2',
+ async3: 'AsyncAPI 3',
+ arazzo1: 'Arazzo 1.0',
+ arazzo1_1: 'Arazzo 1.1',
+ overlay1: 'Overlay 1',
+ openrpc1: 'OpenRPC 1',
+ graphql: 'GraphQL',
+};
+
+function escapeHtml(value: unknown): string {
+ return String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"');
+}
+
+function renderChange(change: JudgedChange): string {
+ const address = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key;
+ const { base, revision } = toJsonChange(change);
+ return `
+
+
+ ${escapeHtml(change.impact)}
+ ${escapeHtml(change.kind)}
+ ${escapeHtml(address)}
+ ${change.verdicts
+ .map(
+ (verdict) =>
+ `${escapeHtml(verdict.message)} ${escapeHtml(
+ verdict.ruleId
+ )}`
+ )
+ .join(' ')}
+
+ ${escapeHtml(JSON.stringify({ base, revision }, null, 2))}
+ `;
+}
+
+export function htmlDiff(result: DiffResult): string {
+ const { major, minor, patch } = result.summary;
+ const { base, revision } = result.specVersions;
+ const specs =
+ base === revision ? SPEC_NAMES[base] : `${SPEC_NAMES[base]} → ${SPEC_NAMES[revision]}`;
+ const bumpSpan = result.bump
+ ? `requires a ${escapeHtml(result.bump)} bump`
+ : '';
+ return `
+
+
+
+API diff
+
+
+
+API diff: ${escapeHtml(formatPath(result.files.base))} → ${escapeHtml(formatPath(result.files.revision))}
+
+ ${major} major
+ ${minor} minor
+ ${patch} patch
+ ${bumpSpan}
+ ${escapeHtml(specs)}
+
+${result.changes.map(renderChange).join('\n')}
+
+`;
+}
diff --git a/packages/cli/src/commands/diff/format/index.ts b/packages/cli/src/commands/diff/format/index.ts
new file mode 100644
index 0000000000..155f94c65f
--- /dev/null
+++ b/packages/cli/src/commands/diff/format/index.ts
@@ -0,0 +1,17 @@
+import type { DiffResult } from '@redocly/openapi-core';
+
+import { htmlDiff } from './html.js';
+import { jsonDiff } from './json.js';
+import { markdownDiff } from './markdown.js';
+import { nextVersionDiff } from './next-version.js';
+import { stylishDiff } from './stylish.js';
+
+export const diffReportFormats = {
+ stylish: stylishDiff,
+ json: jsonDiff,
+ markdown: markdownDiff,
+ html: htmlDiff,
+ 'next-version': nextVersionDiff,
+} satisfies Record string>;
+
+export type DiffReportFormat = keyof typeof diffReportFormats;
diff --git a/packages/cli/src/commands/diff/format/json.ts b/packages/cli/src/commands/diff/format/json.ts
new file mode 100644
index 0000000000..a2a243890b
--- /dev/null
+++ b/packages/cli/src/commands/diff/format/json.ts
@@ -0,0 +1,50 @@
+import {
+ typeOf,
+ type DiffResult,
+ type JudgedChange,
+ type Location,
+ type LocatedNode,
+} from '@redocly/openapi-core';
+
+import { formatPath } from '../../../utils/miscellaneous.js';
+import { lineColOf } from './location.js';
+
+function toJsonLocation(location: Location) {
+ const { file, line, col } = lineColOf(location);
+ return { file, pointer: location.pointer, line, col };
+}
+
+function toJsonNode({ location, value }: LocatedNode) {
+ return { ...toJsonLocation(location), value };
+}
+
+// Fields a change does not have stay undefined, and JSON.stringify leaves them out.
+export function toJsonChange(change: JudgedChange) {
+ return {
+ key: change.key,
+ typeName: typeOf(change.node),
+ kind: change.kind,
+ impact: change.impact,
+ verdicts: change.verdicts.map(({ location, ...verdict }) => ({
+ ...verdict,
+ location: toJsonLocation(location),
+ })),
+ property: change.kind === 'modified' ? change.property : undefined,
+ base: 'base' in change ? toJsonNode(change.base) : undefined,
+ revision: 'revision' in change ? toJsonNode(change.revision) : undefined,
+ };
+}
+
+// The problems met while preparing the documents are printed apart from the report.
+export function jsonDiff(result: DiffResult): string {
+ const report = {
+ reportVersion: '1',
+ files: { base: formatPath(result.files.base), revision: formatPath(result.files.revision) },
+ specVersions: result.specVersions,
+ infoVersions: result.infoVersions,
+ summary: result.summary,
+ bump: result.bump,
+ changes: result.changes.map(toJsonChange),
+ };
+ return JSON.stringify(report, null, 2);
+}
diff --git a/packages/cli/src/commands/diff/format/location.ts b/packages/cli/src/commands/diff/format/location.ts
new file mode 100644
index 0000000000..a4dd01c2cf
--- /dev/null
+++ b/packages/cli/src/commands/diff/format/location.ts
@@ -0,0 +1,10 @@
+import { getLineColLocation, type Location } from '@redocly/openapi-core';
+
+import { formatPath } from '../../../utils/miscellaneous.js';
+
+// A node that a preprocessor or decorator added is not in the text of its file;
+// getLineColLocation falls back to 1:1 for such pointers.
+export function lineColOf(location: Location): { file: string; line: number; col: number } {
+ const { start } = getLineColLocation(location);
+ return { file: formatPath(location.source.absoluteRef), line: start.line, col: start.col };
+}
diff --git a/packages/cli/src/commands/diff/format/markdown.ts b/packages/cli/src/commands/diff/format/markdown.ts
new file mode 100644
index 0000000000..3309e26e48
--- /dev/null
+++ b/packages/cli/src/commands/diff/format/markdown.ts
@@ -0,0 +1,50 @@
+import { displaySide, type DiffResult, type Impact } from '@redocly/openapi-core';
+
+import { formatPath } from '../../../utils/miscellaneous.js';
+import { lineColOf } from './location.js';
+
+const IMPACT_LABELS: Record = {
+ major: '🔴 major',
+ minor: '🟢 minor',
+ patch: '⚪ patch',
+};
+
+// A cell is rendered inside a code span, so a backtick from the description would
+// close it early and let the rest of the row be read as markup. Newlines and pipes
+// would break out of the row itself.
+function escapeCell(value: string): string {
+ return value
+ .replaceAll('|', '\\|')
+ .replaceAll('`', '\\`')
+ .split('\n')
+ .map((line) => line.trim())
+ .join(' ');
+}
+
+export function markdownDiff(result: DiffResult): string {
+ const { major, minor, patch } = result.summary;
+ const required = result.bump ? ` · requires a **${result.bump}** bump` : '';
+ const lines = [
+ `## API diff: ${formatPath(result.files.base)} → ${formatPath(result.files.revision)}`,
+ '',
+ `**${major}** major · **${minor}** minor · **${patch}** patch${required}`,
+ '',
+ '| Impact | Change | Pointer | Location | Details |',
+ '| --- | --- | --- | --- | --- |',
+ ];
+
+ for (const change of result.changes) {
+ const address = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key;
+ const { file, line, col } = lineColOf(displaySide(change).location);
+ // Only the message comes from the compared document and needs escaping. A rule id is
+ // lowercase letters and hyphens, and the backticks around it are ours to keep.
+ const details = change.verdicts
+ .map((verdict) => `${escapeCell(verdict.message)} \`${verdict.ruleId}\``)
+ .join('
');
+ lines.push(
+ `| ${IMPACT_LABELS[change.impact]} | ${change.kind} | \`${escapeCell(address)}\` | ${escapeCell(`${file}:${line}:${col}`)} | ${details} |`
+ );
+ }
+
+ return lines.join('\n');
+}
diff --git a/packages/cli/src/commands/diff/format/next-version.ts b/packages/cli/src/commands/diff/format/next-version.ts
new file mode 100644
index 0000000000..2f4c01e420
--- /dev/null
+++ b/packages/cli/src/commands/diff/format/next-version.ts
@@ -0,0 +1,13 @@
+import { HandledError, type DiffResult } from '@redocly/openapi-core';
+
+import { nextVersion } from '../check-version.js';
+
+export function nextVersionDiff(result: DiffResult): string {
+ const next = nextVersion(result.infoVersions.base, result.bump);
+ if (next === undefined) {
+ throw new HandledError(
+ 'info.version of the base description is not a semver string, so the next version cannot be computed.'
+ );
+ }
+ return next;
+}
diff --git a/packages/cli/src/commands/diff/format/stylish.ts b/packages/cli/src/commands/diff/format/stylish.ts
new file mode 100644
index 0000000000..777d8fa437
--- /dev/null
+++ b/packages/cli/src/commands/diff/format/stylish.ts
@@ -0,0 +1,110 @@
+import {
+ displaySide,
+ impactRank,
+ impacts,
+ nameOf,
+ parsePointer,
+ typeOf,
+ type Change,
+ type DiffNode,
+ type DiffResult,
+ type Impact,
+ type JudgedChange,
+} from '@redocly/openapi-core';
+import { blue, bold, gray, green, red } from 'colorette';
+
+import { lineColOf } from './location.js';
+
+const IMPACT_COLORS: Record string> = {
+ major: red,
+ minor: green,
+ patch: gray,
+};
+
+const IMPACT_GLYPHS: Record = {
+ major: red('✖ major'),
+ minor: green('✔ minor'),
+ patch: gray('· patch'),
+};
+
+// `parsePointer` unescapes every segment and keeps the leading `#`, which names the document.
+function segmentsOf(pointer: string): string[] {
+ const [, ...segments] = parsePointer(pointer);
+ return segments;
+}
+
+// The items that get a heading of their own, by the type of the map that holds them.
+const ITEM_HEADINGS: Partial string>> = {
+ Paths: (path) => path,
+ WebhooksMap: (webhook) => `${webhook} (webhook)`,
+ NamedChannels: (channel) => `channel ${channel}`,
+ NamedOperations: (operation) => `operation ${operation}`,
+ ServerMap: (server) => `server ${server}`,
+};
+
+/**
+ * The heading a change is listed under — its endpoint, webhook, channel, operation or server,
+ * or else the section it sits in — and how many levels below the root that heading names. The
+ * names come from the matched nodes, so a renamed path keeps one heading for all its changes.
+ */
+function headingOf(change: Change): { heading: string; depth: number } {
+ const ancestors: DiffNode[] = [];
+ for (let node: DiffNode | null = change.node; node; node = node.parent) ancestors.unshift(node);
+ const [, section, item, operation] = ancestors;
+
+ if (!section) return { heading: 'document', depth: 0 };
+ const itemHeading = ITEM_HEADINGS[typeOf(section)];
+ if (!item || !itemHeading) return { heading: nameOf(section), depth: 1 };
+
+ const heading = itemHeading(nameOf(item));
+ const isOperation = operation && typeOf(operation) === 'Operation' && typeOf(item) === 'PathItem';
+ if (isOperation) return { heading: `${nameOf(operation).toUpperCase()} ${heading}`, depth: 3 };
+ return { heading, depth: 2 };
+}
+
+// The label leaves out what the heading names, so a change on the heading's own node has only
+// its property, if any. Each segment is unescaped, so a `/` inside a name reads as one.
+function labelOf(change: JudgedChange): string {
+ const below = segmentsOf(change.key).slice(headingOf(change).depth).join('/');
+ if (change.kind !== 'modified') return below;
+ return below ? `${below} · ${change.property}` : change.property;
+}
+
+export function stylishDiff(result: DiffResult): string {
+ const groups = new Map();
+ for (const change of result.changes) {
+ const { heading } = headingOf(change);
+ const group = groups.get(heading) ?? [];
+ group.push(change);
+ groups.set(heading, group);
+ }
+
+ const lines: string[] = [];
+ // The changes come sorted by key, so the headings follow the document's structure.
+ for (const [heading, changes] of groups) {
+ lines.push(bold(blue(heading)));
+ // Sorting is stable, so changes of one impact keep the order the report lists them in.
+ const sorted = changes.toSorted(
+ (left, right) => impactRank(right.impact) - impactRank(left.impact)
+ );
+ for (const change of sorted) {
+ const label = labelOf(change);
+ const kind = bold(label ? change.kind.padEnd(8) : change.kind);
+ lines.push(` ${IMPACT_GLYPHS[change.impact]} ${kind}${label ? ` ${label}` : ''}`);
+ for (const verdict of change.verdicts) {
+ lines.push(gray(` ${verdict.message} (${verdict.ruleId})`));
+ }
+ const { file, line, col } = lineColOf(displaySide(change).location);
+ lines.push(gray(` at ${file}:${line}:${col}`));
+ }
+ lines.push('');
+ }
+
+ // A zero is not a finding, so it is not coloured like one.
+ const counts = impacts.toReversed().map((impact) => {
+ const count = result.summary[impact];
+ return (count ? IMPACT_COLORS[impact] : gray)(`${count} ${impact}`);
+ });
+ lines.push(`${counts.join(', ')}.`);
+ return lines.join('\n');
+}
diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts
new file mode 100644
index 0000000000..0485ff1815
--- /dev/null
+++ b/packages/cli/src/commands/diff/index.ts
@@ -0,0 +1,100 @@
+import {
+ AbortFlowError,
+ BaseResolver,
+ diffDocuments,
+ HandledError,
+ formatProblems,
+ getTotals,
+ logger,
+ type JudgedChange,
+} from '@redocly/openapi-core';
+import { green } from 'colorette';
+import { writeFileSync } from 'node:fs';
+
+import { getFallbackApisOrExit, printExecutionTime } from '../../utils/miscellaneous.js';
+import type { CommandArgs } from '../../wrapper.js';
+import { checkVersion } from './check-version.js';
+import { getDiffFailure } from './fail-on.js';
+import { printGithubActions } from './format/github-actions.js';
+import { diffReportFormats } from './format/index.js';
+import type { DiffArgv } from './types.js';
+
+export async function handleDiff({ argv, config, collectSpecData }: CommandArgs) {
+ if (argv.output && argv.format === 'github-actions') {
+ throw new HandledError(
+ `The github-actions format prints to stdout only. To write a report to a file, use one of these formats: ${Object.keys(diffReportFormats).join(', ')}.`
+ );
+ }
+
+ const startedAt = performance.now();
+
+ config.skipDiffRules(argv['skip-rule']);
+
+ const [{ path: basePath }] = await getFallbackApisOrExit([argv.base], config);
+ const [{ path: revisionPath }] = await getFallbackApisOrExit([argv.revision], config);
+
+ // One resolver loads both documents and resolves their `$ref`s, as lint does.
+ const externalRefResolver = new BaseResolver(config.resolve);
+ const load = async (documentPath: string) => {
+ const document = await externalRefResolver.resolveDocument(null, documentPath, true);
+ if (document instanceof Error) throw document;
+ return document;
+ };
+ const [base, revision] = await Promise.all([load(basePath), load(revisionPath)]);
+
+ collectSpecData?.(revision);
+
+ const result = await diffDocuments({ base, revision, config, externalRefResolver });
+
+ if (result.problems.length) {
+ formatProblems(result.problems, {
+ format: 'codeframe',
+ totals: getTotals(result.problems),
+ command: 'bundle',
+ });
+ logger.warn(
+ '⚠️ The problems above leave parts of the descriptions unresolved, so the diff may miss changes there or judge them without knowing whether they are in a request or a response.\n'
+ );
+ }
+
+ result.changes.sort(byKeyAndProperty);
+
+ if (argv.format === 'github-actions') {
+ printGithubActions(result);
+ } else if (argv.output) {
+ writeFileSync(argv.output, diffReportFormats[argv.format](result));
+ logger.info(`Diff report written to ${argv.output}.\n`);
+ } else {
+ logger.output(diffReportFormats[argv.format](result) + '\n');
+ }
+
+ printExecutionTime('diff', startedAt, `${basePath} vs ${revisionPath}`);
+
+ const failures = [
+ getDiffFailure(result.summary, argv['fail-on']),
+ argv['check-version'] &&
+ checkVersion({
+ base: result.infoVersions.base,
+ revision: result.infoVersions.revision,
+ required: result.bump,
+ }),
+ ].filter((failure) => typeof failure === 'string');
+
+ if (failures.length) {
+ for (const failure of failures) logger.error(`${failure}\n`);
+
+ throw new AbortFlowError('Diff failed.');
+ }
+
+ logger.info(green('✅ Diff passed.\n'));
+}
+
+function byKeyAndProperty(left: JudgedChange, right: JudgedChange): number {
+ if (left.key !== right.key) return left.key < right.key ? -1 : 1;
+
+ const leftProperty = left.kind === 'modified' ? left.property : '';
+ const rightProperty = right.kind === 'modified' ? right.property : '';
+ if (leftProperty !== rightProperty) return leftProperty < rightProperty ? -1 : 1;
+
+ return 0;
+}
diff --git a/packages/cli/src/commands/diff/types.ts b/packages/cli/src/commands/diff/types.ts
new file mode 100644
index 0000000000..11dfe31fca
--- /dev/null
+++ b/packages/cli/src/commands/diff/types.ts
@@ -0,0 +1,18 @@
+import type { Impact } from '@redocly/openapi-core';
+
+import type { VerifyConfigOptions } from '../../types.js';
+import type { DiffReportFormat } from './format/index.js';
+
+export type DiffOutputFormat = DiffReportFormat | 'github-actions';
+
+export type DiffFailOn = Impact | 'none';
+
+export type DiffArgv = {
+ base: string;
+ revision: string;
+ format: DiffOutputFormat;
+ output?: string;
+ 'fail-on': DiffFailOn;
+ 'check-version': boolean;
+ 'skip-rule'?: string[];
+} & VerifyConfigOptions;
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 309e596b2e..0daf15add7 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -13,6 +13,7 @@ import yargs, { type Arguments } from 'yargs';
import { hideBin } from 'yargs/helpers';
import type { BuildDocsArgv } from './commands/build-docs/types.js';
+import type { DiffArgv, DiffFailOn, DiffOutputFormat } from './commands/diff/types.js';
import type { ReportFormat } from './commands/drift/engine/reporter.js';
import { type DriftArgv } from './commands/drift/index.js';
import type { FindingSeverity, MatchMode, TrafficFormat } from './commands/drift/types/index.js';
@@ -134,6 +135,61 @@ yargs(hideBin(process.argv))
commandWrapper(handleInspectNodeTypes)(argv);
}
)
+ .command(
+ 'diff ',
+ 'Compare two API descriptions and rate every change by the semver bump it requires [experimental].',
+ (yargs) =>
+ yargs
+ .env('REDOCLY_CLI_DIFF')
+ .positional('base', { type: 'string', demandOption: true })
+ .positional('revision', { type: 'string', demandOption: true })
+ .option({
+ config: { description: 'Path to the config file.', type: 'string' },
+ 'lint-config': {
+ description: 'Severity level for config file linting.',
+ choices: ['warn', 'error', 'off'] as ReadonlyArray,
+ default: 'warn' as RuleSeverity,
+ },
+ format: {
+ description: 'Use a specific output format.',
+ choices: [
+ 'stylish',
+ 'json',
+ 'markdown',
+ 'html',
+ 'github-actions',
+ 'next-version',
+ ] as ReadonlyArray,
+ default: 'stylish' as const,
+ },
+ output: {
+ description: 'Write the diff report to a file.',
+ type: 'string',
+ alias: 'o',
+ },
+ 'fail-on': {
+ description:
+ 'Exit with a non-zero code when changes of this impact or higher are found.',
+ choices: ['major', 'minor', 'patch', 'none'] as ReadonlyArray,
+ default: 'major' as const,
+ },
+ 'check-version': {
+ description:
+ 'Fail when info.version was not bumped to match the impact of the changes.',
+ type: 'boolean',
+ default: false,
+ },
+ 'skip-rule': {
+ description: 'Ignore certain rules.',
+ array: true,
+ type: 'string',
+ },
+ }),
+ async (argv) => {
+ const { handleDiff } = await import('./commands/diff/index.js');
+ commandWrapper(handleDiff)(argv as Arguments);
+ }
+ )
.command(
'score [api]',
'Score an API description for integration simplicity and agent readiness.',
diff --git a/packages/core/__tests__/utils.ts b/packages/core/__tests__/utils.ts
index 18ad19e1a9..9310000f31 100644
--- a/packages/core/__tests__/utils.ts
+++ b/packages/core/__tests__/utils.ts
@@ -1,7 +1,10 @@
import * as path from 'path';
import {
+ BaseResolver,
+ type Change,
type Document,
+ type JudgedChange,
Source,
type NormalizedProblem,
parseYaml,
@@ -15,6 +18,40 @@ export function parseYamlToDocument(body: string, absoluteRef: string = ''): Doc
};
}
+// A resolver that finds these files, by paths relative to the working directory, in its cache, so
+// a test that needs several files writes none to disk.
+export function resolverWithFiles(files: Record): BaseResolver {
+ const resolver = new BaseResolver();
+ for (const [file, body] of Object.entries(files)) {
+ const absoluteRef = path.resolve(file);
+ resolver.cache.set(absoluteRef, Promise.resolve(parseYamlToDocument(body, absoluteRef)));
+ }
+ return resolver;
+}
+
+export function replaceSourceWithRefInChanges(changes: Array) {
+ return changes.map(({ node: _node, ...change }) => {
+ const mapped: Record = { ...change };
+
+ if ('base' in change) {
+ mapped.base = { ...change.base, location: change.base.location.absolutePointer };
+ }
+
+ if ('revision' in change) {
+ mapped.revision = { ...change.revision, location: change.revision.location.absolutePointer };
+ }
+
+ if ('verdicts' in change) {
+ mapped.verdicts = change.verdicts.map((verdict) => ({
+ ...verdict,
+ location: verdict.location.absolutePointer,
+ }));
+ }
+
+ return mapped;
+ });
+}
+
export function replaceSourceWithRef(results: NormalizedProblem[], cwd?: string) {
const cwdRegexp = cwd ? new RegExp(cwd + path.sep, 'g') : /$^/;
return results.map((r) => {
diff --git a/packages/core/package.json b/packages/core/package.json
index e0efa2a23d..db13ae7cb5 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -57,8 +57,8 @@
"ajv": "npm:@redocly/ajv@^8.18.3",
"ajv-formats": "^3.0.1",
"colorette": "^1.2.0",
+ "fastest-levenshtein": "^1.0.16",
"graphql": "^16.14.1",
- "js-levenshtein": "^1.1.6",
"js-yaml": "^5.2.2",
"json-schema-to-ts": "^3.1.0",
"jsonpath-rfc9535": "1.3.0",
@@ -67,7 +67,6 @@
"yaml-ast-parser": "0.0.43"
},
"devDependencies": {
- "@types/js-levenshtein": "^1.1.0",
"@types/picomatch": "^4.0.2",
"@types/pluralize": "^0.0.29",
"typescript": "6.0.2"
diff --git a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap
index 2d43b299e4..3218db61af 100644
--- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap
+++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap
@@ -471,9 +471,11 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`]
"async2Preprocessors": "Preprocessors",
"async2Rules": "Rules",
"async3Decorators": "Decorators",
+ "async3Diff": "DiffRules",
"async3Preprocessors": "Preprocessors",
"async3Rules": "Rules",
"decorators": "Decorators",
+ "diff": "DiffRules",
"extends": {
"description": "Use extends to inherit rules and their configurations from other rulesets.",
"documentationLink": "https://redocly.com/docs/cli/configuration/reference/extends#extends",
@@ -486,12 +488,15 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`]
"oas2Preprocessors": "Preprocessors",
"oas2Rules": "Rules",
"oas3_0Decorators": "Decorators",
+ "oas3_0Diff": "DiffRules",
"oas3_0Preprocessors": "Preprocessors",
"oas3_0Rules": "Rules",
"oas3_1Decorators": "Decorators",
+ "oas3_1Diff": "DiffRules",
"oas3_1Preprocessors": "Preprocessors",
"oas3_1Rules": "Rules",
"oas3_2Decorators": "Decorators",
+ "oas3_2Diff": "DiffRules",
"oas3_2Preprocessors": "Preprocessors",
"oas3_2Rules": "Rules",
"openrpc1Decorators": "Decorators",
@@ -541,6 +546,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`]
"async2Preprocessors": "Preprocessors",
"async2Rules": "Rules",
"async3Decorators": "Decorators",
+ "async3Diff": "DiffRules",
"async3Preprocessors": "Preprocessors",
"async3Rules": "Rules",
"asyncapi": "rootRedoclyConfigSchema.asyncapi",
@@ -559,6 +565,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`]
"corsProxy": "rootRedoclyConfigSchema.corsProxy",
"decorators": "Decorators",
"developerOnboarding": "rootRedoclyConfigSchema.developerOnboarding",
+ "diff": "DiffRules",
"entitiesCatalog": "rootRedoclyConfigSchema.entitiesCatalog",
"env": "rootRedoclyConfigSchema.env",
"extends": {
@@ -609,12 +616,15 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`]
"oas2Preprocessors": "Preprocessors",
"oas2Rules": "Rules",
"oas3_0Decorators": "Decorators",
+ "oas3_0Diff": "DiffRules",
"oas3_0Preprocessors": "Preprocessors",
"oas3_0Rules": "Rules",
"oas3_1Decorators": "Decorators",
+ "oas3_1Diff": "DiffRules",
"oas3_1Preprocessors": "Preprocessors",
"oas3_1Rules": "Rules",
"oas3_2Decorators": "Decorators",
+ "oas3_2Diff": "DiffRules",
"oas3_2Preprocessors": "Preprocessors",
"oas3_2Rules": "Rules",
"openapi": "rootRedoclyConfigSchema.openapi",
@@ -771,6 +781,11 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`]
},
"properties": {},
},
+ "DiffRules": {
+ "additionalProperties": [Function],
+ "description": "The \`diff\` block sets the semver impact of each diff rule: \`off\`, \`patch\`, \`minor\`, or \`major\`.",
+ "properties": {},
+ },
"Discriminator": {
"description": "When request bodies or response payloads may be one of a number of different schemas, a discriminator object can be used to aid in serialization, deserialization, and validation. The discriminator is a specific object in a schema which is used to inform the consumer of the document of an alternative schema based on the value associated with it.",
"documentationLink": "https://redocly.com/learn/openapi/openapi-visual-reference/discriminator#discriminator-object",
@@ -1351,12 +1366,14 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`]
"async2Preprocessors": "Preprocessors",
"async2Rules": "Rules",
"async3Decorators": "Decorators",
+ "async3Diff": "DiffRules",
"async3Preprocessors": "Preprocessors",
"async3Rules": "Rules",
"color": {
"type": "string",
},
"decorators": "Decorators",
+ "diff": "DiffRules",
"extends": {
"description": "Use extends to inherit rules and their configurations from other rulesets.",
"documentationLink": "https://redocly.com/docs/cli/configuration/reference/extends#extends",
@@ -1372,12 +1389,15 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`]
"oas2Preprocessors": "Preprocessors",
"oas2Rules": "Rules",
"oas3_0Decorators": "Decorators",
+ "oas3_0Diff": "DiffRules",
"oas3_0Preprocessors": "Preprocessors",
"oas3_0Rules": "Rules",
"oas3_1Decorators": "Decorators",
+ "oas3_1Diff": "DiffRules",
"oas3_1Preprocessors": "Preprocessors",
"oas3_1Rules": "Rules",
"oas3_2Decorators": "Decorators",
+ "oas3_2Diff": "DiffRules",
"oas3_2Preprocessors": "Preprocessors",
"oas3_2Rules": "Rules",
"openrpc1Decorators": "Decorators",
diff --git a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap
index 7d7c3f1a73..1678eac532 100644
--- a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap
+++ b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap
@@ -80,6 +80,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file
"tags-alphabetical": "off",
},
"async3Decorators": {},
+ "async3Diff": {},
"async3Preprocessors": {},
"async3Rules": {
"channels-kebab-case": "off",
@@ -101,6 +102,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file
"tags-alphabetical": "off",
},
"decorators": {},
+ "diff": {},
"graphqlRules": {
"no-unused-types": "warn",
"type-description": "off",
@@ -156,6 +158,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file
"tags-alphabetical": "off",
},
"oas3_0Decorators": {},
+ "oas3_0Diff": {},
"oas3_0Preprocessors": {},
"oas3_0Rules": {
"array-parameter-serialization": "off",
@@ -224,6 +227,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file
"tags-alphabetical": "off",
},
"oas3_1Decorators": {},
+ "oas3_1Diff": {},
"oas3_1Preprocessors": {},
"oas3_1Rules": {
"array-parameter-serialization": "off",
@@ -289,6 +293,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file
"tags-alphabetical": "off",
},
"oas3_2Decorators": {},
+ "oas3_2Diff": {},
"oas3_2Preprocessors": {},
"oas3_2Rules": {
"array-parameter-serialization": "off",
@@ -535,6 +540,7 @@ exports[`resolveConfig > should resolve extends with local file config which con
"tags-alphabetical": "off",
},
"async3Decorators": {},
+ "async3Diff": {},
"async3Preprocessors": {},
"async3Rules": {
"channels-kebab-case": "off",
@@ -556,6 +562,7 @@ exports[`resolveConfig > should resolve extends with local file config which con
"tags-alphabetical": "off",
},
"decorators": {},
+ "diff": {},
"graphqlRules": {
"no-unused-types": "warn",
"type-description": "off",
@@ -611,6 +618,7 @@ exports[`resolveConfig > should resolve extends with local file config which con
"tags-alphabetical": "off",
},
"oas3_0Decorators": {},
+ "oas3_0Diff": {},
"oas3_0Preprocessors": {},
"oas3_0Rules": {
"array-parameter-serialization": "off",
@@ -679,6 +687,7 @@ exports[`resolveConfig > should resolve extends with local file config which con
"tags-alphabetical": "off",
},
"oas3_1Decorators": {},
+ "oas3_1Diff": {},
"oas3_1Preprocessors": {},
"oas3_1Rules": {
"array-parameter-serialization": "off",
@@ -744,6 +753,7 @@ exports[`resolveConfig > should resolve extends with local file config which con
"tags-alphabetical": "off",
},
"oas3_2Decorators": {},
+ "oas3_2Diff": {},
"oas3_2Preprocessors": {},
"oas3_2Rules": {
"array-parameter-serialization": "off",
diff --git a/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap
index 764a4fb8cc..af326c6d63 100644
--- a/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap
+++ b/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap
@@ -19,6 +19,12 @@ Config {
"openrpc1": {},
"overlay1": {},
},
+ "diff": {
+ "async3": {},
+ "oas3_0": {},
+ "oas3_1": {},
+ "oas3_2": {},
+ },
"doNotResolveExamples": false,
"document": undefined,
"ignore": {
diff --git a/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap
index 449604b428..fd001f14b1 100644
--- a/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap
+++ b/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap
@@ -19,6 +19,12 @@ Config {
"openrpc1": {},
"overlay1": {},
},
+ "diff": {
+ "async3": {},
+ "oas3_0": {},
+ "oas3_1": {},
+ "oas3_2": {},
+ },
"doNotResolveExamples": false,
"document": undefined,
"ignore": {},
@@ -58,20 +64,25 @@ Config {
"async2Preprocessors": {},
"async2Rules": {},
"async3Decorators": {},
+ "async3Diff": {},
"async3Preprocessors": {},
"async3Rules": {},
"decorators": {},
+ "diff": {},
"graphqlRules": {},
"oas2Decorators": {},
"oas2Preprocessors": {},
"oas2Rules": {},
"oas3_0Decorators": {},
+ "oas3_0Diff": {},
"oas3_0Preprocessors": {},
"oas3_0Rules": {},
"oas3_1Decorators": {},
+ "oas3_1Diff": {},
"oas3_1Preprocessors": {},
"oas3_1Rules": {},
"oas3_2Decorators": {},
+ "oas3_2Diff": {},
"oas3_2Preprocessors": {},
"oas3_2Rules": {},
"openrpc1Decorators": {},
diff --git a/packages/core/src/config/__tests__/config.test.ts b/packages/core/src/config/__tests__/config.test.ts
index 5b4b2b2ecf..46b2688f34 100644
--- a/packages/core/src/config/__tests__/config.test.ts
+++ b/packages/core/src/config/__tests__/config.test.ts
@@ -43,6 +43,12 @@ describe('Config.forAlias', () => {
"openrpc1": {},
"overlay1": {},
},
+ "diff": {
+ "async3": {},
+ "oas3_0": {},
+ "oas3_1": {},
+ "oas3_2": {},
+ },
"doNotResolveExamples": false,
"document": {
"parsed": {
@@ -106,20 +112,25 @@ describe('Config.forAlias', () => {
"async2Preprocessors": {},
"async2Rules": {},
"async3Decorators": {},
+ "async3Diff": {},
"async3Preprocessors": {},
"async3Rules": {},
"decorators": {},
+ "diff": {},
"graphqlRules": {},
"oas2Decorators": {},
"oas2Preprocessors": {},
"oas2Rules": {},
"oas3_0Decorators": {},
+ "oas3_0Diff": {},
"oas3_0Preprocessors": {},
"oas3_0Rules": {},
"oas3_1Decorators": {},
+ "oas3_1Diff": {},
"oas3_1Preprocessors": {},
"oas3_1Rules": {},
"oas3_2Decorators": {},
+ "oas3_2Diff": {},
"oas3_2Preprocessors": {},
"oas3_2Rules": {},
"openrpc1Decorators": {},
diff --git a/packages/core/src/config/__tests__/load.test.ts b/packages/core/src/config/__tests__/load.test.ts
index b40067b24c..47b194a432 100644
--- a/packages/core/src/config/__tests__/load.test.ts
+++ b/packages/core/src/config/__tests__/load.test.ts
@@ -203,6 +203,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"async3Decorators": {},
+ "async3Diff": {},
"async3Preprocessors": {},
"async3Rules": {
"channels-kebab-case": "off",
@@ -224,6 +225,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"decorators": {},
+ "diff": {},
"graphqlRules": {
"no-unused-types": "off",
"type-description": "off",
@@ -282,6 +284,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_0Decorators": {},
+ "oas3_0Diff": {},
"oas3_0Preprocessors": {},
"oas3_0Rules": {
"array-parameter-serialization": "off",
@@ -352,6 +355,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_1Decorators": {},
+ "oas3_1Diff": {},
"oas3_1Preprocessors": {},
"oas3_1Rules": {
"array-parameter-serialization": "off",
@@ -419,6 +423,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_2Decorators": {},
+ "oas3_2Diff": {},
"oas3_2Preprocessors": {},
"oas3_2Rules": {
"array-parameter-serialization": "off",
@@ -595,6 +600,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"async3Decorators": {},
+ "async3Diff": {},
"async3Preprocessors": {},
"async3Rules": {
"channels-kebab-case": "off",
@@ -616,6 +622,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"decorators": {},
+ "diff": {},
"graphqlRules": {
"no-unused-types": "warn",
"type-description": "off",
@@ -672,6 +679,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_0Decorators": {},
+ "oas3_0Diff": {},
"oas3_0Preprocessors": {},
"oas3_0Rules": {
"array-parameter-serialization": "off",
@@ -740,6 +748,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_1Decorators": {},
+ "oas3_1Diff": {},
"oas3_1Preprocessors": {},
"oas3_1Rules": {
"array-parameter-serialization": "off",
@@ -805,6 +814,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_2Decorators": {},
+ "oas3_2Diff": {},
"oas3_2Preprocessors": {},
"oas3_2Rules": {
"array-parameter-serialization": "off",
@@ -992,6 +1002,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"async3Decorators": {},
+ "async3Diff": {},
"async3Preprocessors": {},
"async3Rules": {
"channels-kebab-case": "off",
@@ -1013,6 +1024,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"decorators": {},
+ "diff": {},
"graphqlRules": {
"no-unused-types": "off",
"type-description": "off",
@@ -1069,6 +1081,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_0Decorators": {},
+ "oas3_0Diff": {},
"oas3_0Preprocessors": {},
"oas3_0Rules": {
"array-parameter-serialization": "off",
@@ -1137,6 +1150,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_1Decorators": {},
+ "oas3_1Diff": {},
"oas3_1Preprocessors": {},
"oas3_1Rules": {
"array-parameter-serialization": "off",
@@ -1202,6 +1216,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_2Decorators": {},
+ "oas3_2Diff": {},
"oas3_2Preprocessors": {},
"oas3_2Rules": {
"array-parameter-serialization": "off",
@@ -1473,6 +1488,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"async3Decorators": {},
+ "async3Diff": {},
"async3Preprocessors": {},
"async3Rules": {
"channels-kebab-case": "off",
@@ -1494,6 +1510,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"decorators": {},
+ "diff": {},
"graphqlRules": {
"no-unused-types": "off",
"type-description": "off",
@@ -1552,6 +1569,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_0Decorators": {},
+ "oas3_0Diff": {},
"oas3_0Preprocessors": {},
"oas3_0Rules": {
"array-parameter-serialization": "off",
@@ -1622,6 +1640,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_1Decorators": {},
+ "oas3_1Diff": {},
"oas3_1Preprocessors": {},
"oas3_1Rules": {
"array-parameter-serialization": "off",
@@ -1689,6 +1708,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_2Decorators": {},
+ "oas3_2Diff": {},
"oas3_2Preprocessors": {},
"oas3_2Rules": {
"array-parameter-serialization": "off",
@@ -1865,6 +1885,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"async3Decorators": {},
+ "async3Diff": {},
"async3Preprocessors": {},
"async3Rules": {
"channels-kebab-case": "off",
@@ -1886,6 +1907,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"decorators": {},
+ "diff": {},
"graphqlRules": {
"no-unused-types": "warn",
"type-description": "off",
@@ -1942,6 +1964,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_0Decorators": {},
+ "oas3_0Diff": {},
"oas3_0Preprocessors": {},
"oas3_0Rules": {
"array-parameter-serialization": "off",
@@ -2010,6 +2033,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_1Decorators": {},
+ "oas3_1Diff": {},
"oas3_1Preprocessors": {},
"oas3_1Rules": {
"array-parameter-serialization": "off",
@@ -2075,6 +2099,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_2Decorators": {},
+ "oas3_2Diff": {},
"oas3_2Preprocessors": {},
"oas3_2Rules": {
"array-parameter-serialization": "off",
@@ -2262,6 +2287,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"async3Decorators": {},
+ "async3Diff": {},
"async3Preprocessors": {},
"async3Rules": {
"channels-kebab-case": "off",
@@ -2283,6 +2309,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"decorators": {},
+ "diff": {},
"graphqlRules": {
"no-unused-types": "off",
"type-description": "off",
@@ -2339,6 +2366,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_0Decorators": {},
+ "oas3_0Diff": {},
"oas3_0Preprocessors": {},
"oas3_0Rules": {
"array-parameter-serialization": "off",
@@ -2407,6 +2435,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_1Decorators": {},
+ "oas3_1Diff": {},
"oas3_1Preprocessors": {},
"oas3_1Rules": {
"array-parameter-serialization": "off",
@@ -2472,6 +2501,7 @@ describe('loadConfig', () => {
"tags-alphabetical": "off",
},
"oas3_2Decorators": {},
+ "oas3_2Diff": {},
"oas3_2Preprocessors": {},
"oas3_2Rules": {
"array-parameter-serialization": "off",
diff --git a/packages/core/src/config/builtIn.ts b/packages/core/src/config/builtIn.ts
index 6141c1df8d..c8f507609b 100644
--- a/packages/core/src/config/builtIn.ts
+++ b/packages/core/src/config/builtIn.ts
@@ -5,6 +5,7 @@ import { decorators as oas2Decorators } from '../decorators/oas2/index.js';
import { decorators as oas3Decorators } from '../decorators/oas3/index.js';
import { decorators as openrpc1Decorators } from '../decorators/openrpc/index.js';
import { decorators as overlay1Decorators } from '../decorators/overlay1/index.js';
+import { diffRuleSets } from '../diff/rules/index.js';
import {
rules as arazzo1Rules,
preprocessors as arazzoPreprocessors,
@@ -29,6 +30,7 @@ import {
preprocessors as overlay1Preprocessors,
} from '../rules/overlay1/index.js';
import all from './all.js';
+import diffRecommended from './diff-recommended.js';
import minimal from './minimal.js';
import recommendedStrict from './recommended-strict.js';
import recommended from './recommended.js';
@@ -36,11 +38,18 @@ import spec from './spec.js';
import { type Plugin, type RawGovernanceConfig } from './types.js';
export const builtInConfigs: Record = {
+ /**
+ * lint presets
+ */
recommended,
'recommended-strict': recommendedStrict,
minimal,
all,
spec,
+ /**
+ * diff presets
+ */
+ 'diff-recommended': diffRecommended,
};
export const defaultPlugin: Plugin<'built-in'> = {
@@ -73,5 +82,6 @@ export const defaultPlugin: Plugin<'built-in'> = {
overlay1: overlay1Decorators,
openrpc1: openrpc1Decorators,
},
+ diff: diffRuleSets,
configs: builtInConfigs,
};
diff --git a/packages/core/src/config/config-resolvers.ts b/packages/core/src/config/config-resolvers.ts
index 0830b21f46..ed07aeee63 100644
--- a/packages/core/src/config/config-resolvers.ts
+++ b/packages/core/src/config/config-resolvers.ts
@@ -499,6 +499,19 @@ export async function resolvePlugins(
plugin.assertions = pluginInstance.assertions;
}
+ if (pluginInstance.diff) {
+ if (!pluginInstance.diff.oas3 && !pluginInstance.diff.async3) {
+ throw new Error(`Plugin diff rules must have \`oas3\` or \`async3\` rules "${p}.`);
+ }
+ plugin.diff = {};
+ if (pluginInstance.diff.oas3) {
+ plugin.diff.oas3 = prefixRules(pluginInstance.diff.oas3, id);
+ }
+ if (pluginInstance.diff.async3) {
+ plugin.diff.async3 = prefixRules(pluginInstance.diff.async3, id);
+ }
+ }
+
return {
...pluginInstance,
...plugin,
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index ebe4ba0be0..666f5ffe70 100755
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -2,6 +2,8 @@ import type { RecheckConfig } from '@redocly/config';
import * as fs from 'node:fs';
import * as path from 'node:path';
+import { isDiffFamily } from '../diff/rules/index.js';
+import type { DiffRule, Impact } from '../diff/types.js';
import { stringifyYaml } from '../js-yaml/index.js';
import {
type Oas2RuleSet,
@@ -19,6 +21,7 @@ import {
import { isAbsoluteUrl } from '../ref-utils.js';
import type { Document, ResolvedRefMap } from '../resolve.js';
import type { NodeType } from '../types/index.js';
+import { isDefined } from '../utils/is-defined.js';
import { isPlainObject } from '../utils/is-plain-object.js';
import { omit } from '../utils/omit.js';
import { slash } from '../utils/slash.js';
@@ -52,6 +55,8 @@ export class Config {
preprocessors: Record>;
decorators: Record>;
recheck: RecheckConfig;
+ /** Only the specifications the diff rules cover; the rest are absent, like in `DIRECTIONS`. */
+ diff: Partial>>;
private _usedRules: Set = new Set();
private _usedVersions: Set = new Set();
@@ -161,6 +166,13 @@ export class Config {
};
this.recheck = resolvedConfig.recheck ?? { rules: {} };
+ this.diff = {
+ oas3_0: { ...resolvedConfig.diff, ...resolvedConfig.oas3_0Diff },
+ oas3_1: { ...resolvedConfig.diff, ...resolvedConfig.oas3_1Diff },
+ oas3_2: { ...resolvedConfig.diff, ...resolvedConfig.oas3_2Diff },
+ async3: { ...resolvedConfig.diff, ...resolvedConfig.async3Diff },
+ };
+
this.ignore = opts.ignore ?? {};
}
@@ -346,6 +358,12 @@ export class Config {
}
// TODO: add rules for redocly.yaml / entities?
+ /** The diff rule sets every plugin offers for that specification family. */
+ getDiffRulesForSpecVersion(family: SpecMajorVersion): Record[] {
+ if (!isDiffFamily(family)) return [];
+ return this.plugins.map((plugin) => plugin.diff?.[family]).filter(isDefined);
+ }
+
getRulesForSpecVersion(version: SpecMajorVersion) {
switch (version) {
case 'oas3': {
@@ -436,6 +454,10 @@ export class Config {
}
}
+ getDiffImpact(ruleId: string, specVersion: SpecVersion): Impact | 'off' {
+ return this.diff[specVersion]?.[ruleId] || 'off';
+ }
+
skipRules(rules?: string[]) {
for (const ruleId of rules || []) {
for (const version of specVersions) {
@@ -472,4 +494,14 @@ export class Config {
}
}
}
+
+ skipDiffRules(rules?: string[]) {
+ for (const ruleId of rules || []) {
+ for (const impacts of Object.values(this.diff)) {
+ if (impacts[ruleId]) {
+ impacts[ruleId] = 'off';
+ }
+ }
+ }
+ }
}
diff --git a/packages/core/src/config/constants.ts b/packages/core/src/config/constants.ts
index aa8853a751..c35144bd65 100644
--- a/packages/core/src/config/constants.ts
+++ b/packages/core/src/config/constants.ts
@@ -1,6 +1,6 @@
export const CONFIG_FILE_NAME = 'redocly.yaml';
-export const DEFAULT_CONFIG = { extends: ['recommended'] };
+export const DEFAULT_CONFIG = { extends: ['recommended', 'diff-recommended'] };
export const IGNORE_FILE = '.redocly.lint-ignore.yaml';
export const IGNORE_BANNER =
diff --git a/packages/core/src/config/diff-recommended.ts b/packages/core/src/config/diff-recommended.ts
new file mode 100644
index 0000000000..8be9af7057
--- /dev/null
+++ b/packages/core/src/config/diff-recommended.ts
@@ -0,0 +1,39 @@
+import type { DiffRuleId } from '../diff/rules/index.js';
+import type { Impact } from '../diff/types.js';
+import { type RawGovernanceConfig } from './types.js';
+
+export const defaultDiffRules: Record = {
+ 'channel-address-changed': 'major',
+ 'channel-removed': 'major',
+ 'enum-values-added': 'major',
+ 'enum-values-removed': 'major',
+ 'media-type-removed': 'major',
+ 'message-content-type-changed': 'major',
+ 'message-removed': 'major',
+ 'operation-action-changed': 'major',
+ 'operation-id-changed': 'major',
+ 'operation-removed': 'major',
+ 'parameter-became-required': 'major',
+ 'parameter-removed': 'major',
+ 'parameter-serialization-changed': 'major',
+ 'path-removed': 'major',
+ 'property-removed': 'major',
+ 'request-body-became-required': 'major',
+ 'request-body-removed': 'major',
+ 'required-properties-added': 'major',
+ 'required-properties-removed': 'major',
+ 'response-header-removed': 'major',
+ 'response-removed': 'major',
+ 'schema-combinator-changed': 'major',
+ 'schema-constraint-changed': 'major',
+ 'schema-type-changed': 'major',
+ 'security-requirement-changed': 'major',
+ 'security-scheme-changed': 'major',
+ 'server-removed': 'major',
+};
+
+const diffRecommended: RawGovernanceConfig<'built-in'> = {
+ diff: defaultDiffRules,
+};
+
+export default diffRecommended;
diff --git a/packages/core/src/config/rules.ts b/packages/core/src/config/rules.ts
index 85ae65212b..ca87333f93 100644
--- a/packages/core/src/config/rules.ts
+++ b/packages/core/src/config/rules.ts
@@ -1,3 +1,4 @@
+import type { DiffRule, DiffVisitor, Impact } from '../diff/types.js';
import type {
Arazzo1RuleSet,
Async2RuleSet,
@@ -73,3 +74,27 @@ export function initRules(
.flatMap((visitor) => visitor)
.filter(isDefined);
}
+
+export type InitializedDiffRule = {
+ impact: Impact;
+ ruleId: string;
+ visitor: DiffVisitor;
+};
+
+export function initDiffRules(
+ rules: Record[],
+ config: Config,
+ specVersion: SpecVersion
+): InitializedDiffRule[] {
+ return rules.flatMap((ruleset) =>
+ Object.keys(ruleset).flatMap((ruleId) => {
+ const impact = config.getDiffImpact(ruleId, specVersion);
+
+ if (impact === 'off') {
+ return [];
+ }
+
+ return [{ impact, ruleId, visitor: ruleset[ruleId]() }];
+ })
+ );
+}
diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts
index 5cd93f7faa..147b21e03f 100644
--- a/packages/core/src/config/types.ts
+++ b/packages/core/src/config/types.ts
@@ -1,6 +1,8 @@
import type { ApiConfig, RecheckConfig, RedoclyConfig } from '@redocly/config';
import type { JSONSchema } from 'json-schema-to-ts';
+import type { DiffRuleId, DiffRuleSets } from '../diff/rules/index.js';
+import type { DiffRule, Impact } from '../diff/types.js';
import type {
SpecMajorVersion,
Oas3DecoratorsSet,
@@ -144,6 +146,11 @@ export type RawGovernanceConfig =
openrpc1Decorators?: Record;
recheck?: RecheckConfig;
+ diff?: RuleMap;
+ oas3_0Diff?: RuleMap;
+ oas3_1Diff?: RuleMap;
+ oas3_2Diff?: RuleMap;
+ async3Diff?: RuleMap;
};
export type ResolvedGovernanceConfig = Omit;
@@ -191,6 +198,10 @@ export type RulesConfig = {
export type CustomRulesConfig = RulesConfig;
+export type DiffRulesConfig = {
+ [Family in keyof DiffRuleSets]?: RuleMap;
+};
+
export type AssertionContext = Partial & SkipFunctionContext & { node: any };
export type AssertResult = { message?: string; location?: Location };
@@ -207,10 +218,11 @@ export type Plugin = {
configs?: Record;
rules?: RulesConfig;
+ assertions?: AssertionsConfig;
preprocessors?: PreprocessorsConfig;
decorators?: DecoratorsConfig;
+ diff?: DiffRulesConfig;
typeExtension?: TypeExtensionsConfig;
- assertions?: AssertionsConfig;
// Realm properties
path?: string;
diff --git a/packages/core/src/config/utils.ts b/packages/core/src/config/utils.ts
index e3429b1fd7..9bb845bddd 100644
--- a/packages/core/src/config/utils.ts
+++ b/packages/core/src/config/utils.ts
@@ -86,6 +86,11 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) {
openrpc1Decorators: {},
recheck,
+ diff: {},
+ oas3_0Diff: {},
+ oas3_1Diff: {},
+ oas3_2Diff: {},
+ async3Diff: {},
};
for (const rulesConf of rulesConfList) {
@@ -164,6 +169,15 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) {
assignOnlyExistingConfig(result.openrpc1Decorators, rulesConf.decorators);
recheck = mergeRecheckBlocks(recheck, rulesConf.recheck);
+ assignConfig(result.diff, rulesConf.diff);
+ assignConfig(result.oas3_0Diff, rulesConf.oas3_0Diff);
+ assignOnlyExistingConfig(result.oas3_0Diff, rulesConf.diff);
+ assignConfig(result.oas3_1Diff, rulesConf.oas3_1Diff);
+ assignOnlyExistingConfig(result.oas3_1Diff, rulesConf.diff);
+ assignConfig(result.oas3_2Diff, rulesConf.oas3_2Diff);
+ assignOnlyExistingConfig(result.oas3_2Diff, rulesConf.diff);
+ assignConfig(result.async3Diff, rulesConf.async3Diff);
+ assignOnlyExistingConfig(result.async3Diff, rulesConf.diff);
}
result.recheck = recheck;
diff --git a/packages/core/src/diff/__tests__/changes.test.ts b/packages/core/src/diff/__tests__/changes.test.ts
new file mode 100644
index 0000000000..8fab0d2a69
--- /dev/null
+++ b/packages/core/src/diff/__tests__/changes.test.ts
@@ -0,0 +1,205 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../__tests__/utils.js';
+import { compareTrees } from '../diff-tree.js';
+import { nodeTreeOf, treeOf } from './utils.js';
+
+const cafe = (menuItem: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths: {}
+ components:
+ schemas:
+ MenuItem: ${menuItem}
+`;
+
+describe('changesOf', () => {
+ it('should report every property that differs, at its escaped pointer on each side', async () => {
+ const base = await nodeTreeOf(
+ cafe("{ type: integer, x-price/unit: cents, description: 'A drink' }")
+ );
+ const revision = await nodeTreeOf(
+ cafe("{ type: number, x-price/unit: euros, description: 'A drink' }")
+ );
+
+ const changes = compareTrees(base, revision);
+
+ expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "api.yaml#/components/schemas/MenuItem/type",
+ "value": "integer",
+ },
+ "key": "#/components/schemas/MenuItem",
+ "kind": "modified",
+ "property": "type",
+ "revision": {
+ "location": "api.yaml#/components/schemas/MenuItem/type",
+ "value": "number",
+ },
+ },
+ {
+ "base": {
+ "location": "api.yaml#/components/schemas/MenuItem/x-price~1unit",
+ "value": "cents",
+ },
+ "key": "#/components/schemas/MenuItem",
+ "kind": "modified",
+ "property": "x-price/unit",
+ "revision": {
+ "location": "api.yaml#/components/schemas/MenuItem/x-price~1unit",
+ "value": "euros",
+ },
+ },
+ ]
+ `);
+ });
+
+ it('should locate a property one side does not have at the node itself', async () => {
+ const base = await nodeTreeOf(cafe('{ type: object }'));
+ const revision = await nodeTreeOf(cafe('{ type: object, minProperties: 1 }'));
+
+ const changes = compareTrees(base, revision);
+
+ expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "api.yaml#/components/schemas/MenuItem",
+ "value": undefined,
+ },
+ "key": "#/components/schemas/MenuItem",
+ "kind": "modified",
+ "property": "minProperties",
+ "revision": {
+ "location": "api.yaml#/components/schemas/MenuItem/minProperties",
+ "value": 1,
+ },
+ },
+ ]
+ `);
+ });
+
+ it('should report a removed node once, with its whole value, and nothing below it', async () => {
+ const base = await nodeTreeOf(cafe('{ properties: { price: { type: number, minimum: 0 } } }'));
+ const revision = await nodeTreeOf(cafe('{ properties: {} }'));
+
+ const changes = compareTrees(base, revision);
+
+ expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "api.yaml#/components/schemas/MenuItem/properties/price",
+ "value": {
+ "minimum": 0,
+ "type": "number",
+ },
+ },
+ "key": "#/components/schemas/MenuItem/properties/price",
+ "kind": "removed",
+ },
+ ]
+ `);
+ });
+
+ it('should report a node of another type in the same place as a removal and an addition', () => {
+ // The walker gives a place its type from the document, so the two sides can disagree.
+ const base = treeOf(`
+ #/ Root
+ #/components Components
+ #/components/examples NamedExamples
+ #/components/examples/latte Example
+ `);
+ const revision = treeOf(`
+ #/ Root
+ #/components Components
+ #/components/examples NamedExamples
+ #/components/examples/latte Schema
+ `);
+
+ const changes = compareTrees(base.get('#/')!, revision.get('#/')!);
+
+ expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "tree.yaml#/components/examples/latte",
+ "value": {},
+ },
+ "key": "#/components/examples/latte",
+ "kind": "removed",
+ },
+ {
+ "key": "#/components/examples/latte",
+ "kind": "added",
+ "revision": {
+ "location": "tree.yaml#/components/examples/latte",
+ "value": {},
+ },
+ },
+ ]
+ `);
+ });
+
+ it('should compare a $ref that does not resolve by where it points', async () => {
+ const base = await nodeTreeOf(cafe("{ $ref: '#/components/schemas/Beverage' }"));
+ const revision = await nodeTreeOf(cafe("{ $ref: '#/components/schemas/Dessert' }"));
+
+ const changes = compareTrees(base, revision);
+
+ expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "api.yaml#/components/schemas/MenuItem/$ref",
+ "value": "#/components/schemas/Beverage",
+ },
+ "key": "#/components/schemas/MenuItem",
+ "kind": "modified",
+ "property": "$ref",
+ "revision": {
+ "location": "api.yaml#/components/schemas/MenuItem/$ref",
+ "value": "#/components/schemas/Dessert",
+ },
+ },
+ ]
+ `);
+ });
+
+ it('should compare an empty array that is not a node of its own as a value', async () => {
+ const orders = (security: string) =>
+ nodeTreeOf(
+ `openapi: 3.1.0\ninfo: { title: Cafe, version: 1.0.0 }\npaths: {}\nsecurity: ${security}`
+ );
+
+ const changes = compareTrees(
+ await orders('[{ ApiKey: [] }]'),
+ await orders('[{ ApiKey: [], OAuth: [] }]')
+ );
+
+ expect(changes).toMatchObject([
+ { key: '#/security/0', kind: 'modified', property: 'OAuth', revision: { value: [] } },
+ ]);
+ });
+
+ it('should report a list that the walker records as a node once, not as a value too', async () => {
+ const orders = (security: string) =>
+ nodeTreeOf(
+ `openapi: 3.1.0\ninfo: { title: Cafe, version: 1.0.0 }\npaths: {}\nsecurity: ${security}`
+ );
+
+ const changes = compareTrees(await orders('[]'), await orders('[{ ApiKey: [] }]'));
+
+ expect(changes).toMatchObject([{ key: '#/security/0', kind: 'added' }]);
+ });
+
+ it('should report nothing when the documents are the same', async () => {
+ const root = await nodeTreeOf(cafe('{ type: string, enum: [coffee, tea] }'));
+
+ const changes = compareTrees(root, root);
+
+ expect(changes).toEqual([]);
+ });
+});
diff --git a/packages/core/src/diff/__tests__/diff-tree.test.ts b/packages/core/src/diff/__tests__/diff-tree.test.ts
new file mode 100644
index 0000000000..c918e1e613
--- /dev/null
+++ b/packages/core/src/diff/__tests__/diff-tree.test.ts
@@ -0,0 +1,373 @@
+import * as path from 'node:path';
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges, resolverWithFiles } from '../../../__tests__/utils.js';
+import { pointerOf } from '../diff-node.js';
+import { compareTrees } from '../diff-tree.js';
+import { nodeTreeOf } from './utils.js';
+
+const cafe = (body: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ ${body}
+`;
+
+describe('compareTrees', () => {
+ const menu = (parameters: string) =>
+ cafe(outdent`
+ paths:
+ /menu:
+ get:
+ parameters: ${parameters}
+ responses: {}
+ `);
+ const compare = async (base: string, revision: string) =>
+ compareTrees(await nodeTreeOf(base), await nodeTreeOf(revision));
+
+ it('should compare each child with the child it pairs with', async () => {
+ const changes = await compare(
+ menu('[{ name: limit, in: query }, { name: offset, in: query }]'),
+ menu('[{ name: offset, in: query }, { name: limit, in: query, description: How many }]')
+ );
+
+ expect(changes).toMatchObject([
+ { key: '#/paths/~1menu/get/parameters/1', kind: 'modified', property: 'description' },
+ ]);
+ });
+
+ it('should pair a map entry renamed to a similar key and report the key as changed', async () => {
+ const changes = await compare(
+ cafe('paths: { /order: { get: { responses: {} } } }'),
+ cafe('paths: { /orders: { get: { responses: {} } } }')
+ );
+
+ expect(changes).toMatchObject([
+ {
+ key: '#/paths/~1orders',
+ kind: 'modified',
+ property: 'key',
+ base: { value: '/order' },
+ revision: { value: '/orders' },
+ },
+ ]);
+ });
+
+ it('should tell fixed fields from map entries by the node types', async () => {
+ const changes = await compare(
+ cafe(
+ 'paths: {}\ncomponents: { schemas: { Order: { unevaluatedProperties: { type: string } } } }'
+ ),
+ cafe('paths: {}\ncomponents: { schemas: { Order: { unevaluatedItems: { type: string } } } }')
+ );
+
+ expect(changes).toMatchObject([
+ { key: '#/components/schemas/Order/unevaluatedProperties', kind: 'removed' },
+ { key: '#/components/schemas/Order/unevaluatedItems', kind: 'added' },
+ ]);
+ });
+});
+
+describe('compareTrees through $refs', () => {
+ const menu = (schema: string, schemas: string) =>
+ cafe(outdent`
+ paths:
+ /menu:
+ get:
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: ${schema}
+ components:
+ schemas: ${schemas}
+ `);
+ const item = (nameType: string) =>
+ `{ type: object, properties: { name: { type: ${nameType} } } }`;
+ const itemRef = "{ $ref: '#/components/schemas/Item' }";
+ const menuItemRef = "{ $ref: '#/components/schemas/MenuItem' }";
+
+ // Each side has a folder of its own, so both roots reference their files by the same paths.
+ async function nodeTreesOfFiles(sides: Record<'base' | 'revision', Record>) {
+ const load = (side: 'base' | 'revision') => {
+ const files = Object.entries(sides[side]).map(([file, body]) => [
+ path.join(side, file),
+ body,
+ ]);
+ return nodeTreeOf(
+ sides[side]['openapi.yaml'],
+ path.resolve(side, 'openapi.yaml'),
+ resolverWithFiles(Object.fromEntries(files))
+ );
+ };
+ return { base: await load('base'), revision: await load('revision') };
+ }
+
+ it('should compare $refs to different targets by what the targets describe', async () => {
+ const schemas = `{ Item: ${item('string')}, MenuItem: ${item('integer')} }`;
+ const base = await nodeTreeOf(menu(itemRef, schemas));
+ const revision = await nodeTreeOf(menu(menuItemRef, schemas));
+
+ expect(replaceSourceWithRefInChanges(compareTrees(base, revision))).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "api.yaml#/components/schemas/Item/properties/name/type",
+ "value": "string",
+ },
+ "key": "#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/name",
+ "kind": "modified",
+ "property": "type",
+ "revision": {
+ "location": "api.yaml#/components/schemas/MenuItem/properties/name/type",
+ "value": "integer",
+ },
+ },
+ ]
+ `);
+ });
+
+ it('should find no change where an inline schema moved into a component that reads the same', async () => {
+ const base = await nodeTreeOf(menu(item('string'), '{}'));
+ const revision = await nodeTreeOf(menu(itemRef, `{ Item: ${item('string')} }`));
+
+ expect(compareTrees(base, revision)).toMatchObject([
+ { key: '#/components/schemas/Item', kind: 'added' },
+ ]);
+ });
+
+ it('should compare a component that both documents reference under the place that references it', async () => {
+ const base = await nodeTreeOf(menu(itemRef, `{ Item: ${item('string')} }`));
+ const revision = await nodeTreeOf(menu(itemRef, `{ Item: ${item('integer')} }`));
+
+ expect(compareTrees(base, revision).map((change) => change.key)).toEqual([
+ '#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/name',
+ ]);
+ });
+
+ it('should compare a pair of targets once, under the first place that references both', async () => {
+ const orders = (ref: string) =>
+ cafe(outdent`
+ paths:
+ /menu:
+ get:
+ responses:
+ '200': { description: OK, content: { application/json: { schema: ${ref} } } }
+ post:
+ requestBody: { content: { application/json: { schema: ${ref} } } }
+ responses: {}
+ components:
+ schemas: { Item: ${item('string')}, MenuItem: ${item('integer')} }
+ `);
+ const base = await nodeTreeOf(orders(itemRef));
+ const revision = await nodeTreeOf(orders(menuItemRef));
+
+ expect(compareTrees(base, revision).map((change) => change.key)).toEqual([
+ '#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/name',
+ ]);
+ });
+
+ it('should note the other places that reach a pair it has compared', async () => {
+ const twoUses = (schemas: string) =>
+ cafe(outdent`
+ paths:
+ /menu:
+ get:
+ responses:
+ '200': { description: OK, content: { application/json: { schema: ${itemRef} } } }
+ post:
+ requestBody: { content: { application/json: { schema: ${itemRef} } } }
+ responses: {}
+ components:
+ schemas: ${schemas}
+ `);
+ const base = await nodeTreeOf(twoUses(`{ Item: ${item('string')} }`));
+ const revision = await nodeTreeOf(twoUses(`{ Item: ${item('integer')} }`));
+
+ const [nameChanged] = compareTrees(base, revision);
+ const itemPair = nameChanged.node.parent!.parent!;
+
+ expect(pointerOf(itemPair)).toBe(
+ '#/paths/~1menu/get/responses/200/content/application~1json/schema'
+ );
+ expect(itemPair.referencedBy.map(pointerOf)).toEqual([
+ '#/paths/~1menu/post/requestBody/content/application~1json/schema',
+ '#/components/schemas/Item',
+ ]);
+ });
+
+ it('should end the comparison of components that reference themselves', async () => {
+ const related = (name: string, description: string) =>
+ `{ description: ${description}, properties: { related: { $ref: '#/components/schemas/${name}' } } }`;
+ const schemas = `{ Item: ${related('Item', 'An item')}, MenuItem: ${related('MenuItem', 'A dish')} }`;
+ const base = await nodeTreeOf(menu(itemRef, schemas));
+ const revision = await nodeTreeOf(menu(menuItemRef, schemas));
+
+ expect(compareTrees(base, revision)).toMatchObject([
+ {
+ key: '#/paths/~1menu/get/responses/200/content/application~1json/schema',
+ kind: 'modified',
+ property: 'description',
+ },
+ ]);
+ });
+
+ it('should locate a change in another file in the file of each side', async () => {
+ const files = (nameType: string) => ({
+ 'openapi.yaml': menu('{ $ref: schemas/item.yaml }', '{}'),
+ 'schemas/item.yaml': item(nameType),
+ });
+ const { base, revision } = await nodeTreesOfFiles({
+ base: files('string'),
+ revision: files('integer'),
+ });
+
+ expect(compareTrees(base, revision)).toMatchObject([
+ {
+ key: '#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/name',
+ kind: 'modified',
+ property: 'type',
+ base: {
+ location: {
+ source: { absoluteRef: path.resolve('base/schemas/item.yaml') },
+ pointer: '#/properties/name/type',
+ },
+ },
+ revision: {
+ location: {
+ source: { absoluteRef: path.resolve('revision/schemas/item.yaml') },
+ pointer: '#/properties/name/type',
+ },
+ },
+ },
+ ]);
+ });
+
+ it('should match $refs to other files in a list by their files, in any order', async () => {
+ const files = (oneOf: string) => ({
+ 'openapi.yaml': menu(`{ oneOf: ${oneOf} }`, '{}'),
+ 'coffee.yaml': '{ type: object, properties: { roast: { type: string } } }',
+ 'tea.yaml': '{ type: object, properties: { leaf: { type: integer } } }',
+ });
+ const { base, revision } = await nodeTreesOfFiles({
+ base: files('[{ $ref: coffee.yaml }, { $ref: tea.yaml }]'),
+ revision: files('[{ $ref: tea.yaml }, { $ref: coffee.yaml }]'),
+ });
+
+ expect(compareTrees(base, revision)).toEqual([]);
+ });
+
+ it('should compare the keys written next to a $ref at its place and its targets under $ref', async () => {
+ const order = (ref: string, extraType: string) =>
+ menu(
+ `{ ${ref}, properties: { extra: { type: ${extraType} } } }`,
+ `{ Item: ${item('string')}, MenuItem: ${item('integer')} }`
+ );
+ const base = await nodeTreeOf(order("$ref: '#/components/schemas/Item'", 'string'));
+ const revision = await nodeTreeOf(order("$ref: '#/components/schemas/MenuItem'", 'integer'));
+
+ expect(compareTrees(base, revision).map((change) => change.key)).toEqual([
+ '#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/extra',
+ '#/paths/~1menu/get/responses/200/content/application~1json/schema/$ref/properties/name',
+ ]);
+ });
+
+ it('should keep a $ref on its pair and locate its changes in the target', async () => {
+ const base = await nodeTreeOf(menu(itemRef, `{ Item: ${item('string')} }`));
+ const revision = await nodeTreeOf(menu(itemRef, `{ Item: ${item('integer')} }`));
+
+ const [nameChanged] = compareTrees(base, revision);
+ const schemaPair = nameChanged.node.parent!.parent!;
+
+ expect(schemaPair.revision).toMatchObject({
+ value: { $ref: '#/components/schemas/Item' },
+ resolved: { key: 'Item' },
+ });
+ expect(nameChanged).toMatchObject({
+ key: '#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/name',
+ revision: { location: { pointer: '#/components/schemas/Item/properties/name/type' } },
+ });
+ });
+
+ it('should compare a description added next to a $ref at the place, not the target against it', async () => {
+ const schemas = `{ Item: ${item('string')} }`;
+ const base = await nodeTreeOf(menu(itemRef, schemas));
+ const revision = await nodeTreeOf(
+ menu("{ $ref: '#/components/schemas/Item', description: An item }", schemas)
+ );
+
+ expect(compareTrees(base, revision)).toMatchObject([
+ {
+ key: '#/paths/~1menu/get/responses/200/content/application~1json/schema',
+ kind: 'modified',
+ property: 'description',
+ base: { value: undefined },
+ revision: { value: 'An item' },
+ },
+ ]);
+ });
+
+ it('should follow the target of a component written as a $ref with keys next to it, where a path first reaches it', async () => {
+ const files = (nameType: string) => ({
+ 'openapi.yaml': menu(
+ "{ $ref: '#/components/schemas/A' }",
+ '{ A: { $ref: ./b.yaml, description: An item } }'
+ ),
+ 'b.yaml': item(nameType),
+ });
+ const { base, revision } = await nodeTreesOfFiles({
+ base: files('string'),
+ revision: files('integer'),
+ });
+
+ expect(compareTrees(base, revision)).toMatchObject([
+ {
+ key: '#/paths/~1menu/get/responses/200/content/application~1json/schema/$ref/properties/name',
+ kind: 'modified',
+ property: 'type',
+ },
+ ]);
+ });
+
+ it('should compare the one target of $refs with keys next to them under $ref of the place', async () => {
+ const schema = "{ $ref: '#/components/schemas/Item', description: A dish }";
+ const base = await nodeTreeOf(menu(schema, `{ Item: ${item('string')} }`));
+ const revision = await nodeTreeOf(menu(schema, `{ Item: ${item('integer')} }`));
+
+ expect(compareTrees(base, revision).map((change) => change.key)).toEqual([
+ '#/paths/~1menu/get/responses/200/content/application~1json/schema/$ref/properties/name',
+ ]);
+ });
+
+ it('should report a renamed path by the keys of the places, not of their targets', async () => {
+ const menuItem = (template: string, pathItem: string) =>
+ cafe(outdent`
+ paths:
+ /menu/{${template}}: { $ref: '#/components/pathItems/${pathItem}' }
+ components:
+ pathItems:
+ Item: { get: { responses: {} } }
+ MenuItem: { get: { responses: {} } }
+ `);
+ const base = await nodeTreeOf(menuItem('id', 'Item'));
+ const revision = await nodeTreeOf(menuItem('itemId', 'MenuItem'));
+
+ expect(replaceSourceWithRefInChanges(compareTrees(base, revision))).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "api.yaml#/paths/~1menu~1{id}",
+ "value": "/menu/{id}",
+ },
+ "key": "#/paths/~1menu~1{itemId}",
+ "kind": "modified",
+ "property": "key",
+ "revision": {
+ "location": "api.yaml#/paths/~1menu~1{itemId}",
+ "value": "/menu/{itemId}",
+ },
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/__tests__/direction.test.ts b/packages/core/src/diff/__tests__/direction.test.ts
new file mode 100644
index 0000000000..bbcd51d9c0
--- /dev/null
+++ b/packages/core/src/diff/__tests__/direction.test.ts
@@ -0,0 +1,235 @@
+import { directionsOf } from '../direction.js';
+import { diffTreeOf, withReferences } from './utils.js';
+
+describe('directionsOf for OpenAPI 3', () => {
+ it('should read the direction off the place a node sits in', () => {
+ const cafe = diffTreeOf(`
+ #/ Root
+ #/info Info
+ #/paths Paths
+ #/paths/~1orders PathItem
+ #/paths/~1orders/get Operation
+ #/paths/~1orders/get/parameters ParameterList
+ #/paths/~1orders/get/parameters/0 Parameter
+ #/paths/~1orders/get/responses Responses
+ #/paths/~1orders/get/responses/200 Response
+ #/paths/~1orders/post Operation
+ #/paths/~1orders/post/requestBody RequestBody
+ `);
+
+ expect(directionsOf(cafe.get('#/paths/~1orders/get/parameters/0')!)).toEqual(['request']);
+ expect(directionsOf(cafe.get('#/paths/~1orders/get/responses/200')!)).toEqual(['response']);
+ expect(directionsOf(cafe.get('#/paths/~1orders/post/requestBody')!)).toEqual(['request']);
+ expect(directionsOf(cafe.get('#/info')!)).toEqual([]);
+ });
+
+ it('should turn the direction round under a callback or a webhook, which the API sends', () => {
+ const cafe = diffTreeOf(`
+ #/ Root
+ #/paths Paths
+ #/paths/~1orders PathItem
+ #/paths/~1orders/post Operation
+ #/paths/~1orders/post/callbacks CallbacksMap
+ #/paths/~1orders/post/callbacks/orderReady Callback
+ #/paths/~1orders/post/callbacks/orderReady/url PathItem
+ #/paths/~1orders/post/callbacks/orderReady/url/post Operation
+ #/paths/~1orders/post/callbacks/orderReady/url/post/requestBody RequestBody
+ #/webhooks WebhooksMap
+ #/webhooks/menuChanged PathItem
+ #/webhooks/menuChanged/post Operation
+ #/webhooks/menuChanged/post/responses Responses
+ `);
+ const orderReadySent = cafe.get(
+ '#/paths/~1orders/post/callbacks/orderReady/url/post/requestBody'
+ )!;
+ const menuChangedAnswer = cafe.get('#/webhooks/menuChanged/post/responses')!;
+
+ expect(directionsOf(orderReadySent)).toEqual(['response']);
+ expect(directionsOf(menuChangedAnswer)).toEqual(['request']);
+ });
+
+ it('should give a component the directions of the places that reach it, through other components', () => {
+ const cafe = withReferences(
+ diffTreeOf(`
+ #/ Root
+ #/paths Paths
+ #/paths/~1orders PathItem
+ #/paths/~1orders/get Operation
+ #/paths/~1orders/get/responses Responses
+ #/paths/~1orders/get/responses/200 Response
+ #/paths/~1orders/post Operation
+ #/paths/~1orders/post/requestBody RequestBody
+ #/paths/~1menu PathItem
+ #/paths/~1menu/get Operation
+ #/paths/~1menu/get/responses Responses
+ #/paths/~1menu/get/responses/200 Response
+ #/components Components
+ #/components/schemas NamedSchemas
+ #/components/schemas/Order Schema
+ #/components/schemas/Order/items Schema
+ #/components/schemas/OrderItem Schema
+ #/components/schemas/OrderItem/order Schema
+ #/components/schemas/MenuItem Schema
+ #/components/schemas/Dessert Schema
+ `),
+ [
+ ['#/paths/~1orders/get/responses/200', '#/components/schemas/Order'],
+ ['#/paths/~1orders/post/requestBody', '#/components/schemas/Order'],
+ ['#/components/schemas/Order/items', '#/components/schemas/OrderItem'],
+ ['#/components/schemas/OrderItem/order', '#/components/schemas/Order'],
+ ['#/paths/~1menu/get/responses/200', '#/components/schemas/MenuItem'],
+ ]
+ );
+
+ expect(directionsOf(cafe.get('#/components/schemas/Order')!)).toEqual(['request', 'response']);
+ expect(directionsOf(cafe.get('#/components/schemas/OrderItem')!)).toEqual([
+ 'request',
+ 'response',
+ ]);
+ expect(directionsOf(cafe.get('#/components/schemas/MenuItem')!)).toEqual(['response']);
+ expect(directionsOf(cafe.get('#/components/schemas/Dessert')!)).toEqual([]);
+ });
+
+ it('should read the direction off the place of a $ref when the component it points at says nothing', () => {
+ const cafe = withReferences(
+ diffTreeOf(`
+ #/ Root
+ #/paths Paths
+ #/paths/~1orders PathItem
+ #/paths/~1orders/post Operation
+ #/paths/~1orders/post/requestBody RequestBody
+ #/components Components
+ #/components/requestBodies NamedRequestBodies
+ #/components/requestBodies/NewOrder RequestBody
+ `),
+ [['#/paths/~1orders/post/requestBody', '#/components/requestBodies/NewOrder']]
+ );
+
+ expect(directionsOf(cafe.get('#/components/requestBodies/NewOrder')!)).toEqual(['request']);
+ });
+
+ it('should take readOnly as response data and writeOnly as request data wherever the schema is', () => {
+ const cafe = diffTreeOf(`
+ #/ Root
+ #/paths Paths
+ #/paths/~1orders PathItem
+ #/paths/~1orders/post Operation
+ #/paths/~1orders/post/requestBody RequestBody
+ #/paths/~1orders/post/requestBody/schema Schema
+ #/paths/~1orders/post/requestBody/schema/properties SchemaProperties
+ #/paths/~1orders/post/requestBody/schema/properties/id Schema readOnly=true
+ #/paths/~1orders/get Operation
+ #/paths/~1orders/get/responses Responses
+ #/paths/~1orders/get/responses/200 Response
+ #/paths/~1orders/get/responses/200/schema Schema
+ #/paths/~1orders/get/responses/200/schema/properties SchemaProperties
+ #/paths/~1orders/get/responses/200/schema/properties/secret Schema writeOnly=true
+ `);
+
+ expect(directionsOf(cafe.get('#/paths/~1orders/post/requestBody/schema')!)).toEqual([
+ 'request',
+ ]);
+ expect(
+ directionsOf(cafe.get('#/paths/~1orders/post/requestBody/schema/properties/id')!)
+ ).toEqual(['response']);
+ expect(directionsOf(cafe.get('#/paths/~1orders/get/responses/200/schema')!)).toEqual([
+ 'response',
+ ]);
+ expect(
+ directionsOf(cafe.get('#/paths/~1orders/get/responses/200/schema/properties/secret')!)
+ ).toEqual(['request']);
+ });
+});
+
+describe('directionsOf for AsyncAPI 3', () => {
+ it('should judge what an operation receives as a request and what it sends as a response', () => {
+ const kitchen = withReferences(
+ diffTreeOf(`
+ #/ Root
+ #/channels NamedChannels
+ #/channels/orderPlaced Channel
+ #/channels/orderReady Channel
+ #/channels/menuChanged Channel
+ #/operations NamedOperations
+ #/operations/onOrderPlaced Operation action=receive
+ #/operations/onOrderPlaced/channel Channel
+ #/operations/sendOrderReady Operation action=send
+ #/operations/sendOrderReady/channel Channel
+ `),
+ [
+ ['#/operations/onOrderPlaced/channel', '#/channels/orderPlaced'],
+ ['#/operations/sendOrderReady/channel', '#/channels/orderReady'],
+ ]
+ );
+
+ expect(directionsOf(kitchen.get('#/channels/orderPlaced')!)).toEqual(['request']);
+ expect(directionsOf(kitchen.get('#/channels/orderReady')!)).toEqual(['response']);
+ expect(directionsOf(kitchen.get('#/channels/menuChanged')!)).toEqual([]);
+ });
+
+ it('should turn the direction round for the channel a reply travels on', () => {
+ const kitchen = withReferences(
+ diffTreeOf(`
+ #/ Root
+ #/channels NamedChannels
+ #/channels/orderConfirmed Channel
+ #/operations NamedOperations
+ #/operations/onOrderPlaced Operation action=receive
+ #/operations/onOrderPlaced/reply OperationReply
+ #/operations/onOrderPlaced/reply/channel Channel
+ `),
+ [['#/operations/onOrderPlaced/reply/channel', '#/channels/orderConfirmed']]
+ );
+
+ expect(directionsOf(kitchen.get('#/channels/orderConfirmed')!)).toEqual(['response']);
+ });
+
+ it("should judge what the revision has in the revision's directions, and what only the base has in the base's", () => {
+ const kitchen = withReferences(
+ diffTreeOf(`
+ #/ Root
+ #/channels NamedChannels
+ #/channels/orderReady Channel
+ #/channels/orderReady/messages NamedMessages
+ #/channels/orderReady/messages/orderReady Message
+ #/operations NamedOperations
+ #/operations/sendOrderReady Operation action=send
+ #/operations/sendOrderReady/channel Channel
+ `),
+ [['#/operations/sendOrderReady/channel', '#/channels/orderReady']]
+ );
+ const sendOrderReady = kitchen.get('#/operations/sendOrderReady')!;
+ sendOrderReady.revision = { ...sendOrderReady.revision!, value: { action: 'receive' } };
+ const orderReady = kitchen.get('#/channels/orderReady/messages/orderReady')!;
+
+ expect(directionsOf({ ...orderReady, revision: undefined })).toEqual(['response']);
+ expect(directionsOf(orderReady)).toEqual(['request']);
+ });
+
+ it('should give a message the directions of both its channel and itself', () => {
+ const kitchen = withReferences(
+ diffTreeOf(`
+ #/ Root
+ #/channels NamedChannels
+ #/channels/orders Channel
+ #/channels/orders/messages NamedMessages
+ #/channels/orders/messages/orderPlaced Message
+ #/operations NamedOperations
+ #/operations/onOrder Operation action=receive
+ #/operations/onOrder/channel Channel
+ #/operations/sendOrderPlaced Operation action=send
+ #/operations/sendOrderPlaced/messages MessageList
+ #/operations/sendOrderPlaced/messages/0 Message
+ `),
+ [
+ ['#/operations/onOrder/channel', '#/channels/orders'],
+ ['#/operations/sendOrderPlaced/messages/0', '#/channels/orders/messages/orderPlaced'],
+ ]
+ );
+
+ expect(directionsOf(kitchen.get('#/channels/orders/messages/orderPlaced')!)).toEqual([
+ 'request',
+ 'response',
+ ]);
+ });
+});
diff --git a/packages/core/src/diff/__tests__/impact.test.ts b/packages/core/src/diff/__tests__/impact.test.ts
new file mode 100644
index 0000000000..83b6866568
--- /dev/null
+++ b/packages/core/src/diff/__tests__/impact.test.ts
@@ -0,0 +1,68 @@
+import { pointerOf } from '../diff-node.js';
+import { defaultImpact, highestImpact, impactRank } from '../impact.js';
+import type { Change, DiffNode } from '../types.js';
+import { diffTreeOf } from './utils.js';
+
+const cafe = diffTreeOf(`
+ #/ Root
+ #/paths Paths
+ #/components Components
+ #/components/schemas NamedSchemas
+ #/components/schemas/MenuItem Schema
+ #/components/schemas/MenuItem/properties SchemaProperties
+`);
+
+function addition(node: DiffNode): Change {
+ return {
+ key: pointerOf(node),
+ kind: 'added',
+ node,
+ revision: { location: node.revision!.location, value: {} },
+ };
+}
+
+describe('impact', () => {
+ it('should rank patch below minor below major', () => {
+ expect(impactRank('patch')).toBeLessThan(impactRank('minor'));
+ expect(impactRank('minor')).toBeLessThan(impactRank('major'));
+ });
+
+ it('should pick the highest impact, and none from no impacts', () => {
+ expect(highestImpact(['patch', 'major', 'minor'])).toBe('major');
+ expect(highestImpact([])).toBeUndefined();
+ });
+
+ it('should take an unjudged addition for a minor and anything else for a patch', () => {
+ const paths = cafe.get('#/paths')!;
+ const location = paths.base!.location;
+
+ expect(defaultImpact(addition(paths))).toBe('minor');
+ expect(
+ defaultImpact({
+ key: pointerOf(paths),
+ kind: 'removed',
+ node: paths,
+ base: { location, value: {} },
+ })
+ ).toBe('patch');
+ expect(
+ defaultImpact({
+ key: pointerOf(paths),
+ kind: 'modified',
+ property: 'x-internal',
+ node: paths,
+ base: { location, value: false },
+ revision: { location, value: true },
+ })
+ ).toBe('patch');
+ });
+
+ it('should take a new component, or a new map of them, for a patch until something uses it', () => {
+ expect(defaultImpact(addition(cafe.get('#/components')!))).toBe('patch');
+ expect(defaultImpact(addition(cafe.get('#/components/schemas')!))).toBe('patch');
+ expect(defaultImpact(addition(cafe.get('#/components/schemas/MenuItem')!))).toBe('patch');
+ expect(defaultImpact(addition(cafe.get('#/components/schemas/MenuItem/properties')!))).toBe(
+ 'minor'
+ );
+ });
+});
diff --git a/packages/core/src/diff/__tests__/index.test.ts b/packages/core/src/diff/__tests__/index.test.ts
new file mode 100644
index 0000000000..08b61b27eb
--- /dev/null
+++ b/packages/core/src/diff/__tests__/index.test.ts
@@ -0,0 +1,750 @@
+import { outdent } from 'outdent';
+
+import {
+ replaceSourceWithRef,
+ replaceSourceWithRefInChanges,
+ resolverWithFiles,
+} from '../../../__tests__/utils.js';
+import { createConfig, type Config } from '../../config/index.js';
+import { makeDocumentFromString, type Document } from '../../resolve.js';
+import { HandledError } from '../../utils/error.js';
+import type { Oas3Preprocessor } from '../../visitors.js';
+import { diffDocuments } from '../index.js';
+
+const orderStatus = (paths: string, statuses: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths: ${paths}
+ components:
+ schemas:
+ OrderStatus: { type: string, enum: ${statuses} }
+`;
+
+const menu = (parameters: string, description = 'OK') => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu:
+ get:
+ parameters: ${parameters}
+ responses:
+ '200': { description: ${description} }
+`;
+
+const menuItem = (template: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu/{${template}}:
+ get:
+ parameters:
+ - { name: ${template}, in: path, required: true, schema: { type: string } }
+ responses:
+ '200': { description: OK }
+`;
+
+describe('diffDocuments', () => {
+ it('should not judge a component by a place that only the revision has', async () => {
+ const placeOrder = outdent`
+ { /orders: { post: {
+ requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/OrderStatus' } } } },
+ responses: { 201: { description: Created } } } } }
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(orderStatus('{}', '[placed, preparing, ready]'), 'base.yaml'),
+ revision: makeDocumentFromString(orderStatus(placeOrder, '[placed, ready]'), 'revision.yaml'),
+ config: await createConfig({ extends: ['diff-recommended'] }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "impact": "minor",
+ "key": "#/paths/~1orders",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders",
+ "value": {
+ "post": {
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OrderStatus",
+ },
+ },
+ },
+ },
+ "responses": {
+ "201": {
+ "description": "Created",
+ },
+ },
+ },
+ },
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/components/schemas/OrderStatus/enum",
+ "value": [
+ "placed",
+ "preparing",
+ "ready",
+ ],
+ },
+ "impact": "patch",
+ "key": "#/components/schemas/OrderStatus",
+ "kind": "modified",
+ "property": "enum",
+ "revision": {
+ "location": "revision.yaml#/components/schemas/OrderStatus/enum",
+ "value": [
+ "placed",
+ "ready",
+ ],
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should judge a part only the base has by the base, not by what the revision marks above it', async () => {
+ const orders = (meta: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ get:
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { type: object, properties: { meta: ${meta} } }
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ orders('{ type: object, properties: { a: { type: string }, b: { type: string } } }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ orders('{ type: object, writeOnly: true, properties: { a: { type: string } } }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ extends: ['diff-recommended'] }),
+ });
+
+ expect(result.changes.find((change) => change.kind === 'removed')).toMatchObject({
+ key: '#/paths/~1orders/get/responses/200/content/application~1json/schema/properties/meta/properties/b',
+ impact: 'major',
+ verdicts: [{ ruleId: 'property-removed' }],
+ });
+ });
+
+ it('should judge a component under a $ref with keys next to it in the direction of its place', async () => {
+ const placeOrder = outdent`
+ { /orders: { post: {
+ requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/OrderStatus', description: The first status } } } },
+ responses: { 201: { description: Created } } } } }
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ orderStatus(placeOrder, '[placed, preparing, ready]'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(orderStatus(placeOrder, '[placed, ready]'), 'revision.yaml'),
+ config: await createConfig({ extends: ['diff-recommended'] }),
+ });
+
+ expect(result.changes).toMatchObject([
+ {
+ kind: 'modified',
+ property: 'enum',
+ impact: 'major',
+ verdicts: [{ ruleId: 'enum-values-removed' }],
+ },
+ ]);
+ });
+
+ it('should pair reordered parameters by their content and judge only what changed', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ menu(
+ '[{ name: limit, in: query, schema: { type: integer } }, { name: search, in: query }]'
+ ),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ menu(
+ '[{ name: search, in: query }, { name: limit, in: query, required: true, schema: { type: number } }]',
+ 'Menu items'
+ ),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ extends: ['diff-recommended'] }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/parameters/0",
+ "value": undefined,
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get/parameters/1",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/parameters/1/required",
+ "value": true,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1menu/get/parameters/1/required",
+ "message": "\`limit\` query parameter became required.",
+ "ruleId": "parameter-became-required",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/parameters/0/schema/type",
+ "value": "integer",
+ },
+ "impact": "patch",
+ "key": "#/paths/~1menu/get/parameters/1/schema",
+ "kind": "modified",
+ "property": "type",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/parameters/1/schema/type",
+ "value": "number",
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/responses/200/description",
+ "value": "OK",
+ },
+ "impact": "patch",
+ "key": "#/paths/~1menu/get/responses/200",
+ "kind": "modified",
+ "property": "description",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/responses/200/description",
+ "value": "Menu items",
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ expect(result.summary).toEqual({ major: 1, minor: 0, patch: 2 });
+ expect(result.bump).toBe('major');
+ });
+
+ it('should report a renamed path parameter as a change of key and name, not a removal', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(menuItem('id'), 'base.yaml'),
+ revision: makeDocumentFromString(menuItem('menuItemId'), 'revision.yaml'),
+ config: await createConfig({ extends: ['diff-recommended'] }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu~1{id}",
+ "value": "/menu/{id}",
+ },
+ "impact": "patch",
+ "key": "#/paths/~1menu~1{menuItemId}",
+ "kind": "modified",
+ "property": "key",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu~1{menuItemId}",
+ "value": "/menu/{menuItemId}",
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu~1{id}/get/parameters/0/name",
+ "value": "id",
+ },
+ "impact": "patch",
+ "key": "#/paths/~1menu~1{menuItemId}/get/parameters/0",
+ "kind": "modified",
+ "property": "name",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu~1{menuItemId}/get/parameters/0/name",
+ "value": "menuItemId",
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ expect(result.bump).toBe('patch');
+ });
+
+ it('should find no change where an inline schema moved into a component that a $ref with a description points to', async () => {
+ const cafe = (schema: string, schemas: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu:
+ get:
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: ${schema}
+ components:
+ schemas: ${schemas}
+ `;
+ const item = '{ type: object, properties: { name: { type: string } } }';
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(item, '{}'), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe("{ $ref: '#/components/schemas/Item', description: A dish }", `{ Item: ${item} }`),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ extends: ['diff-recommended'] }),
+ });
+
+ expect(result.changes.map(({ key, kind, impact }) => ({ key, kind, impact }))).toEqual([
+ { key: '#/components/schemas/Item', kind: 'added', impact: 'patch' },
+ ]);
+ });
+
+ it('should leave the change of info.version out of the required bump', async () => {
+ const cafe = (version: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: ${version} }
+ paths: {}
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('1.0.0'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('2.0.0'), 'revision.yaml'),
+ config: await createConfig({ extends: ['diff-recommended'] }),
+ });
+
+ expect(result.changes).toHaveLength(1);
+ expect(result.bump).toBeUndefined();
+ });
+
+ it('should compare a specification without diff rules by its structure alone', async () => {
+ const swagger = (paths: string) => outdent`
+ swagger: '2.0'
+ info: { title: Cafe, version: 1.0.0 }
+ paths: ${paths}
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ swagger('{ /menu: { get: { responses: { 200: { description: OK } } } } }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(swagger('{}'), 'revision.yaml'),
+ config: await createConfig({ extends: ['diff-recommended'] }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu",
+ "value": {
+ "get": {
+ "responses": {
+ "200": {
+ "description": "OK",
+ },
+ },
+ },
+ },
+ },
+ "impact": "patch",
+ "key": "#/paths/~1menu",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should name a header added to a Swagger 2 response by its key in the headers map', async () => {
+ const swagger = (headers: string) => outdent`
+ swagger: '2.0'
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu:
+ get:
+ responses:
+ '200': { description: OK, headers: ${headers} }
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(swagger('{ X-Request-Id: { type: string } }'), 'base.yaml'),
+ revision: makeDocumentFromString(
+ swagger('{ X-Request-Id: { type: string }, X-Rate-Limit: { type: integer } }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ extends: ['diff-recommended'] }),
+ });
+
+ expect(result.changes).toMatchObject([
+ {
+ key: '#/paths/~1menu/get/responses/200/headers/X-Rate-Limit',
+ kind: 'added',
+ node: { revision: { key: 'X-Rate-Limit' } },
+ },
+ ]);
+ });
+
+ it('should judge the request body of a webhook in a file of its own as data the API sends', async () => {
+ const orderReady = (properties: string) => outdent`
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { type: object, properties: ${properties} }
+ responses:
+ '200': { description: OK }
+ `;
+ const root = outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ webhooks:
+ orderReady: { $ref: webhooks/order-ready.yaml }
+ `;
+ const resolver = resolverWithFiles({
+ 'base/openapi.yaml': root,
+ 'base/webhooks/order-ready.yaml': orderReady(
+ '{ id: { type: string }, note: { type: string } }'
+ ),
+ 'revision/openapi.yaml': root,
+ 'revision/webhooks/order-ready.yaml': orderReady('{ id: { type: string } }'),
+ });
+ const load = async (side: 'base' | 'revision') =>
+ (await resolver.resolveDocument(null, `${side}/openapi.yaml`, true)) as Document;
+
+ const result = await diffDocuments({
+ base: await load('base'),
+ revision: await load('revision'),
+ config: await createConfig({ extends: ['diff-recommended'] }),
+ externalRefResolver: resolver,
+ });
+
+ expect(result.changes).toMatchObject([
+ {
+ key: '#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/note',
+ kind: 'removed',
+ impact: 'major',
+ verdicts: [{ ruleId: 'property-removed' }],
+ },
+ ]);
+ });
+
+ it('should judge the target of a chain of $refs with keys next to them in the direction of the path that uses it', async () => {
+ const item = (type: string) => outdent`
+ get:
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { type: ${type} }
+ callbacks:
+ onEvent:
+ '{$request.body#/url}': { $ref: '#' }
+ `;
+ const root = outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu: { $ref: ./menu.yaml }
+ `;
+ const menu = '{ $ref: ./item.yaml, summary: Menu }';
+ const resolver = resolverWithFiles({
+ 'base/openapi.yaml': root,
+ 'base/menu.yaml': menu,
+ 'base/item.yaml': item('string'),
+ 'revision/openapi.yaml': root,
+ 'revision/menu.yaml': menu,
+ 'revision/item.yaml': item('integer'),
+ });
+ const load = async (side: 'base' | 'revision') =>
+ (await resolver.resolveDocument(null, `${side}/openapi.yaml`, true)) as Document;
+
+ const result = await diffDocuments({
+ base: await load('base'),
+ revision: await load('revision'),
+ config: await createConfig({ extends: ['diff-recommended'] }),
+ externalRefResolver: resolver,
+ });
+
+ expect(result.changes).toMatchObject([
+ {
+ key: '#/paths/~1menu/$ref/get/responses/200/content/application~1json/schema',
+ kind: 'modified',
+ property: 'type',
+ impact: 'major',
+ verdicts: [
+ { ruleId: 'schema-type-changed', message: "Type widened from 'string' to 'integer'." },
+ ],
+ },
+ ]);
+ });
+
+ it("should judge a callback's $ref to a path's request body as a request", async () => {
+ const cafe = (required: string) => outdent`
+ openapi: 3.0.3
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ responses: { '200': { description: OK } }
+ callbacks:
+ done:
+ '{$request.body#/url}':
+ post:
+ requestBody: { $ref: '#/paths/~1receipts/post/requestBody' }
+ responses: { '200': { description: OK } }
+ /receipts:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ required: ${required}
+ properties: { id: { type: string }, total: { type: number } }
+ responses: { '200': { description: OK } }
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('[id]'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('[id, total]'), 'revision.yaml'),
+ config: await createConfig({ extends: ['diff-recommended'] }),
+ });
+
+ expect(result.changes).toMatchObject([
+ {
+ key: '#/paths/~1orders/post/callbacks/done/{$request.body#~1url}/post/requestBody/content/application~1json/schema',
+ kind: 'modified',
+ property: 'required',
+ impact: 'major',
+ verdicts: [{ ruleId: 'required-properties-added' }],
+ },
+ ]);
+ });
+
+ it('should refuse to compare documents of different specifications', async () => {
+ const swagger = outdent`
+ swagger: '2.0'
+ info: { title: Cafe, version: 1.0.0 }
+ paths: {}
+ `;
+ const config = await createConfig({ extends: ['diff-recommended'] });
+
+ await expect(
+ diffDocuments({
+ base: makeDocumentFromString(swagger, 'base.yaml'),
+ revision: makeDocumentFromString(menu('[]'), 'revision.yaml'),
+ config,
+ })
+ ).rejects.toThrow(HandledError);
+ });
+
+ it('should report a $ref that does not resolve as an error', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(menu('[]'), 'base.yaml'),
+ revision: makeDocumentFromString(
+ menu("[{ $ref: '#/components/parameters/Limit' }]"),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ extends: ['diff-recommended'] }),
+ });
+
+ expect(replaceSourceWithRef(result.problems)).toMatchInlineSnapshot(`
+ [
+ {
+ "location": [
+ {
+ "pointer": "#/paths/~1menu/get/parameters/0",
+ "reportOnKey": false,
+ "source": "revision.yaml",
+ },
+ ],
+ "message": "Can't resolve $ref",
+ "reference": "https://redocly.com/docs/cli/rules/oas/no-unresolved-refs",
+ "ruleId": "no-unresolved-refs",
+ "severity": "error",
+ "suggest": [],
+ },
+ ]
+ `);
+ });
+});
+
+describe('diffDocuments configuration', () => {
+ const orders = (operations: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders/{orderId}: ${operations}
+ `;
+ const get = 'get: { responses: { 200: { description: OK } } }';
+ const cancel = 'delete: { responses: { 204: { description: Cancelled } } }';
+ const base = makeDocumentFromString(orders(`{ ${get}, ${cancel} }`), 'base.yaml');
+ const revision = makeDocumentFromString(orders(`{ ${get} }`), 'revision.yaml');
+
+ it('should take the impacts of diff-recommended', async () => {
+ const config = await createConfig({ extends: ['diff-recommended'] });
+
+ const [cancelRemoved] = (await diffDocuments({ base, revision, config })).changes;
+
+ expect(cancelRemoved.impact).toBe('major');
+ });
+
+ it('should let the diff map override a preset', async () => {
+ const config = await createConfig({
+ extends: ['diff-recommended'],
+ diff: { 'operation-removed': 'minor' },
+ });
+
+ const [cancelRemoved] = (await diffDocuments({ base, revision, config })).changes;
+
+ expect(cancelRemoved.impact).toBe('minor');
+ });
+
+ it('should let a specification block override a preset', async () => {
+ const config = await createConfig({
+ extends: ['diff-recommended'],
+ oas3_1Diff: { 'operation-removed': 'minor' },
+ });
+
+ const [cancelRemoved] = (await diffDocuments({ base, revision, config })).changes;
+
+ expect(cancelRemoved.impact).toBe('minor');
+ });
+
+ it('should leave a change unjudged when its rule is off', async () => {
+ const config = await createConfig({
+ extends: ['diff-recommended'],
+ diff: { 'operation-removed': 'off' },
+ });
+
+ const [cancelRemoved] = (await diffDocuments({ base, revision, config })).changes;
+
+ expect(cancelRemoved.impact).toBe('patch');
+ });
+
+ it('should run only the listed rules when no preset is extended', async () => {
+ const config = await createConfig({ diff: { 'path-removed': 'major' } });
+
+ const [cancelRemoved] = (await diffDocuments({ base, revision, config })).changes;
+
+ expect(cancelRemoved.impact).toBe('patch');
+ });
+
+ it('should compare the descriptions as written, without the configured decorators', async () => {
+ const config = await createConfig({
+ extends: ['diff-recommended'],
+ decorators: { 'remove-x-internal': 'on' },
+ });
+ const withKitchen = makeDocumentFromString(
+ outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders/{orderId}: { ${get}, ${cancel} }
+ /kitchen:
+ x-internal: true
+ get: { responses: { 200: { description: OK } } }
+ `,
+ 'revision.yaml'
+ );
+
+ const result = await diffDocuments({ base, revision: withKitchen, config });
+
+ expect(result.changes.map(({ key, kind }) => ({ key, kind }))).toEqual([
+ { key: '#/paths/~1kitchen', kind: 'added' },
+ ]);
+ });
+});
+
+describe('diffDocuments of a document that another file refers back into', () => {
+ const cafe = (costType: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu:
+ get:
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { $ref: schemas/item.yaml }
+ components:
+ schemas:
+ Price:
+ type: object
+ properties:
+ amount: { type: number }
+ cost: { type: ${costType} }
+ `;
+
+ // `schemas/item.yaml` uses `Price` from the root; the revision changes `Price.cost`.
+ async function diffCafes(config: Config) {
+ const item = "properties: { price: { $ref: '../openapi.yaml#/components/schemas/Price' } }";
+ const resolver = resolverWithFiles({
+ 'base/openapi.yaml': cafe('number'),
+ 'base/schemas/item.yaml': item,
+ 'revision/openapi.yaml': cafe('string'),
+ 'revision/schemas/item.yaml': item,
+ });
+ const load = async (side: 'base' | 'revision') =>
+ (await resolver.resolveDocument(null, `${side}/openapi.yaml`, true)) as Document;
+
+ const result = await diffDocuments({
+ base: await load('base'),
+ revision: await load('revision'),
+ config,
+ externalRefResolver: resolver,
+ });
+ return result;
+ }
+
+ it('should compare what the preprocessors left of it', async () => {
+ // It changes `Price` from the components map, which the `$ref` in `item.yaml` does not pass through.
+ const removeCost: Oas3Preprocessor = () => ({
+ NamedSchemas: {
+ leave(schemas) {
+ delete schemas.Price?.properties?.cost;
+ },
+ },
+ });
+
+ const result = await diffCafes(
+ await createConfig({
+ plugins: [{ id: 'cafe', preprocessors: { oas3: { 'remove-cost': removeCost } } }],
+ preprocessors: { 'cafe/remove-cost': 'on' },
+ })
+ );
+
+ expect(result.changes).toEqual([]);
+ });
+});
diff --git a/packages/core/src/diff/__tests__/judge.test.ts b/packages/core/src/diff/__tests__/judge.test.ts
new file mode 100644
index 0000000000..c7d976bd05
--- /dev/null
+++ b/packages/core/src/diff/__tests__/judge.test.ts
@@ -0,0 +1,212 @@
+import type { InitializedDiffRule } from '../../config/rules.js';
+import type { Location } from '../../ref-utils.js';
+import { appliesTo, judgeChanges } from '../judge.js';
+import type { Change, DiffRule } from '../types.js';
+import { diffTreeOf } from './utils.js';
+
+const rulesOf = (rule: DiffRule): InitializedDiffRule[] => [
+ { ruleId: 'my-rule', impact: 'major', visitor: rule() },
+];
+const judgeWith = (rule: DiffRule, change: Change) =>
+ judgeChanges([change], rulesOf(rule), 'oas3_1')[0];
+
+describe('judgeChanges', () => {
+ it('should run a nested handler for a member only and an any handler for every change', () => {
+ const cafe = diffTreeOf(`
+ #/Order Schema
+ #/Order/properties SchemaProperties
+ #/Order/properties/items Schema
+ `);
+ const order = cafe.get('#/Order')!;
+ const items = cafe.get('#/Order/properties/items')!;
+ const orderRemoved: Change = {
+ key: '#/Order',
+ kind: 'removed',
+ node: order,
+ base: { location: order.base!.location, value: {} },
+ };
+ const itemsRemoved: Change = {
+ key: '#/Order/properties/items',
+ kind: 'removed',
+ node: items,
+ base: { location: items.base!.location, value: {} },
+ };
+ const rule: DiffRule = () => ({
+ any(_change, { report }) {
+ report({ message: 'Something changed.' });
+ },
+ SchemaProperties: {
+ Schema(_change, { report }) {
+ report({ message: 'A property changed.' });
+ },
+ },
+ });
+
+ expect(judgeWith(rule, orderRemoved).verdicts).toMatchObject([
+ { message: 'Something changed.' },
+ ]);
+ expect(judgeWith(rule, itemsRemoved).verdicts).toMatchObject([
+ { message: 'Something changed.' },
+ { message: 'A property changed.' },
+ ]);
+ });
+
+ it('should hand the rule the location of the changed side and report there unless told otherwise', () => {
+ const cafe = diffTreeOf(`
+ #/Order Schema
+ `);
+ const order = cafe.get('#/Order')!;
+ const orderRemoved: Change = {
+ key: '#/Order',
+ kind: 'removed',
+ node: order,
+ base: { location: order.base!.location, value: {} },
+ };
+ const seen: Location[] = [];
+ const elsewhere = order.base!.location.child(['type']);
+ const rule: DiffRule = () => ({
+ Schema(_change, { report, location }) {
+ seen.push(location);
+ report({ message: 'Removed.' });
+ report({ message: 'Elsewhere.', location: elsewhere });
+ },
+ });
+
+ const { impact, verdicts } = judgeWith(rule, orderRemoved);
+
+ expect(seen).toEqual([order.base!.location]);
+ expect(verdicts).toEqual([
+ { ruleId: 'my-rule', impact: 'major', message: 'Removed.', location: order.base!.location },
+ { ruleId: 'my-rule', impact: 'major', message: 'Elsewhere.', location: elsewhere },
+ ]);
+ expect(impact).toBe('major');
+ });
+
+ it('should give a change no rule speaks about the default impact', () => {
+ const cafe = diffTreeOf(`
+ #/Order Schema
+ `);
+ const order = cafe.get('#/Order')!;
+ const orderAdded: Change = {
+ key: '#/Order',
+ kind: 'added',
+ node: order,
+ revision: { location: order.revision!.location, value: {} },
+ };
+
+ expect(judgeWith(() => ({}), orderAdded)).toMatchObject({ impact: 'minor', verdicts: [] });
+ });
+
+ it('should give a rule the directions of the change when it asks', () => {
+ const cafe = diffTreeOf(`
+ #/ Root
+ #/paths Paths
+ #/paths/~1orders PathItem
+ #/paths/~1orders/get Operation
+ #/paths/~1orders/get/parameters ParameterList
+ #/paths/~1orders/get/parameters/0 Parameter
+ `);
+ const limit = cafe.get('#/paths/~1orders/get/parameters/0')!;
+ const limitRemoved: Change = {
+ key: '#/paths/~1orders/get/parameters/0',
+ kind: 'removed',
+ node: limit,
+ base: { location: limit.base!.location, value: {} },
+ };
+ const rule: DiffRule = () => ({
+ Parameter(_change, { report, getDirections }) {
+ report({ message: `Travels as ${getDirections().join(' and ')}.` });
+ },
+ });
+
+ expect(judgeWith(rule, limitRemoved).verdicts).toMatchObject([
+ { message: 'Travels as request.' },
+ ]);
+ });
+
+ it('should reject a lint hook, which a diff rule has no use for', () => {
+ const withEnter: DiffRule = () => ({ Schema: { enter() {} } });
+
+ expect(() => judgeChanges([], rulesOf(withEnter), 'oas3_1')).toThrow(
+ "Diff rule 'my-rule' uses 'enter' where diff visitors do not have it."
+ );
+ });
+
+ it('should reject an any handler nested under a type', () => {
+ const withNestedAny: DiffRule = () => ({ Schema: { any() {} } });
+
+ expect(() => judgeChanges([], rulesOf(withNestedAny), 'oas3_1')).toThrow(
+ "Diff rule 'my-rule' uses 'any' where diff visitors do not have it."
+ );
+ });
+});
+
+describe('appliesTo', () => {
+ it('should apply a nested path to a member of the map, not to the schema that owns it', () => {
+ const cafe = diffTreeOf(`
+ #/Order Schema
+ #/Order/properties SchemaProperties
+ #/Order/properties/items Schema
+ `);
+ const order = cafe.get('#/Order')!;
+ const items = cafe.get('#/Order/properties/items')!;
+
+ expect(appliesTo(['SchemaProperties', 'Schema'], items)).toBe(true);
+ expect(appliesTo(['SchemaProperties', 'Schema'], order)).toBe(false);
+ });
+
+ it('should not apply a nested path to a schema further down inside a member', () => {
+ const cafe = diffTreeOf(`
+ #/Order Schema
+ #/Order/properties SchemaProperties
+ #/Order/properties/items Schema
+ #/Order/properties/items/items Schema
+ `);
+ const orderLine = cafe.get('#/Order/properties/items/items')!;
+
+ expect(appliesTo(['SchemaProperties', 'Schema'], orderLine)).toBe(false);
+ });
+
+ it('should apply a nested path to a member of a map at any depth', () => {
+ const cafe = diffTreeOf(`
+ #/Order Schema
+ #/Order/properties SchemaProperties
+ #/Order/properties/payment Schema
+ #/Order/properties/payment/oneOf OneOf
+ #/Order/properties/payment/oneOf/0 Schema
+ #/Order/properties/payment/oneOf/0/properties SchemaProperties
+ #/Order/properties/payment/oneOf/0/properties/cardNumber Schema
+ `);
+ const cardNumber = cafe.get('#/Order/properties/payment/oneOf/0/properties/cardNumber')!;
+
+ expect(appliesTo(['SchemaProperties', 'Schema'], cardNumber)).toBe(true);
+ });
+
+ it('should apply OneOf › Schema to an alternative, not to a property of it', () => {
+ const cafe = diffTreeOf(`
+ #/Payment Schema
+ #/Payment/oneOf OneOf
+ #/Payment/oneOf/0 Schema
+ #/Payment/oneOf/0/properties SchemaProperties
+ #/Payment/oneOf/0/properties/cardNumber Schema
+ `);
+ const card = cafe.get('#/Payment/oneOf/0')!;
+ const cardNumber = cafe.get('#/Payment/oneOf/0/properties/cardNumber')!;
+
+ expect(appliesTo(['OneOf', 'Schema'], card)).toBe(true);
+ expect(appliesTo(['OneOf', 'Schema'], cardNumber)).toBe(false);
+ });
+
+ it('should apply a flat path to every node of its type and the empty path to any node', () => {
+ const cafe = diffTreeOf(`
+ #/ Root
+ #/Order Schema
+ #/Order/items Schema
+ `);
+ const root = cafe.get('#/')!;
+ const orderLine = cafe.get('#/Order/items')!;
+
+ expect(appliesTo(['Schema'], orderLine)).toBe(true);
+ expect(appliesTo([], root)).toBe(true);
+ });
+});
diff --git a/packages/core/src/diff/__tests__/pair-children.test.ts b/packages/core/src/diff/__tests__/pair-children.test.ts
new file mode 100644
index 0000000000..a1c0cdbb8f
--- /dev/null
+++ b/packages/core/src/diff/__tests__/pair-children.test.ts
@@ -0,0 +1,120 @@
+import type { NodeEntry, NodeValue } from '../../node-tree/types.js';
+import { Location } from '../../ref-utils.js';
+import { Source } from '../../resolve.js';
+import { pairChildren } from '../pair-children.js';
+import type { Pair } from '../types.js';
+
+const source = new Source('cafe.yaml', '');
+
+const child = (key: string | number, type: string, value: object = {}): NodeEntry => ({
+ type: { name: type, properties: {} },
+ key,
+ // The pairing reads the key, the type name and the value of a child, and the type of its parent
+ // to tell a field from a map entry.
+ value: value as NodeValue,
+ location: new Location(source, `#/${key}`),
+ parent: null,
+ children: [],
+});
+const parameter = (index: number, value: object) => child(index, 'Parameter', value);
+const property = (name: string) => child(name, 'Schema', { type: 'string' });
+const field = (name: string) => ({
+ ...property(name),
+ parent: {
+ ...child('owner', 'Schema'),
+ type: { name: 'Schema', properties: { [name]: { name: 'Schema', properties: {} } } },
+ },
+});
+
+const keysOf = (pairs: Pair[]) => pairs.map((pair) => [pair.base?.key, pair.revision?.key]);
+
+describe('pairChildren', () => {
+ it('should pair children that are the same, wherever they are', () => {
+ const pairs = pairChildren(
+ [parameter(0, { name: 'limit', in: 'query' }), parameter(1, { name: 'offset', in: 'query' })],
+ [parameter(0, { name: 'offset', in: 'query' }), parameter(1, { name: 'limit', in: 'query' })]
+ );
+
+ expect(keysOf(pairs)).toEqual([
+ [0, 1],
+ [1, 0],
+ ]);
+ });
+
+ it('should pair the most alike of the rest and leave the others alone', () => {
+ const pairs = pairChildren(
+ [property('price'), property('name')],
+ [property('prices'), property('color')]
+ );
+
+ expect(keysOf(pairs)).toEqual([
+ ['price', 'prices'],
+ ['name', undefined],
+ [undefined, 'color'],
+ ]);
+ });
+
+ it('should pair the same before the alike, so a renamed child never takes the place of an unchanged one', () => {
+ const pairs = pairChildren([property('limit'), property('limits')], [property('limits')]);
+
+ expect(keysOf(pairs)).toEqual([
+ ['limits', 'limits'],
+ ['limit', undefined],
+ ]);
+ });
+
+ it('should pair duplicates in turn and leave the extra one alone', () => {
+ const search = { name: 'search', in: 'query' };
+
+ const pairs = pairChildren(
+ [parameter(0, search), parameter(1, search)],
+ [parameter(0, search)]
+ );
+
+ expect(keysOf(pairs)).toEqual([
+ [0, 0],
+ [1, undefined],
+ ]);
+ });
+
+ it('should only pair children of one type', () => {
+ const pairs = pairChildren([child('/menu', 'PathItem')], [child('x-menu', 'SpecExtension')]);
+
+ expect(keysOf(pairs)).toEqual([
+ ['/menu', undefined],
+ [undefined, 'x-menu'],
+ ]);
+ });
+
+ it('should pair a field only with the same field', () => {
+ const pairs = pairChildren(
+ [field('items'), field('unevaluatedProperties')],
+ [field('unevaluatedItems'), field('items')]
+ );
+
+ expect(keysOf(pairs)).toEqual([
+ ['items', 'items'],
+ ['unevaluatedProperties', undefined],
+ [undefined, 'unevaluatedItems'],
+ ]);
+ });
+
+ it('should pair an inline item with a $ref that stands for the same content', () => {
+ const limit = { name: 'limit', in: 'query' };
+ const limitRef = parameter(0, { $ref: '#/components/parameters/Limit' });
+ limitRef.resolved = child('Limit', 'Parameter', limit);
+
+ const pairs = pairChildren([parameter(0, limit)], [limitRef]);
+
+ expect(keysOf(pairs)).toEqual([[0, 0]]);
+ });
+
+ it('should pair items whose keys are written in another order', () => {
+ const pairs = pairChildren(
+ [parameter(0, { name: 'limit', in: 'query', required: true, style: 'form', example: 20 })],
+ [parameter(0, { example: 20, style: 'form', required: true, in: 'query', name: 'limit' })]
+ );
+
+ expect(keysOf(pairs)).toEqual([[0, 0]]);
+ });
+});
diff --git a/packages/core/src/diff/__tests__/utils.ts b/packages/core/src/diff/__tests__/utils.ts
new file mode 100644
index 0000000000..1d52abdcb3
--- /dev/null
+++ b/packages/core/src/diff/__tests__/utils.ts
@@ -0,0 +1,88 @@
+import { createConfig } from '../../config/index.js';
+import { detectSpec } from '../../detect-spec.js';
+import { buildNodeTree } from '../../node-tree/index.js';
+import type { NodeEntry } from '../../node-tree/types.js';
+import { getTypes } from '../../oas-types.js';
+import { Location } from '../../ref-utils.js';
+import { BaseResolver, makeDocumentFromString, resolveDocument, Source } from '../../resolve.js';
+import { normalizeTypes } from '../../types/index.js';
+import type { DiffNode } from '../types.js';
+
+const source = new Source('tree.yaml', '');
+
+/** The node tree of a YAML document, typed the way the diff types it. */
+export async function nodeTreeOf(
+ yaml: string,
+ absoluteRef = 'api.yaml',
+ externalRefResolver = new BaseResolver()
+): Promise {
+ const document = makeDocumentFromString(yaml, absoluteRef);
+ const config = await createConfig({});
+ const specVersion = detectSpec(document.parsed);
+ const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config);
+ const resolvedRefMap = await resolveDocument({
+ rootDocument: document,
+ rootType: types.Root,
+ externalRefResolver,
+ });
+ const ctx = { problems: [], specVersion, visitorsData: {} };
+ return buildNodeTree({ document, types, resolvedRefMap, ctx });
+}
+
+/**
+ * A node tree spelled out line by line as `pointer Type`, optionally followed by `name=value`
+ * fields, for tests that need node types rather than a document. The first line is the root;
+ * each other line is a child of the line whose pointer it extends by one segment.
+ */
+export function treeOf(lines: string): Map {
+ const entries = new Map();
+
+ for (const line of lines.trim().split('\n')) {
+ const [pointer, type, ...fields] = line.trim().split(/\s+/);
+ const parentPointer = pointer.slice(0, pointer.lastIndexOf('/'));
+ const parent = entries.get(parentPointer === '#' ? '#/' : parentPointer) ?? null;
+ const entry: NodeEntry = {
+ type: { name: type, properties: {} },
+ key: pointer.slice(pointer.lastIndexOf('/') + 1),
+ location: new Location(source, pointer),
+ value: Object.fromEntries(
+ fields.map((field) => {
+ const [name, value] = field.split('=');
+ return [name, value === 'true' ? true : value === 'false' ? false : value];
+ })
+ ),
+ parent,
+ children: [],
+ };
+ parent?.children.push(entry);
+ entries.set(pointer, entry);
+ }
+
+ return entries;
+}
+
+/** A spelled-out tree paired with itself, its diff nodes keyed by pointer. */
+export function diffTreeOf(lines: string): Map {
+ const nodes = new Map();
+
+ for (const [pointer, entry] of treeOf(lines)) {
+ const parent = entry.parent ? nodes.get(entry.parent.location.pointer)! : null;
+ nodes.set(pointer, { base: entry, revision: entry, parent, referencedBy: [] });
+ }
+
+ return nodes;
+}
+
+/**
+ * The spelled-out diff tree with each `[from, to]` pair linked as a `$ref`: the pair at `to`
+ * is reached again from the place at `from`, the way `compareTrees` notes it.
+ */
+export function withReferences(
+ nodes: Map,
+ references: Array<[from: string, to: string]>
+): Map {
+ for (const [from, to] of references) {
+ nodes.get(to)!.referencedBy.push(nodes.get(from)!);
+ }
+ return nodes;
+}
diff --git a/packages/core/src/diff/changes.ts b/packages/core/src/diff/changes.ts
new file mode 100644
index 0000000000..90ecd8e951
--- /dev/null
+++ b/packages/core/src/diff/changes.ts
@@ -0,0 +1,68 @@
+import { valueOf } from '../node-tree/access.js';
+import type { NodeEntry } from '../node-tree/types.js';
+import { dequal } from '../utils/dequal.js';
+import { isPlainObject } from '../utils/is-plain-object.js';
+import { isScalar, isScalarArray } from '../utils/is-scalar.js';
+import { comparedSidesOf, latestOf, pointerOf } from './diff-node.js';
+import type { Change, DiffNode, LocatedNode } from './types.js';
+
+export function displaySide(change: Change): LocatedNode {
+ return change.kind === 'removed' ? change.base : change.revision;
+}
+
+export function changesOf(node: DiffNode): Change[] {
+ const { base, revision } = comparedSidesOf(node);
+
+ if (!base) {
+ return [{ key: pointerOf(node), node, kind: 'added', revision: located(latestOf(node)) }];
+ }
+ if (!revision) {
+ return [{ key: pointerOf(node), node, kind: 'removed', base: located(base) }];
+ }
+
+ const changes: Change[] = [];
+ let key: string | undefined;
+
+ const before = comparableValues(base);
+ const after = comparableValues(revision);
+
+ for (const property of Object.keys({ ...before, ...after })) {
+ if (dequal(before[property], after[property])) continue;
+
+ key ??= pointerOf(node);
+ changes.push({
+ key,
+ node,
+ kind: 'modified',
+ property,
+ base: locatedProperty(base, property, before[property]),
+ revision: locatedProperty(revision, property, after[property]),
+ });
+ }
+
+ return changes;
+}
+
+function comparableValues(side: NodeEntry): Record {
+ const values: Record = {};
+ const childKeys = new Set(side.children.map((child) => child.key));
+
+ if (isPlainObject(side.value)) {
+ for (const [name, item] of Object.entries(side.value)) {
+ // Children and the targets of `$ref`s are compared as pairs of their own.
+ if (childKeys.has(name) || (name === '$ref' && side.resolved)) continue;
+ if (isScalar(item) || isScalarArray(item)) values[name] = item;
+ }
+ }
+
+ return values;
+}
+
+function located(side: NodeEntry): LocatedNode {
+ return { location: side.location, value: valueOf(side) };
+}
+
+function locatedProperty(side: NodeEntry, property: string, value: unknown): LocatedNode {
+ const isOwnProperty = isPlainObject(side.value) && property in side.value;
+ return { location: isOwnProperty ? side.location.child([property]) : side.location, value };
+}
diff --git a/packages/core/src/diff/diff-node.ts b/packages/core/src/diff/diff-node.ts
new file mode 100644
index 0000000000..2a7a0b0755
--- /dev/null
+++ b/packages/core/src/diff/diff-node.ts
@@ -0,0 +1,48 @@
+import { keyKindOf } from '../node-tree/access.js';
+import type { NodeEntry } from '../node-tree/types.js';
+import { escapePointerFragment, isRefWithSiblings, joinPointer } from '../ref-utils.js';
+import type { DiffNode, Pair } from './types.js';
+
+// A pair has at least one side.
+export function latestOf(node: Pair): NodeEntry {
+ return (node.revision ?? node.base)!;
+}
+
+export function typeOf(node: DiffNode): string {
+ return latestOf(node).type.name;
+}
+
+// A field or a list item written as a `$ref` is named after its target: `schema` or `0` says less
+// than `Order`. A map entry keeps its own key.
+export function nameOf(node: DiffNode): string {
+ const written = latestOf(node);
+ const name =
+ keyKindOf(written) === 'entry' ? written.key : (written.resolved?.key ?? written.key);
+ return String(name);
+}
+
+export function hasKeysNextToRefs(sides: Pair): boolean {
+ if (!sides.base?.resolved || !sides.revision?.resolved) return false;
+ return isRefWithSiblings(sides.base.value) || isRefWithSiblings(sides.revision.value);
+}
+
+// A `$ref` stands for its target, unless both sides are `$ref`s and either has keys next to its
+// `$ref` (OpenAPI 3.1): then the written keys are compared here, and the targets one level down.
+export function comparedSidesOf(node: DiffNode): Pair {
+ if (!node.base || !node.revision || hasKeysNextToRefs(node)) return node;
+
+ return {
+ base: node.base.resolved ?? node.base,
+ revision: node.revision.resolved ?? node.revision,
+ };
+}
+
+export function pointerOf(node: DiffNode): string {
+ if (!node.parent) return '#/';
+
+ const written = latestOf(node);
+ const isRefTarget = written === latestOf(comparedSidesOf(node.parent)).resolved;
+ const segment = isRefTarget ? '$ref' : escapePointerFragment(String(written.key));
+
+ return joinPointer(pointerOf(node.parent), segment);
+}
diff --git a/packages/core/src/diff/diff-tree.ts b/packages/core/src/diff/diff-tree.ts
new file mode 100644
index 0000000000..05af33b1e0
--- /dev/null
+++ b/packages/core/src/diff/diff-tree.ts
@@ -0,0 +1,68 @@
+import type { NodeEntry } from '../node-tree/types.js';
+import { changesOf } from './changes.js';
+import { comparedSidesOf, hasKeysNextToRefs, pointerOf } from './diff-node.js';
+import { pairChildren } from './pair-children.js';
+import type { Change, DiffNode } from './types.js';
+
+/** A node that one side does not have is one change: nothing below it is compared. */
+export function compareTrees(base: NodeEntry, revision: NodeEntry): Change[] {
+ const changes: Change[] = [];
+ const compared = new Map();
+
+ function compare(node: DiffNode): void {
+ const sides = comparedSidesOf(node);
+
+ if (sides.base && sides.revision) {
+ // A pair reached again, or through itself, was compared where it was reached first.
+ const id = `${sides.base.location.absolutePointer}|${sides.revision.location.absolutePointer}`;
+ const first = compared.get(id);
+
+ if (first) {
+ first.referencedBy.push(node);
+ return;
+ }
+
+ compared.set(id, node);
+ }
+
+ changes.push(...changesOf(node));
+
+ if (!sides.base || !sides.revision) return;
+
+ for (const pair of pairChildren(sides.base.children, sides.revision.children)) {
+ const child: DiffNode = { ...pair, parent: node, referencedBy: [] };
+
+ // A renamed map entry, such as a path or a property.
+ if (
+ pair.base &&
+ pair.revision &&
+ typeof pair.base.key === 'string' &&
+ pair.base.key !== pair.revision.key
+ ) {
+ changes.push({
+ key: pointerOf(child),
+ node: child,
+ kind: 'modified',
+ property: 'key',
+ base: { location: pair.base.location, value: pair.base.key },
+ revision: { location: pair.revision.location, value: pair.revision.key },
+ });
+ }
+
+ compare(child);
+ }
+
+ if (hasKeysNextToRefs(sides)) {
+ compare({
+ base: sides.base.resolved,
+ revision: sides.revision.resolved,
+ parent: node,
+ referencedBy: [],
+ });
+ }
+ }
+
+ compare({ base, revision, parent: null, referencedBy: [] });
+
+ return changes;
+}
diff --git a/packages/core/src/diff/direction.ts b/packages/core/src/diff/direction.ts
new file mode 100644
index 0000000000..76ffa5a963
--- /dev/null
+++ b/packages/core/src/diff/direction.ts
@@ -0,0 +1,78 @@
+import { enclosing, fieldOf } from '../node-tree/access.js';
+import type { NodeEntry } from '../node-tree/types.js';
+import { comparedSidesOf } from './diff-node.js';
+import type { DiffNode, Direction } from './types.js';
+
+const bothDirections: Direction[] = ['request', 'response'];
+
+/** Empty for data that travels neither way, such as `info` or a component nothing references. */
+export function directionsOf(node: DiffNode): Direction[] {
+ const directions = directionsIn(node.revision ? 'revision' : 'base', node, new Set());
+
+ return bothDirections.filter((direction) => directions.includes(direction));
+}
+
+function directionsIn(
+ side: 'base' | 'revision',
+ node: DiffNode,
+ visited: Set
+): Direction[] {
+ // Each place is asked once, which also ends the climb through a component that references itself.
+ if (visited.has(node)) return [];
+ visited.add(node);
+
+ // Only pairs have children or other places that reach them, so every place climbed has `side`.
+ const own = ownDirection(comparedSidesOf(node)[side]!) ?? ownDirection(node[side]!);
+ if (own) return [own];
+
+ return [node.parent, ...node.referencedBy].flatMap((place) =>
+ place ? directionsIn(side, place, visited) : []
+ );
+}
+
+function ownDirection(node: NodeEntry): Direction | undefined {
+ switch (node.type.name) {
+ case 'RequestBody':
+ case 'ParameterList':
+ return directionAt(node, 'request');
+ case 'Responses':
+ return directionAt(node, 'response');
+ // "readOnly" data only comes from the API and "writeOnly" data only goes to it (OpenAPI,
+ // Schema Object), wherever the schema is used.
+ case 'Schema':
+ if (fieldOf(node, 'readOnly') === true) return 'response';
+ if (fieldOf(node, 'writeOnly') === true) return 'request';
+ return undefined;
+ // AsyncAPI 3: `receive` means another application produces the message, so its payload is
+ // judged the way a request body is; `send` means this application produces it. An OpenAPI
+ // operation has no `action`, so it says nothing.
+ case 'Operation':
+ return actionDirection(fieldOf(node, 'action'));
+ // A reply answers the operation, so it travels back the other way.
+ case 'OperationReply': {
+ const direction = actionDirection(fieldOf(enclosing(node, 'Operation'), 'action'));
+ return direction && opposite(direction);
+ }
+ default:
+ return undefined;
+ }
+}
+
+// Outside `components`, every `Parameter` sits under a `ParameterList` and every `Response`
+// under `Responses`, so the lists carry the direction. Under a callback or a webhook the API
+// sends the request itself, so it is the other way round.
+function directionAt(node: NodeEntry, direction: Direction): Direction | undefined {
+ if (enclosing(node, 'Components')) return undefined;
+ const sentByApi = enclosing(node, 'CallbacksMap') ?? enclosing(node, 'WebhooksMap');
+ return sentByApi ? opposite(direction) : direction;
+}
+
+function actionDirection(action: unknown): Direction | undefined {
+ if (action === 'receive') return 'request';
+ if (action === 'send') return 'response';
+ return undefined;
+}
+
+function opposite(direction: Direction): Direction {
+ return direction === 'request' ? 'response' : 'request';
+}
diff --git a/packages/core/src/diff/impact.ts b/packages/core/src/diff/impact.ts
new file mode 100644
index 0000000000..6cdb058f2b
--- /dev/null
+++ b/packages/core/src/diff/impact.ts
@@ -0,0 +1,30 @@
+import { typeOf } from './diff-node.js';
+import type { Change, DiffNode, Impact } from './types.js';
+
+/** Lowest first. */
+export const impacts = ['patch', 'minor', 'major'] as const;
+
+export function impactRank(impact: Impact): number {
+ return impacts.indexOf(impact);
+}
+
+export function highestImpact(candidates: Impact[]): Impact | undefined {
+ return candidates.reduce(
+ (highest, impact) =>
+ highest === undefined || impactRank(impact) > impactRank(highest) ? impact : highest,
+ undefined
+ );
+}
+
+// What a change means when no rule spoke: something new is a minor, everything else a patch.
+// A new component adds nothing to the API until a place uses it, and that place is a change of
+// its own.
+export function defaultImpact(change: Change): Impact {
+ return change.kind === 'added' && !isComponentOrContainer(change.node) ? 'minor' : 'patch';
+}
+
+// `components` itself, a map in it such as `schemas`, or a component in that map.
+function isComponentOrContainer(node: DiffNode): boolean {
+ const map = node.parent;
+ return [node, map, map?.parent].some((level) => !!level && typeOf(level) === 'Components');
+}
diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts
new file mode 100644
index 0000000000..3ec49b0024
--- /dev/null
+++ b/packages/core/src/diff/index.ts
@@ -0,0 +1,135 @@
+import type { Config } from '../config/index.js';
+import { initDiffRules, initRules } from '../config/rules.js';
+import { detectSpec, getMajorSpecVersion } from '../detect-spec.js';
+import { buildNodeTree } from '../node-tree/index.js';
+import { getTypes, type SpecVersion } from '../oas-types.js';
+import { BaseResolver, resolveDocument, type Document } from '../resolve.js';
+import { normalizeTypes } from '../types/index.js';
+import { HandledError } from '../utils/error.js';
+import { isPlainObject } from '../utils/is-plain-object.js';
+import { normalizeVisitors } from '../visitors.js';
+import { walkDocument, type WalkContext } from '../walk.js';
+import { typeOf } from './diff-node.js';
+import { compareTrees } from './diff-tree.js';
+import { highestImpact } from './impact.js';
+import { judgeChanges } from './judge.js';
+import type { DiffResult, DiffSummary, Impact, JudgedChange } from './types.js';
+
+export async function diffDocuments(opts: {
+ base: Document;
+ revision: Document;
+ config: Config;
+ externalRefResolver?: BaseResolver;
+}): Promise {
+ const { base, revision, config, externalRefResolver = new BaseResolver(config.resolve) } = opts;
+
+ const baseVersion = detectSpec(base.parsed);
+ const revisionVersion = detectSpec(revision.parsed);
+
+ const family = getMajorSpecVersion(revisionVersion);
+
+ if (getMajorSpecVersion(baseVersion) !== family) {
+ throw new HandledError(`Cannot compare ${baseVersion} with ${revisionVersion}.`);
+ }
+
+ const [baseTree, revisionTree] = await Promise.all([
+ preparedNodeTree(base, baseVersion, config, externalRefResolver),
+ preparedNodeTree(revision, revisionVersion, config, externalRefResolver),
+ ]);
+
+ const rules = initDiffRules(config.getDiffRulesForSpecVersion(family), config, revisionVersion);
+
+ const changes = judgeChanges(
+ compareTrees(baseTree.root, revisionTree.root),
+ rules,
+ revisionVersion
+ );
+
+ return {
+ files: {
+ base: base.source.absoluteRef,
+ revision: revision.source.absoluteRef,
+ },
+ specVersions: {
+ base: baseVersion,
+ revision: revisionVersion,
+ },
+ infoVersions: { base: infoVersionOf(base), revision: infoVersionOf(revision) },
+ summary: countByImpact(changes),
+ bump: requiredBump(changes),
+ changes,
+ problems: [...baseTree.problems, ...revisionTree.problems],
+ };
+}
+
+// Preprocessors change the document first, as they do before lint.
+async function preparedNodeTree(
+ document: Document,
+ specVersion: SpecVersion,
+ config: Config,
+ externalRefResolver: BaseResolver
+) {
+ const rules = config.getRulesForSpecVersion(getMajorSpecVersion(specVersion));
+ const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config);
+
+ const ctx: WalkContext = {
+ problems: [],
+ specVersion,
+ config,
+ visitorsData: {},
+ };
+
+ const preprocessors = initRules(rules, config, 'preprocessors', specVersion);
+
+ let resolvedRefMap = await resolveDocument({
+ rootDocument: document,
+ rootType: types.Root,
+ externalRefResolver,
+ });
+
+ if (preprocessors.length > 0) {
+ // Make additional pass to resolve refs defined in preprocessors.
+ walkDocument({
+ document,
+ rootType: types.Root,
+ normalizedVisitors: normalizeVisitors(preprocessors, types),
+ resolvedRefMap,
+ ctx,
+ });
+ resolvedRefMap = await resolveDocument({
+ rootDocument: document,
+ rootType: types.Root,
+ externalRefResolver,
+ });
+ }
+
+ return {
+ root: buildNodeTree({ document, types, resolvedRefMap, ctx }),
+ problems: ctx.problems.map((problem) => config.addProblemToIgnore(problem)),
+ };
+}
+
+function infoVersionOf({ parsed }: Document): string | undefined {
+ const version =
+ isPlainObject(parsed) && isPlainObject(parsed.info) ? parsed.info.version : undefined;
+ return typeof version === 'string' ? version : undefined;
+}
+
+function countByImpact(changes: JudgedChange[]): DiffSummary {
+ const summary: DiffSummary = { major: 0, minor: 0, patch: 0 };
+ for (const change of changes) summary[change.impact]++;
+ return summary;
+}
+
+function requiredBump(changes: JudgedChange[]): Impact | undefined {
+ return highestImpact(
+ changes.flatMap((change) => {
+ const isVersionChange =
+ change.kind === 'modified' &&
+ change.property === 'version' &&
+ typeOf(change.node) === 'Info';
+
+ return !isVersionChange ? [change.impact] : [];
+ })
+ );
+}
diff --git a/packages/core/src/diff/judge.ts b/packages/core/src/diff/judge.ts
new file mode 100644
index 0000000000..babf8418d5
--- /dev/null
+++ b/packages/core/src/diff/judge.ts
@@ -0,0 +1,106 @@
+import type { InitializedDiffRule } from '../config/rules.js';
+import type { SpecVersion } from '../oas-types.js';
+import { displaySide } from './changes.js';
+import { typeOf } from './diff-node.js';
+import { directionsOf } from './direction.js';
+import { defaultImpact, highestImpact } from './impact.js';
+import type {
+ Change,
+ DiffNode,
+ DiffRuleContext,
+ DiffVisit,
+ DiffVisitor,
+ Direction,
+ Impact,
+ JudgedChange,
+ RuleVerdict,
+} from './types.js';
+
+type RuleHandler = {
+ ruleId: string;
+ impact: Impact;
+ typePath: string[];
+ visit: DiffVisit;
+};
+
+// A diff visitor sees whole changes, not a walk, so lint's hooks have nothing to run on.
+const HOOKS = new Set(['enter', 'leave', 'skip']);
+
+// Each handler with the types it is nested under, so matching a change needs no walk.
+function flattenVisitor(
+ visitor: DiffVisitor,
+ ruleId: string,
+ outerTypes: string[] = []
+): Pick[] {
+ return Object.entries(visitor).flatMap(([type, value]) => {
+ if (HOOKS.has(type) || (type === 'any' && outerTypes.length > 0)) {
+ throw new Error(`Diff rule '${ruleId}' uses '${type}' where diff visitors do not have it.`);
+ }
+ if (typeof value !== 'function') return flattenVisitor(value, ruleId, [...outerTypes, type]);
+ return [{ typePath: type === 'any' ? [] : [...outerTypes, type], visit: value }];
+ });
+}
+
+/**
+ * Lint's nesting: `SchemaProperties › Schema` applies to a schema whose nearest enclosing
+ * `SchemaProperties` or `Schema` is a `SchemaProperties` — a property, not a schema further down
+ * inside one.
+ */
+export function appliesTo(typePath: string[], node: DiffNode): boolean {
+ let current: DiffNode | undefined = node;
+
+ for (let index = typePath.length - 1; index >= 0; index--) {
+ if (current === undefined || typeOf(current) !== typePath[index]) return false;
+ if (index > 0) current = nearest(current.parent, [typePath[index - 1], typePath[index]]);
+ }
+ return true;
+}
+
+function nearest(node: DiffNode | null, types: string[]): DiffNode | undefined {
+ for (let current = node; current; current = current.parent) {
+ if (types.includes(typeOf(current))) return current;
+ }
+ return undefined;
+}
+
+export function judgeChanges(
+ changes: Change[],
+ rules: InitializedDiffRule[],
+ specVersion: SpecVersion
+): JudgedChange[] {
+ const handlers: RuleHandler[] = rules.flatMap(({ ruleId, impact, visitor }) =>
+ flattenVisitor(visitor, ruleId).map((handler) => ({ ruleId, impact, ...handler }))
+ );
+
+ return changes.map((change) => judgeChange(change, handlers, specVersion));
+}
+
+function judgeChange(
+ change: Change,
+ handlers: RuleHandler[],
+ specVersion: SpecVersion
+): JudgedChange {
+ // Computed once per change, and only when a rule asks.
+ let directions: Direction[] | undefined;
+ const getDirections = () => (directions ??= directionsOf(change.node));
+ const location = displaySide(change).location;
+ const verdicts: RuleVerdict[] = [];
+
+ for (const { ruleId, impact, typePath, visit } of handlers) {
+ if (!appliesTo(typePath, change.node)) continue;
+
+ const report: DiffRuleContext['report'] = (verdict) => {
+ verdicts.push({
+ ruleId,
+ impact,
+ message: verdict.message,
+ location: verdict.location ?? location,
+ });
+ };
+
+ visit(change, { report, location, getDirections, specVersion });
+ }
+
+ const impact = highestImpact(verdicts.map((verdict) => verdict.impact));
+ return { ...change, verdicts, impact: impact ?? defaultImpact(change) };
+}
diff --git a/packages/core/src/diff/pair-children.ts b/packages/core/src/diff/pair-children.ts
new file mode 100644
index 0000000000..95095ade6e
--- /dev/null
+++ b/packages/core/src/diff/pair-children.ts
@@ -0,0 +1,117 @@
+import { distance } from 'fastest-levenshtein';
+
+import { keyKindOf, valueOf } from '../node-tree/access.js';
+import type { NodeEntry } from '../node-tree/types.js';
+import { isPlainObject } from '../utils/is-plain-object.js';
+import type { Pair } from './types.js';
+
+// Git counts a file as renamed when at least half of it is unchanged; the same share pairs a
+// renamed key or a changed list item with what it was.
+const PAIRING_THRESHOLD = 0.5;
+
+/**
+ * Pairs each child with the same one on the other side, or else the most alike of its type. The
+ * same ones pair first, so a changed child never takes the place of an unchanged one.
+ */
+export function pairChildren(base: NodeEntry[], revision: NodeEntry[]): Pair[] {
+ const pairs: Pair[] = [];
+ const unpaired = new Set(revision);
+ const changed: NodeEntry[] = [];
+
+ const written = knownAs([...base, ...revision], (child) => child.value);
+ for (const child of base) {
+ const same = findSame(child, unpaired, written);
+
+ if (same) {
+ unpaired.delete(same);
+ pairs.push({ base: child, revision: same });
+ } else {
+ changed.push(child);
+ }
+ }
+
+ // A `$ref` is written as the `$ref`, so two `$ref`s to one place are the same at once; what
+ // changed is paired by what it stands for, so a `$ref` and inline content can still meet.
+ const meant = knownAs([...changed, ...unpaired], valueOf);
+ for (const child of changed) {
+ const alike = findMostAlike(child, unpaired, meant);
+
+ if (alike) unpaired.delete(alike);
+ pairs.push({ base: child, revision: alike });
+ }
+
+ for (const child of unpaired) pairs.push({ revision: child });
+
+ return pairs;
+}
+
+function knownAs(
+ children: NodeEntry[],
+ contentOf: (child: NodeEntry) => unknown
+): Map {
+ return new Map(
+ children.map((child) => [
+ child,
+ typeof child.key === 'string' ? child.key : jsonWithSortedKeys(contentOf(child)),
+ ])
+ );
+}
+
+function findSame(
+ child: NodeEntry,
+ candidates: Set,
+ known: Map
+): NodeEntry | undefined {
+ for (const candidate of candidates) {
+ if (candidate.type.name === child.type.name && known.get(candidate) === known.get(child)) {
+ return candidate;
+ }
+ }
+
+ return undefined;
+}
+
+// The first of the most alike wins a tie, so the document order decides.
+function findMostAlike(
+ child: NodeEntry,
+ candidates: Set,
+ known: Map
+): NodeEntry | undefined {
+ let mostAlike: NodeEntry | undefined;
+ let highestScore = 0;
+
+ for (const candidate of candidates) {
+ if (candidate.type.name !== child.type.name) continue;
+
+ const score = similarity(child, candidate, known);
+
+ if (score >= PAIRING_THRESHOLD && score > highestScore) {
+ mostAlike = candidate;
+ highestScore = score;
+ }
+ }
+
+ return mostAlike;
+}
+
+// The share of the longer of the two that the edit leaves untouched. A field pairs only with the
+// same field.
+function similarity(child: NodeEntry, other: NodeEntry, known: Map): number {
+ if (keyKindOf(child) === 'field') return child.key === other.key ? 1 : 0;
+
+ const childName = known.get(child)!;
+ const otherName = known.get(other)!;
+ const longest = Math.max(childName.length, otherName.length);
+ return longest === 0 ? 1 : 1 - distance(childName, otherName) / longest;
+}
+
+function jsonWithSortedKeys(value: unknown): string {
+ if (Array.isArray(value)) return `[${value.map(jsonWithSortedKeys).join(',')}]`;
+ if (!isPlainObject(value)) return JSON.stringify(value) ?? 'null';
+
+ const fields = Object.keys(value)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${jsonWithSortedKeys(value[key])}`);
+
+ return `{${fields.join(',')}}`;
+}
diff --git a/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts b/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts
new file mode 100644
index 0000000000..b057c5343a
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts
@@ -0,0 +1,159 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (address: string) => outdent`
+ asyncapi: 3.0.0
+ info: { title: Cafe kitchen, version: 1.0.0 }
+ channels:
+ orderPlaced: { address: ${address} }
+`;
+
+describe('channel-address-changed', () => {
+ it('should report a channel that moved to another address', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('orders.placed'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('kitchen.orders.placed'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'channel-address-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/channels/orderPlaced/address",
+ "value": "orders.placed",
+ },
+ "impact": "major",
+ "key": "#/channels/orderPlaced",
+ "kind": "modified",
+ "property": "address",
+ "revision": {
+ "location": "revision.yaml#/channels/orderPlaced/address",
+ "value": "kitchen.orders.placed",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/channels/orderPlaced/address",
+ "message": "\`address\` of channel \`orderPlaced\` changed from 'orders.placed' to 'kitchen.orders.placed'.",
+ "ruleId": "channel-address-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a channel that got an address', async () => {
+ const orders = (channel: string) => outdent`
+ asyncapi: 3.0.0
+ info: { title: Cafe kitchen, version: 1.0.0 }
+ channels:
+ orders: ${channel}
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(orders('{ title: Orders }'), 'base.yaml'),
+ revision: makeDocumentFromString(
+ orders('{ address: orders, title: Orders }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'channel-address-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/channels/orders",
+ "value": undefined,
+ },
+ "impact": "major",
+ "key": "#/channels/orders",
+ "kind": "modified",
+ "property": "address",
+ "revision": {
+ "location": "revision.yaml#/channels/orders/address",
+ "value": "orders",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/channels/orders/address",
+ "message": "\`address\` of channel \`orders\` was set to 'orders'.",
+ "ruleId": "channel-address-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a channel that lost its address', async () => {
+ const orders = (channel: string) => outdent`
+ asyncapi: 3.0.0
+ info: { title: Cafe kitchen, version: 1.0.0 }
+ channels:
+ orders: ${channel}
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(orders('{ address: orders, title: Orders }'), 'base.yaml'),
+ revision: makeDocumentFromString(orders('{ title: Orders }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'channel-address-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/channels/orders/address",
+ "value": "orders",
+ },
+ "impact": "major",
+ "key": "#/channels/orders",
+ "kind": "modified",
+ "property": "address",
+ "revision": {
+ "location": "revision.yaml#/channels/orders",
+ "value": undefined,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/channels/orders",
+ "message": "\`address\` of channel \`orders\` was removed.",
+ "ruleId": "channel-address-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should name the channel an operation points to by the channel, not by the operation field', async () => {
+ const kitchen = (channel: string) => outdent`
+ asyncapi: 3.0.0
+ info: { title: Cafe kitchen, version: 1.0.0 }
+ channels:
+ orders: { address: orders }
+ ordersV2: { address: orders.v2 }
+ operations:
+ placeOrder: { action: send, channel: { $ref: '#/channels/${channel}' } }
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(kitchen('orders'), 'base.yaml'),
+ revision: makeDocumentFromString(kitchen('ordersV2'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'channel-address-changed': 'major' } }),
+ });
+
+ expect(result.changes.flatMap((change) => change.verdicts)).toMatchObject([
+ { message: "`address` of channel `ordersV2` changed from 'orders' to 'orders.v2'." },
+ ]);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/channel-removed.test.ts b/packages/core/src/diff/rules/__tests__/channel-removed.test.ts
new file mode 100644
index 0000000000..22c64a005d
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/channel-removed.test.ts
@@ -0,0 +1,117 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (channels?: string) => outdent`
+ asyncapi: 3.0.0
+ info: { title: Cafe kitchen, version: 1.0.0 }
+ ${channels === undefined ? '' : `channels: ${channels}`}
+`;
+
+const placed = 'orderPlaced: { address: orders.placed }';
+const ready = 'orderReady: { address: orders.ready }';
+
+// A channel is where messages travel, so both sides of it break together.
+describe('channel-removed', () => {
+ it('should report a channel that is gone', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${placed}, ${ready} }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(`{ ${placed} }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'channel-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/channels/orderReady",
+ "value": {
+ "address": "orders.ready",
+ },
+ },
+ "impact": "major",
+ "key": "#/channels/orderReady",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/channels/orderReady",
+ "message": "Channel \`orderReady\` was removed.",
+ "ruleId": "channel-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report every channel leaving with the whole map', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${placed} }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(), 'revision.yaml'),
+ config: await createConfig({ diff: { 'channel-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/channels",
+ "value": {
+ "orderPlaced": {
+ "address": "orders.placed",
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/channels",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/channels",
+ "message": "All channels were removed.",
+ "ruleId": "channel-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report the components channels map', async () => {
+ const withComponents = (components: string) => outdent`
+ asyncapi: 3.0.0
+ info: { title: Cafe kitchen, version: 1.0.0 }
+ components: ${components}
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(withComponents(`{ channels: { ${placed} } }`), 'base.yaml'),
+ revision: makeDocumentFromString(withComponents('{}'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'channel-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/components/channels",
+ "value": {
+ "orderPlaced": {
+ "address": "orders.placed",
+ },
+ },
+ },
+ "impact": "patch",
+ "key": "#/components/channels",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts b/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts
new file mode 100644
index 0000000000..8f1fb0faff
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts
@@ -0,0 +1,194 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (status: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: ${status}
+ responses:
+ '201':
+ description: Created
+ content:
+ application/json:
+ schema: ${status}
+`;
+
+describe('enum-values-added', () => {
+ it('should report the values a response may now send, not the ones a request now accepts', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: string, enum: [placed, ready] }'), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe('{ type: string, enum: [placed, preparing, ready] }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'enum-values-added': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum",
+ "value": [
+ "placed",
+ "ready",
+ ],
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "enum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum",
+ "value": [
+ "placed",
+ "preparing",
+ "ready",
+ ],
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum",
+ "value": [
+ "placed",
+ "ready",
+ ],
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "enum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum",
+ "value": [
+ "placed",
+ "preparing",
+ "ready",
+ ],
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum",
+ "message": "Enum gained values: 'preparing'.",
+ "ruleId": "enum-values-added",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report an enum removed from a response, not an enum set on one', async () => {
+ const order = (status: string, size: string) =>
+ cafe(`{ type: object, properties: { status: ${status}, size: ${size} } }`);
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ order('{ type: string, enum: [placed, ready] }', '{ type: string }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ order('{ type: string }', '{ type: string, enum: [small, large] }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'enum-values-added': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status/enum",
+ "value": [
+ "placed",
+ "ready",
+ ],
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status",
+ "kind": "modified",
+ "property": "enum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size",
+ "kind": "modified",
+ "property": "enum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size/enum",
+ "value": [
+ "small",
+ "large",
+ ],
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status/enum",
+ "value": [
+ "placed",
+ "ready",
+ ],
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status",
+ "kind": "modified",
+ "property": "enum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status",
+ "value": undefined,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status",
+ "message": "Enum of \`status\` was removed.",
+ "ruleId": "enum-values-added",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size",
+ "kind": "modified",
+ "property": "enum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size/enum",
+ "value": [
+ "small",
+ "large",
+ ],
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts b/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts
new file mode 100644
index 0000000000..6fa31a89a0
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts
@@ -0,0 +1,271 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (status: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: ${status}
+ responses:
+ '201':
+ description: Created
+ content:
+ application/json:
+ schema: ${status}
+`;
+
+describe('enum-values-removed', () => {
+ it('should report the values a request no longer accepts, not the ones a response stops sending', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ type: string, enum: [placed, preparing, ready] }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('{ type: string, enum: [placed, ready] }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'enum-values-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum",
+ "value": [
+ "placed",
+ "preparing",
+ "ready",
+ ],
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "enum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum",
+ "value": [
+ "placed",
+ "ready",
+ ],
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum",
+ "message": "Enum lost values: 'preparing'.",
+ "ruleId": "enum-values-removed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum",
+ "value": [
+ "placed",
+ "preparing",
+ "ready",
+ ],
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "enum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum",
+ "value": [
+ "placed",
+ "ready",
+ ],
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a schema used only in a response, even where a base-side writeOnly property loses values', async () => {
+ const cafeWithNote = (note: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders/{orderId}:
+ get:
+ parameters:
+ - { name: orderId, in: path, required: true, schema: { type: string } }
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Order' }
+ components:
+ schemas:
+ Order:
+ type: object
+ properties:
+ note: ${note}
+ `;
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafeWithNote('{ type: string, writeOnly: true, enum: [a, b] }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafeWithNote('{ type: string, enum: [a] }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'enum-values-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/components/schemas/Order/properties/note/writeOnly",
+ "value": true,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders~1{orderId}/get/responses/200/content/application~1json/schema/properties/note",
+ "kind": "modified",
+ "property": "writeOnly",
+ "revision": {
+ "location": "revision.yaml#/components/schemas/Order/properties/note",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/components/schemas/Order/properties/note/enum",
+ "value": [
+ "a",
+ "b",
+ ],
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders~1{orderId}/get/responses/200/content/application~1json/schema/properties/note",
+ "kind": "modified",
+ "property": "enum",
+ "revision": {
+ "location": "revision.yaml#/components/schemas/Order/properties/note/enum",
+ "value": [
+ "a",
+ ],
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report an enum set on a request, not an enum removed from one', async () => {
+ const order = (status: string, size: string) =>
+ cafe(`{ type: object, properties: { status: ${status}, size: ${size} } }`);
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ order('{ type: string }', '{ type: string, enum: [small, large] }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ order('{ type: string, enum: [placed, ready] }', '{ type: string }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'enum-values-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status",
+ "value": undefined,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status",
+ "kind": "modified",
+ "property": "enum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status/enum",
+ "value": [
+ "placed",
+ "ready",
+ ],
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status/enum",
+ "message": "Enum of \`status\` was set to 'placed', 'ready'.",
+ "ruleId": "enum-values-removed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size/enum",
+ "value": [
+ "small",
+ "large",
+ ],
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size",
+ "kind": "modified",
+ "property": "enum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status",
+ "kind": "modified",
+ "property": "enum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status/enum",
+ "value": [
+ "placed",
+ "ready",
+ ],
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size/enum",
+ "value": [
+ "small",
+ "large",
+ ],
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size",
+ "kind": "modified",
+ "property": "enum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts b/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts
new file mode 100644
index 0000000000..fb7ad8d6a9
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts
@@ -0,0 +1,184 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (content?: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu:
+ get:
+ responses:
+ '200':
+ description: OK
+ ${content === undefined ? '' : `content: ${content}`}
+`;
+
+const json = 'application/json: { schema: { type: array } }';
+const csv = 'text/csv: { schema: { type: string } }';
+
+describe('media-type-removed', () => {
+ it('should report a media type that is gone', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${json}, ${csv} }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(`{ ${json} }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'media-type-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/responses/200/content/text~1csv",
+ "value": {
+ "schema": {
+ "type": "string",
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get/responses/200/content/text~1csv",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1menu/get/responses/200/content/text~1csv",
+ "message": "Media type \`text/csv\` was removed.",
+ "ruleId": "media-type-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report every media type leaving with the whole content map', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${json} }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(), 'revision.yaml'),
+ config: await createConfig({ diff: { 'media-type-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/responses/200/content",
+ "value": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ },
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get/responses/200/content",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1menu/get/responses/200/content",
+ "message": "All media types were removed.",
+ "ruleId": "media-type-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a media type replaced by another', async () => {
+ const xml = 'application/xml: { schema: { type: array } }';
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${json} }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(`{ ${xml} }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'media-type-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/responses/200/content/application~1json",
+ "value": "application/json",
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get/responses/200/content/application~1xml",
+ "kind": "modified",
+ "property": "key",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/responses/200/content/application~1xml",
+ "value": "application/xml",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1menu/get/responses/200/content/application~1xml",
+ "message": "Media type \`application/json\` became \`application/xml\`.",
+ "ruleId": "media-type-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a media type of a component response nothing references', async () => {
+ const withComponent = (content: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths: {}
+ components:
+ responses:
+ Unused:
+ description: OK
+ content: ${content}
+ `;
+ const plain = 'text/plain: { schema: { type: string } }';
+ const xml = 'application/xml: { schema: { type: array } }';
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(withComponent(`{ ${json}, ${plain} }`), 'base.yaml'),
+ revision: makeDocumentFromString(withComponent(`{ ${xml} }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'media-type-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/components/responses/Unused/content/application~1json",
+ "value": "application/json",
+ },
+ "impact": "patch",
+ "key": "#/components/responses/Unused/content/application~1xml",
+ "kind": "modified",
+ "property": "key",
+ "revision": {
+ "location": "revision.yaml#/components/responses/Unused/content/application~1xml",
+ "value": "application/xml",
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/components/responses/Unused/content/text~1plain",
+ "value": {
+ "schema": {
+ "type": "string",
+ },
+ },
+ },
+ "impact": "patch",
+ "key": "#/components/responses/Unused/content/text~1plain",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts b/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts
new file mode 100644
index 0000000000..ced0be0211
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts
@@ -0,0 +1,119 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (contentType?: string) => outdent`
+ asyncapi: 3.0.0
+ info: { title: Cafe kitchen, version: 1.0.0 }
+ channels:
+ orders:
+ address: orders
+ messages:
+ orderPlaced: { ${contentType === undefined ? '' : `contentType: ${contentType}, `}payload: { type: object } }
+`;
+
+describe('message-content-type-changed', () => {
+ it('should report a message encoded another way', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('application/json'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('application/avro'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'message-content-type-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/channels/orders/messages/orderPlaced/contentType",
+ "value": "application/json",
+ },
+ "impact": "major",
+ "key": "#/channels/orders/messages/orderPlaced",
+ "kind": "modified",
+ "property": "contentType",
+ "revision": {
+ "location": "revision.yaml#/channels/orders/messages/orderPlaced/contentType",
+ "value": "application/avro",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/channels/orders/messages/orderPlaced/contentType",
+ "message": "\`contentType\` of message \`orderPlaced\` changed from 'application/json' to 'application/avro'.",
+ "ruleId": "message-content-type-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a message that gained or lost its content type', async () => {
+ const gained = await diffDocuments({
+ base: makeDocumentFromString(cafe(), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('application/json'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'message-content-type-changed': 'major' } }),
+ });
+ const lost = await diffDocuments({
+ base: makeDocumentFromString(cafe('application/json'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(), 'revision.yaml'),
+ config: await createConfig({ diff: { 'message-content-type-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(gained.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/channels/orders/messages/orderPlaced",
+ "value": undefined,
+ },
+ "impact": "major",
+ "key": "#/channels/orders/messages/orderPlaced",
+ "kind": "modified",
+ "property": "contentType",
+ "revision": {
+ "location": "revision.yaml#/channels/orders/messages/orderPlaced/contentType",
+ "value": "application/json",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/channels/orders/messages/orderPlaced/contentType",
+ "message": "\`contentType\` of message \`orderPlaced\` was set to 'application/json'.",
+ "ruleId": "message-content-type-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ expect(replaceSourceWithRefInChanges(lost.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/channels/orders/messages/orderPlaced/contentType",
+ "value": "application/json",
+ },
+ "impact": "major",
+ "key": "#/channels/orders/messages/orderPlaced",
+ "kind": "modified",
+ "property": "contentType",
+ "revision": {
+ "location": "revision.yaml#/channels/orders/messages/orderPlaced",
+ "value": undefined,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/channels/orders/messages/orderPlaced",
+ "message": "\`contentType\` of message \`orderPlaced\` was removed.",
+ "ruleId": "message-content-type-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/message-removed.test.ts b/packages/core/src/diff/rules/__tests__/message-removed.test.ts
new file mode 100644
index 0000000000..909b3ec369
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/message-removed.test.ts
@@ -0,0 +1,136 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (messages?: string) => outdent`
+ asyncapi: 3.0.0
+ info: { title: Cafe kitchen, version: 1.0.0 }
+ channels:
+ orders:
+ address: orders
+ ${messages === undefined ? '' : `messages: ${messages}`}
+`;
+
+const placed = 'orderPlaced: { payload: { type: object } }';
+const cancelled = 'orderCancelled: { payload: { type: object } }';
+
+describe('message-removed', () => {
+ it('should report a message that is gone from its channel', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${placed}, ${cancelled} }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(`{ ${placed} }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'message-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/channels/orders/messages/orderCancelled",
+ "value": {
+ "payload": {
+ "type": "object",
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/channels/orders/messages/orderCancelled",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/channels/orders/messages/orderCancelled",
+ "message": "Message \`orderCancelled\` was removed.",
+ "ruleId": "message-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report every message of a channel leaving with the whole map', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${placed} }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(), 'revision.yaml'),
+ config: await createConfig({ diff: { 'message-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/channels/orders/messages",
+ "value": {
+ "orderPlaced": {
+ "payload": {
+ "type": "object",
+ },
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/channels/orders/messages",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/channels/orders/messages",
+ "message": "All messages of channel \`orders\` were removed.",
+ "ruleId": "message-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a components messages map, which no channel is', async () => {
+ const withComponents = (components: string) => outdent`
+ asyncapi: 3.0.0
+ info: { title: Cafe kitchen, version: 1.0.0 }
+ channels:
+ orders:
+ address: orders
+ messages:
+ ${placed}
+ components: ${components}
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ withComponents(`{ messages: { ${cancelled} }, schemas: { Order: { type: object } } }`),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ withComponents('{ schemas: { Order: { type: object } } }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'message-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/components/messages",
+ "value": {
+ "orderCancelled": {
+ "payload": {
+ "type": "object",
+ },
+ },
+ },
+ },
+ "impact": "patch",
+ "key": "#/components/messages",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts b/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts
new file mode 100644
index 0000000000..506dcb9466
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts
@@ -0,0 +1,54 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (action: string) => outdent`
+ asyncapi: 3.0.0
+ info: { title: Cafe kitchen, version: 1.0.0 }
+ channels:
+ orders: { address: orders }
+ operations:
+ onOrderPlaced:
+ action: ${action}
+ channel: { $ref: '#/channels/orders' }
+`;
+
+describe('operation-action-changed', () => {
+ it('should report an operation that now sends what it received', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('receive'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('send'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'operation-action-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/operations/onOrderPlaced/action",
+ "value": "receive",
+ },
+ "impact": "major",
+ "key": "#/operations/onOrderPlaced",
+ "kind": "modified",
+ "property": "action",
+ "revision": {
+ "location": "revision.yaml#/operations/onOrderPlaced/action",
+ "value": "send",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/operations/onOrderPlaced/action",
+ "message": "\`action\` of operation \`onOrderPlaced\` changed from 'receive' to 'send'.",
+ "ruleId": "operation-action-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/operation-id-changed.test.ts b/packages/core/src/diff/rules/__tests__/operation-id-changed.test.ts
new file mode 100644
index 0000000000..91b097af91
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/operation-id-changed.test.ts
@@ -0,0 +1,184 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (operationId?: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu:
+ get:
+ ${operationId === undefined ? '' : `operationId: ${operationId}`}
+ responses: { 200: { description: OK } }
+`;
+
+const diffOperationIds = async (base?: string, revision?: string) =>
+ diffDocuments({
+ base: makeDocumentFromString(cafe(base), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(revision), 'revision.yaml'),
+ config: await createConfig({ diff: { 'operation-id-changed': 'major' } }),
+ });
+
+describe('operation-id-changed', () => {
+ it('should report an operationId that changed or was removed, not one that was added', async () => {
+ const changed = await diffOperationIds('listMenu', 'getMenu');
+
+ expect(replaceSourceWithRefInChanges(changed.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/operationId",
+ "value": "listMenu",
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get",
+ "kind": "modified",
+ "property": "operationId",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/operationId",
+ "value": "getMenu",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1menu/get/operationId",
+ "message": "\`operationId\` of operation \`GET /menu\` changed from 'listMenu' to 'getMenu'.",
+ "ruleId": "operation-id-changed",
+ },
+ ],
+ },
+ ]
+ `);
+
+ const removed = await diffOperationIds('listMenu');
+
+ expect(replaceSourceWithRefInChanges(removed.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/operationId",
+ "value": "listMenu",
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get",
+ "kind": "modified",
+ "property": "operationId",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get",
+ "value": undefined,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1menu/get",
+ "message": "\`operationId\` of operation \`GET /menu\` was removed.",
+ "ruleId": "operation-id-changed",
+ },
+ ],
+ },
+ ]
+ `);
+
+ const added = await diffOperationIds(undefined, 'listMenu');
+
+ expect(replaceSourceWithRefInChanges(added.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1menu/get",
+ "kind": "modified",
+ "property": "operationId",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/operationId",
+ "value": "listMenu",
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report an operationId changed in a webhook or a callback', async () => {
+ const withOperationIds = (webhookId: string, callbackId: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ callbacks:
+ ready:
+ "{$request.body#/url}":
+ post:
+ operationId: ${callbackId}
+ responses: { 200: { description: OK } }
+ responses: { 200: { description: OK } }
+ webhooks:
+ orderReady:
+ post:
+ operationId: ${webhookId}
+ responses: { 200: { description: OK } }
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(withOperationIds('onReady', 'notifyReady'), 'base.yaml'),
+ revision: makeDocumentFromString(withOperationIds('readyHook', 'readyCall'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'operation-id-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post/operationId",
+ "value": "notifyReady",
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post",
+ "kind": "modified",
+ "property": "operationId",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post/operationId",
+ "value": "readyCall",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post/operationId",
+ "message": "\`operationId\` of operation \`POST {$request.body#/url}\` changed from 'notifyReady' to 'readyCall'.",
+ "ruleId": "operation-id-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/webhooks/orderReady/post/operationId",
+ "value": "onReady",
+ },
+ "impact": "major",
+ "key": "#/webhooks/orderReady/post",
+ "kind": "modified",
+ "property": "operationId",
+ "revision": {
+ "location": "revision.yaml#/webhooks/orderReady/post/operationId",
+ "value": "readyHook",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/webhooks/orderReady/post/operationId",
+ "message": "\`operationId\` of operation \`POST orderReady\` changed from 'onReady' to 'readyHook'.",
+ "ruleId": "operation-id-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/operation-removed.test.ts b/packages/core/src/diff/rules/__tests__/operation-removed.test.ts
new file mode 100644
index 0000000000..efb60dd8f7
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/operation-removed.test.ts
@@ -0,0 +1,270 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (operations: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders/{orderId}: ${operations}
+`;
+
+const get = 'get: { responses: { 200: { description: OK } } }';
+const cancel = 'delete: { responses: { 204: { description: Cancelled } } }';
+
+describe('operation-removed', () => {
+ it('should report an operation that is gone', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${get}, ${cancel} }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(`{ ${get} }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'operation-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders~1{orderId}/delete",
+ "value": {
+ "responses": {
+ "204": {
+ "description": "Cancelled",
+ },
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders~1{orderId}/delete",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders~1{orderId}/delete",
+ "message": "Operation \`DELETE /orders/{orderId}\` was removed.",
+ "ruleId": "operation-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should name an AsyncAPI operation by its key', async () => {
+ const kitchen = (operations: string) => outdent`
+ asyncapi: 3.0.0
+ info: { title: Cafe kitchen, version: 1.0.0 }
+ channels:
+ orders: { address: orders }
+ operations: ${operations}
+ `;
+ const receive = `onOrderPlaced: { action: receive, channel: { $ref: '#/channels/orders' } }`;
+ const send = `sendOrderReady: { action: send, channel: { $ref: '#/channels/orders' } }`;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(kitchen(`{ ${receive}, ${send} }`), 'base.yaml'),
+ revision: makeDocumentFromString(kitchen(`{ ${receive} }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'operation-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/operations/sendOrderReady",
+ "value": {
+ "action": "send",
+ "channel": {
+ "$ref": "#/channels/orders",
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/operations/sendOrderReady",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/operations/sendOrderReady",
+ "message": "Operation \`sendOrderReady\` was removed.",
+ "ruleId": "operation-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report an operation removed from a callback', async () => {
+ const withCallback = (operations: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ callbacks: { ready: { "{$request.body#/url}": { ${operations} } } }
+ responses: { 200: { description: OK } }
+ `;
+ const post = 'post: { responses: { 200: { description: OK } } }';
+ const put = 'put: { responses: { 200: { description: OK } } }';
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(withCallback(`${post}, ${put}`), 'base.yaml'),
+ revision: makeDocumentFromString(withCallback(put), 'revision.yaml'),
+ config: await createConfig({ diff: { 'operation-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post",
+ "value": {
+ "responses": {
+ "200": {
+ "description": "OK",
+ },
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post",
+ "message": "Operation \`POST {$request.body#/url}\` was removed.",
+ "ruleId": "operation-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report an operation removed from a webhook', async () => {
+ const withWebhook = (operations: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths: {}
+ webhooks:
+ orderReady: { ${operations} }
+ `;
+ const post = 'post: { responses: { 200: { description: OK } } }';
+ const put = 'put: { responses: { 200: { description: OK } } }';
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(withWebhook(`${post}, ${put}`), 'base.yaml'),
+ revision: makeDocumentFromString(withWebhook(put), 'revision.yaml'),
+ config: await createConfig({ diff: { 'operation-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/webhooks/orderReady/post",
+ "value": {
+ "responses": {
+ "200": {
+ "description": "OK",
+ },
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/webhooks/orderReady/post",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/webhooks/orderReady/post",
+ "message": "Operation \`POST orderReady\` was removed.",
+ "ruleId": "operation-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report an AsyncAPI document losing all its operations, not its components operations', async () => {
+ const kitchen = (operations: string, components: string) => outdent`
+ asyncapi: 3.0.0
+ info: { title: Cafe kitchen, version: 1.0.0 }
+ channels:
+ orders: { address: orders }
+ ${operations}
+ ${components}
+ `;
+ const receive = `onOrderPlaced: { action: receive, channel: { $ref: '#/channels/orders' } }`;
+ const operations = `operations: { ${receive} }`;
+ const components = `components: { operations: { ${receive} } }`;
+
+ const document = await diffDocuments({
+ base: makeDocumentFromString(kitchen(operations, ''), 'base.yaml'),
+ revision: makeDocumentFromString(kitchen('', ''), 'revision.yaml'),
+ config: await createConfig({ diff: { 'operation-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(document.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/operations",
+ "value": {
+ "onOrderPlaced": {
+ "action": "receive",
+ "channel": {
+ "$ref": "#/channels/orders",
+ },
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/operations",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/operations",
+ "message": "All operations were removed.",
+ "ruleId": "operation-removed",
+ },
+ ],
+ },
+ ]
+ `);
+
+ const unused = await diffDocuments({
+ base: makeDocumentFromString(kitchen('', components), 'base.yaml'),
+ revision: makeDocumentFromString(kitchen('', 'components: {}'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'operation-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(unused.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/components/operations",
+ "value": {
+ "onOrderPlaced": {
+ "action": "receive",
+ "channel": {
+ "$ref": "#/channels/orders",
+ },
+ },
+ },
+ },
+ "impact": "patch",
+ "key": "#/components/operations",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts b/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts
new file mode 100644
index 0000000000..117b983895
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts
@@ -0,0 +1,292 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (parameters: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu:
+ get:
+ parameters: ${parameters}
+ responses:
+ '200': { description: OK }
+ components:
+ parameters:
+ Sort: { name: sort, in: query, schema: { type: string } }
+ Category: { name: category, in: query, required: true, schema: { type: string } }
+`;
+
+const search = '{ name: search, in: query, schema: { type: string } }';
+
+describe('parameter-became-required', () => {
+ it('should report an optional parameter that became required, not one that became optional', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe(
+ '[{ name: limit, in: query, schema: { type: integer } }, { name: search, in: query, required: true }]'
+ ),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe(
+ '[{ name: limit, in: query, required: true, schema: { type: integer } }, { name: search, in: query, required: false }]'
+ ),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'parameter-became-required': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/parameters/0",
+ "value": undefined,
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get/parameters/0",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/parameters/0/required",
+ "value": true,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1menu/get/parameters/0/required",
+ "message": "\`limit\` query parameter became required.",
+ "ruleId": "parameter-became-required",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/parameters/1/required",
+ "value": true,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1menu/get/parameters/1",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/parameters/1/required",
+ "value": false,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a new required parameter, not a new optional one', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`[${search}]`), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe(
+ `[${search}, { name: limit, in: query, schema: { type: integer } }, { name: locale, in: header, required: true, schema: { type: string } }]`
+ ),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'parameter-became-required': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "impact": "minor",
+ "key": "#/paths/~1menu/get/parameters/1",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/parameters/1",
+ "value": {
+ "in": "query",
+ "name": "limit",
+ "schema": {
+ "type": "integer",
+ },
+ },
+ },
+ "verdicts": [],
+ },
+ {
+ "impact": "major",
+ "key": "#/paths/~1menu/get/parameters/2",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/parameters/2",
+ "value": {
+ "in": "header",
+ "name": "locale",
+ "required": true,
+ "schema": {
+ "type": "string",
+ },
+ },
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1menu/get/parameters/2/required",
+ "message": "Required \`locale\` header parameter was added.",
+ "ruleId": "parameter-became-required",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report each required parameter of a new parameters list at its own required field', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu:
+ get:
+ responses:
+ '200': { description: OK }
+ components:
+ parameters:
+ C: { name: c, in: query, required: true, schema: { type: string } }
+ `,
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu:
+ get:
+ parameters:
+ - { name: a, in: query, required: true, schema: { type: string } }
+ - { name: b, in: query, schema: { type: string } }
+ - { $ref: '#/components/parameters/C' }
+ responses:
+ '200': { description: OK }
+ components:
+ parameters:
+ C: { name: c, in: query, required: true, schema: { type: string } }
+ `,
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'parameter-became-required': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "impact": "major",
+ "key": "#/paths/~1menu/get/parameters",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/parameters",
+ "value": [
+ {
+ "in": "query",
+ "name": "a",
+ "required": true,
+ "schema": {
+ "type": "string",
+ },
+ },
+ {
+ "in": "query",
+ "name": "b",
+ "schema": {
+ "type": "string",
+ },
+ },
+ {
+ "$ref": "#/components/parameters/C",
+ },
+ ],
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1menu/get/parameters/0/required",
+ "message": "Required \`a\` query parameter was added.",
+ "ruleId": "parameter-became-required",
+ },
+ {
+ "impact": "major",
+ "location": "revision.yaml#/components/parameters/C/required",
+ "message": "Required \`c\` query parameter was added.",
+ "ruleId": "parameter-became-required",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should read whether a referenced parameter is required from what it points at', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe("[{ $ref: '#/components/parameters/Sort' }]"), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe(
+ "[{ $ref: '#/components/parameters/Sort' }, { $ref: '#/components/parameters/Category' }]"
+ ),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'parameter-became-required': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "impact": "major",
+ "key": "#/paths/~1menu/get/parameters/1",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/parameters/1",
+ "value": {
+ "in": "query",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string",
+ },
+ },
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/components/parameters/Category/required",
+ "message": "Required \`category\` query parameter was added.",
+ "ruleId": "parameter-became-required",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report referenced parameters listed in another order', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe(
+ "[{ $ref: '#/components/parameters/Sort' }, { $ref: '#/components/parameters/Category' }]"
+ ),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe(
+ "[{ $ref: '#/components/parameters/Category' }, { $ref: '#/components/parameters/Sort' }]"
+ ),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'parameter-became-required': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`[]`);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts b/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts
new file mode 100644
index 0000000000..aaadbabdca
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts
@@ -0,0 +1,343 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (parameters?: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu:
+ get:
+ ${parameters === undefined ? '' : `parameters: ${parameters}`}
+ responses:
+ '200': { description: OK }
+ components:
+ parameters:
+ Sort: { name: sort, in: query, schema: { type: string } }
+ Category: { name: category, in: query, required: true, schema: { type: string } }
+`;
+
+const limit = '{ name: limit, in: query, schema: { type: integer } }';
+const search = '{ name: search, in: query, schema: { type: string } }';
+
+describe('parameter-removed', () => {
+ it('should report a parameter that is gone', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`[${limit}, ${search}]`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(`[${search}]`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'parameter-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/parameters/0",
+ "value": {
+ "in": "query",
+ "name": "limit",
+ "schema": {
+ "type": "integer",
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get/parameters/0",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1menu/get/parameters/0",
+ "message": "\`limit\` query parameter was removed.",
+ "ruleId": "parameter-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report the last parameter leaving with the whole list', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`[${limit}]`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(), 'revision.yaml'),
+ config: await createConfig({ diff: { 'parameter-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/parameters",
+ "value": [
+ {
+ "in": "query",
+ "name": "limit",
+ "schema": {
+ "type": "integer",
+ },
+ },
+ ],
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get/parameters",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1menu/get/parameters",
+ "message": "All parameters were removed.",
+ "ruleId": "parameter-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a referenced parameter swapped for another', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe("[{ $ref: '#/components/parameters/Sort' }]"), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe("[{ $ref: '#/components/parameters/Category' }]"),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'parameter-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/components/parameters/Sort/name",
+ "value": "sort",
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get/parameters/0",
+ "kind": "modified",
+ "property": "name",
+ "revision": {
+ "location": "revision.yaml#/components/parameters/Category/name",
+ "value": "category",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/components/parameters/Category/name",
+ "message": "\`sort\` query parameter was renamed to \`category\`.",
+ "ruleId": "parameter-removed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/components/parameters/Sort",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1menu/get/parameters/0",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/components/parameters/Category/required",
+ "value": true,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a parameter moved to another place in the request', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('[{ name: token, in: query, schema: { type: string } }]'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('[{ name: token, in: header, schema: { type: string } }]'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'parameter-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/parameters/0/in",
+ "value": "query",
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get/parameters/0",
+ "kind": "modified",
+ "property": "in",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/parameters/0/in",
+ "value": "header",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1menu/get/parameters/0/in",
+ "message": "\`token\` query parameter is now a header parameter.",
+ "ruleId": "parameter-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should name a parameter by its old name and place when both changed', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('[{ name: id, in: query, schema: { type: string } }]'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('[{ name: itemId, in: header, schema: { type: string } }]'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'parameter-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/parameters/0/name",
+ "value": "id",
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get/parameters/0",
+ "kind": "modified",
+ "property": "name",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/parameters/0/name",
+ "value": "itemId",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1menu/get/parameters/0/name",
+ "message": "\`id\` query parameter was renamed to \`itemId\`.",
+ "ruleId": "parameter-removed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/parameters/0/in",
+ "value": "query",
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get/parameters/0",
+ "kind": "modified",
+ "property": "in",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/parameters/0/in",
+ "value": "header",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1menu/get/parameters/0/in",
+ "message": "\`id\` query parameter is now a header parameter.",
+ "ruleId": "parameter-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a path parameter renamed with its path, or a header renamed only in case', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu/{id}:
+ get:
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string } }
+ - { name: X-Token, in: header, schema: { type: string } }
+ responses:
+ '200': { description: OK }
+ `,
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu/{itemId}:
+ get:
+ parameters:
+ - { name: itemId, in: path, required: true, schema: { type: string } }
+ - { name: x-token, in: header, schema: { type: string } }
+ responses:
+ '200': { description: OK }
+ `,
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'parameter-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu~1{id}",
+ "value": "/menu/{id}",
+ },
+ "impact": "patch",
+ "key": "#/paths/~1menu~1{itemId}",
+ "kind": "modified",
+ "property": "key",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu~1{itemId}",
+ "value": "/menu/{itemId}",
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu~1{id}/get/parameters/0/name",
+ "value": "id",
+ },
+ "impact": "patch",
+ "key": "#/paths/~1menu~1{itemId}/get/parameters/0",
+ "kind": "modified",
+ "property": "name",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu~1{itemId}/get/parameters/0/name",
+ "value": "itemId",
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu~1{id}/get/parameters/1/name",
+ "value": "X-Token",
+ },
+ "impact": "patch",
+ "key": "#/paths/~1menu~1{itemId}/get/parameters/1",
+ "kind": "modified",
+ "property": "name",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu~1{itemId}/get/parameters/1/name",
+ "value": "x-token",
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts b/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts
new file mode 100644
index 0000000000..e90eeeca98
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts
@@ -0,0 +1,135 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (parameters: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /menu:
+ get:
+ parameters: ${parameters}
+ responses:
+ '200': { description: OK }
+ components:
+ parameters:
+ Sort: { name: sort, in: query, schema: { type: string } }
+ Category: { name: category, in: query, required: true, schema: { type: string } }
+`;
+
+describe('parameter-serialization-changed', () => {
+ it('should report a parameter put on the wire another way', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe(
+ '[{ name: category, in: query, style: form, explode: true, schema: { type: array } }]'
+ ),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe(
+ '[{ name: category, in: query, style: form, explode: false, schema: { type: array } }]'
+ ),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'parameter-serialization-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/parameters/0/explode",
+ "value": true,
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get/parameters/0",
+ "kind": "modified",
+ "property": "explode",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/parameters/0/explode",
+ "value": false,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1menu/get/parameters/0/explode",
+ "message": "\`explode\` of \`category\` query parameter changed from 'true' to 'false'.",
+ "ruleId": "parameter-serialization-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a serialization keyword that was set or removed', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe(
+ '[{ name: category, in: query, style: form, schema: { type: array } }, { name: sort, in: query, schema: { type: string } }]'
+ ),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe(
+ '[{ name: category, in: query, schema: { type: array } }, { name: sort, in: query, explode: false, schema: { type: string } }]'
+ ),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'parameter-serialization-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/parameters/0/style",
+ "value": "form",
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get/parameters/0",
+ "kind": "modified",
+ "property": "style",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/parameters/0",
+ "value": undefined,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1menu/get/parameters/0",
+ "message": "\`style\` of \`category\` query parameter was removed.",
+ "ruleId": "parameter-serialization-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/parameters/1",
+ "value": undefined,
+ },
+ "impact": "major",
+ "key": "#/paths/~1menu/get/parameters/1",
+ "kind": "modified",
+ "property": "explode",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu/get/parameters/1/explode",
+ "value": false,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1menu/get/parameters/1/explode",
+ "message": "\`explode\` of \`sort\` query parameter was set to 'false'.",
+ "ruleId": "parameter-serialization-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/path-removed.test.ts b/packages/core/src/diff/rules/__tests__/path-removed.test.ts
new file mode 100644
index 0000000000..ad6003c914
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/path-removed.test.ts
@@ -0,0 +1,496 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (paths: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths: ${paths}
+`;
+
+const menu = '/menu: { get: { responses: { 200: { description: OK } } } }';
+const revenue = '/revenue: { get: { responses: { 200: { description: OK } } } }';
+
+describe('path-removed', () => {
+ it('should report a path that is gone', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${menu}, ${revenue} }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(`{ ${menu} }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'path-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1revenue",
+ "value": {
+ "get": {
+ "responses": {
+ "200": {
+ "description": "OK",
+ },
+ },
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1revenue",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1revenue",
+ "message": "Path \`/revenue\` was removed.",
+ "ruleId": "path-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a renamed path, not one whose template names changed', async () => {
+ const renamed = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${menu} }`), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe('{ /menus: { get: { responses: { 200: { description: OK } } } } }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'path-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(renamed.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu",
+ "value": "/menu",
+ },
+ "impact": "major",
+ "key": "#/paths/~1menus",
+ "kind": "modified",
+ "property": "key",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menus",
+ "value": "/menus",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1menus",
+ "message": "Path \`/menu\` was renamed to \`/menus\`.",
+ "ruleId": "path-removed",
+ },
+ ],
+ },
+ ]
+ `);
+
+ const menuItem = (name: string) =>
+ `{ "/menu/{${name}}": { get: { responses: { 200: { description: OK } } } } }`;
+ const renamedTemplate = await diffDocuments({
+ base: makeDocumentFromString(cafe(menuItem('id')), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(menuItem('itemId')), 'revision.yaml'),
+ config: await createConfig({ diff: { 'path-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(renamedTemplate.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu~1{id}",
+ "value": "/menu/{id}",
+ },
+ "impact": "patch",
+ "key": "#/paths/~1menu~1{itemId}",
+ "kind": "modified",
+ "property": "key",
+ "revision": {
+ "location": "revision.yaml#/paths/~1menu~1{itemId}",
+ "value": "/menu/{itemId}",
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a removed webhook', async () => {
+ const withWebhooks = (webhooks: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths: { ${menu} }
+ webhooks: ${webhooks}
+ `;
+ const orderPlaced = 'orderPlaced: { post: { responses: { 200: { description: OK } } } }';
+ const orderReady = 'orderReady: { post: { responses: { 200: { description: OK } } } }';
+ const webhook = await diffDocuments({
+ base: makeDocumentFromString(withWebhooks(`{ ${orderPlaced}, ${orderReady} }`), 'base.yaml'),
+ revision: makeDocumentFromString(withWebhooks(`{ ${orderPlaced} }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'path-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(webhook.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/webhooks/orderReady",
+ "value": {
+ "post": {
+ "responses": {
+ "200": {
+ "description": "OK",
+ },
+ },
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/webhooks/orderReady",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/webhooks/orderReady",
+ "message": "Webhook \`orderReady\` was removed.",
+ "ruleId": "path-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a removed callback and a callback URL it no longer calls', async () => {
+ const withCallbacks = (callbacks: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ callbacks: ${callbacks}
+ responses: { 200: { description: OK } }
+ `;
+ const url = '"{$request.body#/url}": { post: { responses: { 200: { description: OK } } } }';
+ const other = '"{$request.body#/other}": { post: { responses: { 200: { description: OK } } } }';
+
+ const callback = await diffDocuments({
+ base: makeDocumentFromString(withCallbacks(`{ ready: { ${url} } }`), 'base.yaml'),
+ revision: makeDocumentFromString(withCallbacks('{}'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'path-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(callback.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/callbacks/ready",
+ "value": {
+ "{$request.body#/url}": {
+ "post": {
+ "responses": {
+ "200": {
+ "description": "OK",
+ },
+ },
+ },
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/callbacks/ready",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders/post/callbacks/ready",
+ "message": "Callback \`ready\` was removed.",
+ "ruleId": "path-removed",
+ },
+ ],
+ },
+ ]
+ `);
+
+ const callbackUrl = await diffDocuments({
+ base: makeDocumentFromString(withCallbacks(`{ ready: { ${url}, ${other} } }`), 'base.yaml'),
+ revision: makeDocumentFromString(withCallbacks(`{ ready: { ${url} } }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'path-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(callbackUrl.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1other}",
+ "value": {
+ "post": {
+ "responses": {
+ "200": {
+ "description": "OK",
+ },
+ },
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/callbacks/ready/{$request.body#~1other}",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1other}",
+ "message": "Callback \`ready\` no longer calls \`{$request.body#/other}\`.",
+ "ruleId": "path-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report an operation losing all its callbacks', async () => {
+ const withCallbacks = (callbacks: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ ${callbacks}
+ responses: { 200: { description: OK } }
+ `;
+ const url = '"{$request.body#/url}": { post: { responses: { 200: { description: OK } } } }';
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(withCallbacks(`callbacks: { ready: { ${url} } }`), 'base.yaml'),
+ revision: makeDocumentFromString(withCallbacks(''), 'revision.yaml'),
+ config: await createConfig({ diff: { 'path-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/callbacks",
+ "value": {
+ "ready": {
+ "{$request.body#/url}": {
+ "post": {
+ "responses": {
+ "200": {
+ "description": "OK",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/callbacks",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders/post/callbacks",
+ "message": "All callbacks were removed.",
+ "ruleId": "path-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report the document losing all its paths', async () => {
+ const withWebhooks = (paths: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ ${paths}
+ webhooks:
+ orderReady: { post: { responses: { 200: { description: OK } } } }
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(withWebhooks(`paths: { ${menu} }`), 'base.yaml'),
+ revision: makeDocumentFromString(withWebhooks(''), 'revision.yaml'),
+ config: await createConfig({ diff: { 'path-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths",
+ "value": {
+ "/menu": {
+ "get": {
+ "responses": {
+ "200": {
+ "description": "OK",
+ },
+ },
+ },
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/paths",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths",
+ "message": "All paths were removed.",
+ "ruleId": "path-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report the document losing all its webhooks', async () => {
+ const withWebhooks = (webhooks: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths: { ${menu} }
+ ${webhooks}
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ withWebhooks(
+ 'webhooks: { orderReady: { post: { responses: { 200: { description: OK } } } } }'
+ ),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(withWebhooks(''), 'revision.yaml'),
+ config: await createConfig({ diff: { 'path-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/webhooks",
+ "value": {
+ "orderReady": {
+ "post": {
+ "responses": {
+ "200": {
+ "description": "OK",
+ },
+ },
+ },
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/webhooks",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/webhooks",
+ "message": "All webhooks were removed.",
+ "ruleId": "path-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a callback of the components that nothing references', async () => {
+ const withComponentCallback = (callback: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths: {}
+ components:
+ callbacks:
+ Ready: ${callback}
+ `;
+ const url = (operations: string) => `"{$request.body#/url}": { ${operations} }`;
+ const post = (operationId: string) =>
+ `post: { operationId: ${operationId}, responses: { 200: { description: OK } } }`;
+ const put = 'put: { responses: { 200: { description: OK } } }';
+ const other = '"{$request.body#/other}": { post: { responses: { 200: { description: OK } } } }';
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ withComponentCallback(`{ ${url(`${post('notifyReady')}, ${put}`)}, ${other} }`),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ withComponentCallback(`{ ${url(post('readyCall'))} }`),
+ 'revision.yaml'
+ ),
+ config: await createConfig({
+ diff: {
+ 'path-removed': 'major',
+ 'operation-removed': 'major',
+ 'operation-id-changed': 'major',
+ },
+ }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/components/callbacks/Ready/{$request.body#~1url}/post/operationId",
+ "value": "notifyReady",
+ },
+ "impact": "patch",
+ "key": "#/components/callbacks/Ready/{$request.body#~1url}/post",
+ "kind": "modified",
+ "property": "operationId",
+ "revision": {
+ "location": "revision.yaml#/components/callbacks/Ready/{$request.body#~1url}/post/operationId",
+ "value": "readyCall",
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/components/callbacks/Ready/{$request.body#~1url}/put",
+ "value": {
+ "responses": {
+ "200": {
+ "description": "OK",
+ },
+ },
+ },
+ },
+ "impact": "patch",
+ "key": "#/components/callbacks/Ready/{$request.body#~1url}/put",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/components/callbacks/Ready/{$request.body#~1other}",
+ "value": {
+ "post": {
+ "responses": {
+ "200": {
+ "description": "OK",
+ },
+ },
+ },
+ },
+ },
+ "impact": "patch",
+ "key": "#/components/callbacks/Ready/{$request.body#~1other}",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/property-removed.test.ts b/packages/core/src/diff/rules/__tests__/property-removed.test.ts
new file mode 100644
index 0000000000..0b20c5fbfe
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/property-removed.test.ts
@@ -0,0 +1,396 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (order: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: ${order}
+ responses:
+ '201':
+ description: Created
+ content:
+ application/json:
+ schema: ${order}
+`;
+
+describe('property-removed', () => {
+ it('should report a property a response no longer sends, not one a request no longer takes', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe(
+ '{ type: object, properties: { menuItemId: { type: string }, note: { type: string } } }'
+ ),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('{ type: object, properties: { menuItemId: { type: string } } }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'property-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note",
+ "value": {
+ "type": "string",
+ },
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note",
+ "value": {
+ "type": "string",
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note",
+ "message": "Property \`note\` was removed.",
+ "ruleId": "property-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a oneOf alternative, which is not a property', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ oneOf: [{ type: string }, { type: integer }] }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(cafe('{ oneOf: [{ type: string }] }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'property-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1",
+ "value": {
+ "type": "integer",
+ },
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf/1",
+ "value": {
+ "type": "integer",
+ },
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf/1",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a writeOnly property, which a response never sends', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ properties: { pin: { type: string, writeOnly: true } } }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(cafe('{ properties: {} }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'property-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/pin",
+ "value": {
+ "type": "string",
+ "writeOnly": true,
+ },
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/pin",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/pin",
+ "value": {
+ "type": "string",
+ "writeOnly": true,
+ },
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/pin",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report every property leaving with the whole properties map', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ type: object, properties: { note: { type: string } } }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(cafe('{ type: object }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'property-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties",
+ "value": {
+ "note": {
+ "type": "string",
+ },
+ },
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties",
+ "value": {
+ "note": {
+ "type": "string",
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties",
+ "message": "All properties were removed.",
+ "ruleId": "property-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a property renamed in a response, not in a request', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ type: object, properties: { name: { type: string } } }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('{ type: object, properties: { names: { type: string } } }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'property-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/name",
+ "value": "name",
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/names",
+ "kind": "modified",
+ "property": "key",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/names",
+ "value": "names",
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/name",
+ "value": "name",
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/names",
+ "kind": "modified",
+ "property": "key",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/names",
+ "value": "names",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/names",
+ "message": "Property \`name\` was renamed to \`names\`.",
+ "ruleId": "property-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should name the owner of a removed property by the place of its $ref, not by its target', async () => {
+ const order = (item: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ get:
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Order' }
+ components:
+ schemas:
+ Order: { type: object, properties: { item: { $ref: '#/components/schemas/${item}' } } }
+ Item: { type: object, properties: { name: { type: string }, price: { type: number } } }
+ MenuItem: { type: object, properties: { name: { type: string } } }
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(order('Item'), 'base.yaml'),
+ revision: makeDocumentFromString(order('MenuItem'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'property-removed': 'major' } }),
+ });
+
+ expect(result.changes.flatMap((change) => change.verdicts)).toMatchObject([
+ { message: 'Property `price` of `item` was removed.' },
+ ]);
+ });
+
+ it('should not name the owner of a property written next to a $ref', async () => {
+ const order = (properties: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ get:
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Order', properties: ${properties} }
+ components:
+ schemas:
+ Order: { type: object }
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ order('{ id: { type: string }, extra: { type: string } }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(order('{ id: { type: string } }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'property-removed': 'major' } }),
+ });
+
+ expect(result.changes.flatMap((change) => change.verdicts)).toMatchObject([
+ { message: 'Property `extra` was removed.' },
+ ]);
+ });
+
+ it('should report a property a request no longer takes only where its schema rejects unlisted properties', async () => {
+ const order = (properties: string, extras: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: false
+ properties:
+ menuItemId: { type: string }
+ ${properties}
+ extras: { type: object, properties: ${extras} }
+ responses:
+ '201': { description: Created }
+ `;
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ order('note: { type: string }', '{ sugar: { type: boolean }, milk: { type: boolean } }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(order('', '{ sugar: { type: boolean } }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'property-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/extras/properties/milk",
+ "value": {
+ "type": "boolean",
+ },
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/extras/properties/milk",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note",
+ "value": {
+ "type": "string",
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note",
+ "message": "Property \`note\` was removed.",
+ "ruleId": "property-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts b/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts
new file mode 100644
index 0000000000..656971a384
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts
@@ -0,0 +1,84 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (required: boolean) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ requestBody:
+ required: ${required}
+ content: { application/json: { schema: { type: object } } }
+ responses:
+ '201': { description: Created }
+`;
+
+describe('request-body-became-required', () => {
+ it('should report an optional request body that became required', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(false), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(true), 'revision.yaml'),
+ config: await createConfig({ diff: { 'request-body-became-required': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/required",
+ "value": false,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/required",
+ "value": true,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/required",
+ "message": "Request body became required.",
+ "ruleId": "request-body-became-required",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a required request body that became optional', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(true), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(false), 'revision.yaml'),
+ config: await createConfig({ diff: { 'request-body-became-required': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/required",
+ "value": true,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/required",
+ "value": false,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts b/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts
new file mode 100644
index 0000000000..6d58b087e9
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts
@@ -0,0 +1,57 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (operation: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post: ${operation}
+`;
+
+const created = "responses: { '201': { description: Created } }";
+const order = 'requestBody: { content: { application/json: { schema: { type: object } } } }';
+
+describe('request-body-removed', () => {
+ it('should report a request body that is gone', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${order}, ${created} }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(`{ ${created} }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'request-body-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody",
+ "value": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ },
+ },
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders/post/requestBody",
+ "message": "Request body was removed.",
+ "ruleId": "request-body-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts b/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts
new file mode 100644
index 0000000000..4a77331bd4
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts
@@ -0,0 +1,286 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (order: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: ${order}
+ responses:
+ '201':
+ description: Created
+ content:
+ application/json:
+ schema: ${order}
+`;
+
+describe('required-properties-added', () => {
+ it('should report the properties a request must now send, not the ones a response now promises', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: object, required: [menuItemId] }'), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe('{ type: object, required: [menuItemId, quantity] }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'required-properties-added': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required",
+ "value": [
+ "menuItemId",
+ ],
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required",
+ "value": [
+ "menuItemId",
+ "quantity",
+ ],
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required",
+ "message": "Properties became required: \`quantity\`.",
+ "ruleId": "required-properties-added",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required",
+ "value": [
+ "menuItemId",
+ ],
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required",
+ "value": [
+ "menuItemId",
+ "quantity",
+ ],
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a readOnly property, which a request never sends', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ properties: { orderId: { type: string, readOnly: true } } }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('{ required: [orderId], properties: { orderId: { type: string, readOnly: true } } }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'required-properties-added': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required",
+ "value": [
+ "orderId",
+ ],
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required",
+ "value": [
+ "orderId",
+ ],
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a schema that became readOnly along with its own new required property', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe(
+ '{ type: object, properties: { note: { type: object, properties: { text: { type: string } } } } }'
+ ),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe(
+ '{ type: object, properties: { note: { type: object, readOnly: true, required: [text], properties: { text: { type: string } } } } }'
+ ),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'required-properties-added': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note",
+ "kind": "modified",
+ "property": "readOnly",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/readOnly",
+ "value": true,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/required",
+ "value": [
+ "text",
+ ],
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note",
+ "kind": "modified",
+ "property": "readOnly",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note/readOnly",
+ "value": true,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note/required",
+ "value": [
+ "text",
+ ],
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a $ref property named by the component key whose target is readOnly', async () => {
+ const cafeWithUser = (order: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: ${order}
+ responses:
+ '201':
+ description: Created
+ components:
+ schemas:
+ User: { type: object, readOnly: true }
+ `;
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafeWithUser(
+ '{ type: object, properties: { owner: { $ref: "#/components/schemas/User" } } }'
+ ),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafeWithUser(
+ '{ type: object, required: [owner], properties: { owner: { $ref: "#/components/schemas/User" } } }'
+ ),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'required-properties-added': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required",
+ "value": [
+ "owner",
+ ],
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts b/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts
new file mode 100644
index 0000000000..1b3cb4432d
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts
@@ -0,0 +1,226 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (order: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: ${order}
+ responses:
+ '201':
+ description: Created
+ content:
+ application/json:
+ schema: ${order}
+`;
+
+describe('required-properties-removed', () => {
+ it('should report the properties a response no longer promises, not the ones a request may now omit', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ type: object, required: [menuItemId, quantity] }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('{ type: object, required: [menuItemId] }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'required-properties-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required",
+ "value": [
+ "menuItemId",
+ "quantity",
+ ],
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required",
+ "value": [
+ "menuItemId",
+ ],
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required",
+ "value": [
+ "menuItemId",
+ "quantity",
+ ],
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required",
+ "value": [
+ "menuItemId",
+ ],
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required",
+ "message": "Properties are no longer required: \`quantity\`.",
+ "ruleId": "required-properties-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a writeOnly property, which a response never sends', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ required: [pin], properties: { pin: { type: string, writeOnly: true } } }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('{ properties: { pin: { type: string, writeOnly: true } } }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'required-properties-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required",
+ "value": [
+ "pin",
+ ],
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required",
+ "value": [
+ "pin",
+ ],
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a writeOnly property removed together with its required entry', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe(
+ '{ required: [secret], properties: { id: { type: string }, secret: { type: string, writeOnly: true } } }'
+ ),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('{ properties: { id: { type: string } } }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'required-properties-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required",
+ "value": [
+ "secret",
+ ],
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/secret",
+ "value": {
+ "type": "string",
+ "writeOnly": true,
+ },
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/secret",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required",
+ "value": [
+ "secret",
+ ],
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "required",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/secret",
+ "value": {
+ "type": "string",
+ "writeOnly": true,
+ },
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/secret",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts b/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts
new file mode 100644
index 0000000000..2373446db6
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts
@@ -0,0 +1,204 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (response: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ responses:
+ '201': ${response}
+`;
+
+const location = 'Location: { schema: { type: string } }';
+const rateLimit = 'X-Rate-Limit: { schema: { type: integer } }';
+
+describe('response-header-removed', () => {
+ it('should report a header that is gone', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe(`{ description: Created, headers: { ${location}, ${rateLimit} } }`),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe(`{ description: Created, headers: { ${location} } }`),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'response-header-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/headers/X-Rate-Limit",
+ "value": {
+ "schema": {
+ "type": "integer",
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/headers/X-Rate-Limit",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders/post/responses/201/headers/X-Rate-Limit",
+ "message": "Response header \`X-Rate-Limit\` was removed.",
+ "ruleId": "response-header-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report every header leaving with the whole map', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe(`{ description: Created, headers: { ${location} } }`),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(cafe('{ description: Created }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'response-header-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/headers",
+ "value": {
+ "Location": {
+ "schema": {
+ "type": "string",
+ },
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/headers",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders/post/responses/201/headers",
+ "message": "All headers of response \`201\` were removed.",
+ "ruleId": "response-header-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a renamed header, not one renamed only in case', async () => {
+ const withHeader = (name: string) =>
+ cafe(`{ description: Created, headers: { ${name}: { schema: { type: integer } } } }`);
+ const config = await createConfig({ diff: { 'response-header-removed': 'major' } });
+
+ const renamed = await diffDocuments({
+ base: makeDocumentFromString(withHeader('X-Rate'), 'base.yaml'),
+ revision: makeDocumentFromString(withHeader('X-RateLimit'), 'revision.yaml'),
+ config,
+ });
+ const recased = await diffDocuments({
+ base: makeDocumentFromString(withHeader('X-Rate'), 'base.yaml'),
+ revision: makeDocumentFromString(withHeader('x-rate'), 'revision.yaml'),
+ config,
+ });
+
+ expect(replaceSourceWithRefInChanges(renamed.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/headers/X-Rate",
+ "value": "X-Rate",
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/headers/X-RateLimit",
+ "kind": "modified",
+ "property": "key",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/headers/X-RateLimit",
+ "value": "X-RateLimit",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/headers/X-RateLimit",
+ "message": "Response header \`X-Rate\` was renamed to \`X-RateLimit\`.",
+ "ruleId": "response-header-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ expect(replaceSourceWithRefInChanges(recased.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/headers/X-Rate",
+ "value": "X-Rate",
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/headers/x-rate",
+ "kind": "modified",
+ "property": "key",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/headers/x-rate",
+ "value": "x-rate",
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a removed component header', async () => {
+ const withComponents = (headers: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ responses:
+ '201': { description: Created }
+ components:
+ headers: ${headers}
+ `;
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ withComponents('{ X-Rate: { schema: { type: integer } } }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(withComponents('{}'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'response-header-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/components/headers/X-Rate",
+ "value": {
+ "schema": {
+ "type": "integer",
+ },
+ },
+ },
+ "impact": "patch",
+ "key": "#/components/headers/X-Rate",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/response-removed.test.ts b/packages/core/src/diff/rules/__tests__/response-removed.test.ts
new file mode 100644
index 0000000000..1f49001167
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/response-removed.test.ts
@@ -0,0 +1,162 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges, resolverWithFiles } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString, type Document } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (responses: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders/{orderId}:
+ get:
+ parameters:
+ - { name: orderId, in: path, required: true, schema: { type: string } }
+ responses: ${responses}
+`;
+
+describe('response-removed', () => {
+ it('should report a response that is gone', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe("{ '200': { description: OK }, '404': { description: Not found } }"),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(cafe("{ '200': { description: OK } }"), 'revision.yaml'),
+ config: await createConfig({ diff: { 'response-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders~1{orderId}/get/responses/404",
+ "value": {
+ "description": "Not found",
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders~1{orderId}/get/responses/404",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders~1{orderId}/get/responses/404",
+ "message": "Response \`404\` was removed.",
+ "ruleId": "response-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a response that became another status code', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe("{ '200': { description: OK } }"), 'base.yaml'),
+ revision: makeDocumentFromString(cafe("{ '204': { description: OK } }"), 'revision.yaml'),
+ config: await createConfig({ diff: { 'response-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders~1{orderId}/get/responses/200",
+ "value": "200",
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders~1{orderId}/get/responses/204",
+ "kind": "modified",
+ "property": "key",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders~1{orderId}/get/responses/204",
+ "value": "204",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders~1{orderId}/get/responses/204",
+ "message": "Response \`200\` became \`204\`.",
+ "ruleId": "response-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a removed component response, which no operation returns', async () => {
+ const withComponents = (components: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ get:
+ responses:
+ '200': { description: OK }
+ components:
+ responses: ${components}
+ `;
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ withComponents('{ NotFound: { description: Not found } }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(withComponents('{}'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'response-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/components/responses/NotFound",
+ "value": {
+ "description": "Not found",
+ },
+ },
+ "impact": "patch",
+ "key": "#/components/responses/NotFound",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should name a removed response by its own code when it points at a response in another file', async () => {
+ const failed = "{ $ref: '#/components/responses/Failed' }";
+ const orders = (responses: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ responses: ${responses}
+ components:
+ responses:
+ Failed: { $ref: failed.yaml }
+ `;
+ const resolver = resolverWithFiles({
+ 'failed.yaml': 'description: Failed',
+ 'base.yaml': orders(`{ '400': ${failed}, '500': ${failed} }`),
+ 'revision.yaml': orders(`{ '400': ${failed} }`),
+ });
+ const [base, revision] = (await Promise.all(
+ ['base.yaml', 'revision.yaml'].map((file) => resolver.resolveDocument(null, file, true))
+ )) as Document[];
+
+ const result = await diffDocuments({
+ base,
+ revision,
+ config: await createConfig({ diff: { 'response-removed': 'major' } }),
+ externalRefResolver: resolver,
+ });
+
+ expect(
+ result.changes.flatMap(({ verdicts }) => verdicts.map(({ message }) => message))
+ ).toEqual(['Response `500` was removed.']);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts
new file mode 100644
index 0000000000..995a8ec814
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts
@@ -0,0 +1,190 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (payment: string, responsePayment = payment) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: ${payment}
+ responses:
+ '201':
+ description: Created
+ content:
+ application/json:
+ schema: ${responsePayment}
+`;
+
+describe('schema-combinator-changed', () => {
+ it('should report a oneOf alternative a request no longer accepts, not one a response stops sending', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ oneOf: [{ const: card }, { const: cash }] }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(cafe('{ oneOf: [{ const: card }] }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'schema-combinator-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1",
+ "value": {
+ "const": "cash",
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1",
+ "message": "\`oneOf\` subschema was removed.",
+ "ruleId": "schema-combinator-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf/1",
+ "value": {
+ "const": "cash",
+ },
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf/1",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report an allOf member a request must now satisfy, not one a response now satisfies', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ allOf: [{ required: [amount] }] }'), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe('{ allOf: [{ required: [amount] }, { required: [currency] }] }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-combinator-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf/1",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf/1",
+ "value": {
+ "required": [
+ "currency",
+ ],
+ },
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf/1",
+ "message": "\`allOf\` subschema was added.",
+ "ruleId": "schema-combinator-changed",
+ },
+ ],
+ },
+ {
+ "impact": "minor",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/allOf/1",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/allOf/1",
+ "value": {
+ "required": [
+ "currency",
+ ],
+ },
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a whole oneOf removed from a response and a whole allOf added to a request', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ type: object }', '{ type: string, oneOf: [{ const: card }, { const: cash }] }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('{ type: object, allOf: [{ required: [card] }] }', '{ type: string }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-combinator-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf",
+ "value": [
+ {
+ "required": [
+ "card",
+ ],
+ },
+ ],
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf",
+ "message": "\`allOf\` was added.",
+ "ruleId": "schema-combinator-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf",
+ "value": [
+ {
+ "const": "card",
+ },
+ {
+ "const": "cash",
+ },
+ ],
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf",
+ "message": "\`oneOf\` was removed.",
+ "ruleId": "schema-combinator-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/schema-constraint-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-constraint-changed.test.ts
new file mode 100644
index 0000000000..1d8df967b0
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/schema-constraint-changed.test.ts
@@ -0,0 +1,1272 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (requestSchema: string, responseSchema = requestSchema) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: ${requestSchema}
+ responses:
+ '201':
+ description: Created
+ content:
+ application/json:
+ schema: ${responseSchema}
+`;
+
+describe('schema-constraint-changed', () => {
+ it('should report a bound that makes a request accept less, not one that lets a response send less', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: integer, maximum: 20 }'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('{ type: integer, maximum: 10 }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum",
+ "value": 20,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "maximum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum",
+ "value": 10,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum",
+ "message": "Upper bound changed from '<= 20' to '<= 10'.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maximum",
+ "value": 20,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "maximum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maximum",
+ "value": 10,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a bound that lets a response send more, not one that makes a request accept more', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: integer, minimum: 1 }'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('{ type: integer, minimum: 0 }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum",
+ "value": 1,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "minimum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum",
+ "value": 0,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum",
+ "value": 1,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "minimum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum",
+ "value": 0,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum",
+ "message": "Lower bound changed from '>= 1' to '>= 0'.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report an OpenAPI 3.0 bound written the OpenAPI 3.1 way', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ type: number, minimum: 0, exclusiveMinimum: true }').replace('3.1.0', '3.0.3'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('{ type: number, exclusiveMinimum: 0 }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/openapi",
+ "value": "3.0.3",
+ },
+ "impact": "patch",
+ "key": "#/",
+ "kind": "modified",
+ "property": "openapi",
+ "revision": {
+ "location": "revision.yaml#/openapi",
+ "value": "3.1.0",
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum",
+ "value": 0,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "minimum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/exclusiveMinimum",
+ "value": true,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "exclusiveMinimum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/exclusiveMinimum",
+ "value": 0,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum",
+ "value": 0,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "minimum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/exclusiveMinimum",
+ "value": true,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "exclusiveMinimum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/exclusiveMinimum",
+ "value": 0,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a bound that became exclusive in a request', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: number, minimum: 0 }'), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe('{ type: number, exclusiveMinimum: 0 }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum",
+ "value": 0,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "minimum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "value": undefined,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "message": "Lower bound changed from '>= 0' to '> 0'.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "exclusiveMinimum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/exclusiveMinimum",
+ "value": 0,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum",
+ "value": 0,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "minimum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "exclusiveMinimum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/exclusiveMinimum",
+ "value": 0,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a length limit that makes a request accept less', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: string, maxLength: 200 }'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('{ type: string, maxLength: 100 }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength",
+ "value": 200,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "maxLength",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength",
+ "value": 100,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength",
+ "message": "\`maxLength\` changed from '200' to '100'.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxLength",
+ "value": 200,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "maxLength",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxLength",
+ "value": 100,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a pattern that changed in a response, which may now send values a client rejects', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe("{ type: string, pattern: '^[a-z]+$' }"), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe("{ type: string, pattern: '^[a-z ]+$' }"),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern",
+ "value": "^[a-z]+$",
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "pattern",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern",
+ "value": "^[a-z ]+$",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern",
+ "message": "\`pattern\` changed from '^[a-z]+$' to '^[a-z ]+$'.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/pattern",
+ "value": "^[a-z]+$",
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "pattern",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/pattern",
+ "value": "^[a-z ]+$",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/pattern",
+ "message": "\`pattern\` changed from '^[a-z]+$' to '^[a-z ]+$'.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a format added to a request, not to a response', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: string }'), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe('{ type: string, format: date-time }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "value": undefined,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "format",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/format",
+ "value": "date-time",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/format",
+ "message": "\`format\` was set to 'date-time'.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "format",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/format",
+ "value": "date-time",
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report closing an open request object, not an open response object', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ type: object, additionalProperties: true }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('{ type: object, additionalProperties: false }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties",
+ "value": true,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "additionalProperties",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties",
+ "value": false,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties",
+ "message": "\`additionalProperties\` changed from 'true' to 'false'.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties",
+ "value": true,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "additionalProperties",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties",
+ "value": false,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report opening a closed response object, not a closed request object', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ type: object, additionalProperties: false }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('{ type: object, additionalProperties: true }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties",
+ "value": false,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "additionalProperties",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties",
+ "value": true,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties",
+ "value": false,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "additionalProperties",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties",
+ "value": true,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties",
+ "message": "\`additionalProperties\` changed from 'false' to 'true'.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report an array limit that makes a request accept less, not one that lets a response send less', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: array, maxItems: 10 }'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('{ type: array, maxItems: 5 }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxItems",
+ "value": 10,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "maxItems",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxItems",
+ "value": 5,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxItems",
+ "message": "\`maxItems\` changed from '10' to '5'.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxItems",
+ "value": 10,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "maxItems",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxItems",
+ "value": 5,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report items that must now be unique in a request', async () => {
+ const config = await createConfig({ diff: { 'schema-constraint-changed': 'major' } });
+
+ const uniqueItemsRequired = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: array }'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('{ type: array, uniqueItems: true }'), 'revision.yaml'),
+ config,
+ });
+ const uniqueItemsDeclined = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: array }'), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe('{ type: array, uniqueItems: false }'),
+ 'revision.yaml'
+ ),
+ config,
+ });
+
+ expect(replaceSourceWithRefInChanges(uniqueItemsRequired.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "value": undefined,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "uniqueItems",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/uniqueItems",
+ "value": true,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/uniqueItems",
+ "message": "\`uniqueItems\` was set to 'true'.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "uniqueItems",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/uniqueItems",
+ "value": true,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ expect(replaceSourceWithRefInChanges(uniqueItemsDeclined.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "uniqueItems",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/uniqueItems",
+ "value": false,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "uniqueItems",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/uniqueItems",
+ "value": false,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a const that changed on both sides, since neither accepts the other value', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ const: small }'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('{ const: large }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/const",
+ "value": "small",
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "const",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/const",
+ "value": "large",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/const",
+ "message": "\`const\` changed from 'small' to 'large'.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/const",
+ "value": "small",
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "const",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/const",
+ "value": "large",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/const",
+ "message": "\`const\` changed from 'small' to 'large'.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a subschema that constrains a request, and its removal from a response', async () => {
+ const constrained = '{ type: object, not: { required: [secret] } }';
+ const config = await createConfig({ diff: { 'schema-constraint-changed': 'major' } });
+
+ const addedToRequest = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: object }', '{ type: object }'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(constrained, '{ type: object }'), 'revision.yaml'),
+ config,
+ });
+ const removedFromResponse = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: object }', constrained), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe('{ type: object }', '{ type: object }'),
+ 'revision.yaml'
+ ),
+ config,
+ });
+
+ expect(replaceSourceWithRefInChanges(addedToRequest.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/not",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/not",
+ "value": {
+ "required": [
+ "secret",
+ ],
+ },
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/not",
+ "message": "\`not\` was added.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ expect(replaceSourceWithRefInChanges(removedFromResponse.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/not",
+ "value": {
+ "required": [
+ "secret",
+ ],
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/not",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/not",
+ "message": "\`not\` was removed.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a tuple added to a request and dependent schemas removed from a response', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ type: array }', '{ type: object, dependentSchemas: { a: { required: [b] } } }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('{ type: array, prefixItems: [{ type: string }] }', '{ type: object }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/prefixItems",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/prefixItems",
+ "value": [
+ {
+ "type": "string",
+ },
+ ],
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/prefixItems",
+ "message": "\`prefixItems\` was added.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/dependentSchemas",
+ "value": {
+ "a": {
+ "required": [
+ "b",
+ ],
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/dependentSchemas",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/dependentSchemas",
+ "message": "\`dependentSchemas\` was removed.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a property named like a keyword', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ type: object, properties: {} }', '{ type: object }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('{ type: object, properties: { not: { type: string } } }', '{ type: object }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "impact": "minor",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/not",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/not",
+ "value": {
+ "type": "string",
+ },
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a closed object opened with a schema once, as accepting more', async () => {
+ const config = await createConfig({ diff: { 'schema-constraint-changed': 'major' } });
+ const closed = '{ type: object, unevaluatedProperties: false }';
+ const opened = '{ type: object, unevaluatedProperties: { type: string } }';
+
+ const openedInResponse = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: object }', closed), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('{ type: object }', opened), 'revision.yaml'),
+ config,
+ });
+ const openedInRequest = await diffDocuments({
+ base: makeDocumentFromString(cafe(closed, '{ type: object }'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(opened, '{ type: object }'), 'revision.yaml'),
+ config,
+ });
+
+ expect(replaceSourceWithRefInChanges(openedInResponse.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/unevaluatedProperties",
+ "value": false,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "unevaluatedProperties",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/unevaluatedProperties",
+ "value": undefined,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/unevaluatedProperties",
+ "message": "\`unevaluatedProperties\` was removed.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ {
+ "impact": "minor",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/unevaluatedProperties",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/unevaluatedProperties",
+ "value": {
+ "type": "string",
+ },
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ expect(replaceSourceWithRefInChanges(openedInRequest.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/unevaluatedProperties",
+ "value": false,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "unevaluatedProperties",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/unevaluatedProperties",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "impact": "minor",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/unevaluatedProperties",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/unevaluatedProperties",
+ "value": {
+ "type": "string",
+ },
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a lower bound set in a request and an upper bound removed in a response', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ type: integer }', '{ type: integer, maximum: 10 }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('{ type: integer, minimum: 1 }', '{ type: integer }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "value": undefined,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "minimum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum",
+ "value": 1,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum",
+ "message": "Lower bound was set to '>= 1'.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maximum",
+ "value": 10,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "maximum",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "value": undefined,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "message": "Upper bound was removed.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report dependent required properties added to a request', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: object }'), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe('{ type: object, dependentRequired: { a: [b] } }', '{ type: object }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/dependentRequired",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/dependentRequired",
+ "value": {
+ "a": [
+ "b",
+ ],
+ },
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/dependentRequired",
+ "message": "\`dependentRequired\` was added.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a schema that constrains the extra properties of an open request object', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: object }'), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe('{ type: object, additionalProperties: { type: string } }', '{ type: object }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties",
+ "value": {
+ "type": "string",
+ },
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties",
+ "message": "\`additionalProperties\` was added.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should judge a closed tuple opened with an items schema as accepting more', async () => {
+ const closed = '{ type: array, prefixItems: [{ type: string }], items: false }';
+ const opened = '{ type: array, prefixItems: [{ type: string }], items: { type: string } }';
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(closed), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(opened), 'revision.yaml'),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/items",
+ "value": false,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "items",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/items",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "impact": "minor",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/items",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/items",
+ "value": {
+ "type": "string",
+ },
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/items",
+ "value": false,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "items",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/items",
+ "value": undefined,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/items",
+ "message": "\`items\` was removed.",
+ "ruleId": "schema-constraint-changed",
+ },
+ ],
+ },
+ {
+ "impact": "minor",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/items",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/items",
+ "value": {
+ "type": "string",
+ },
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a lone if, which constrains nothing', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('{ type: object }'), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe('{ type: object, if: { required: [a] } }', '{ type: object }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "impact": "minor",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/if",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/if",
+ "value": {
+ "required": [
+ "a",
+ ],
+ },
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts
new file mode 100644
index 0000000000..ad794e6034
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts
@@ -0,0 +1,379 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (openapi: string, price: string) => outdent`
+ openapi: ${openapi}
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: ${price}
+ responses:
+ '201':
+ description: Created
+ content:
+ application/json:
+ schema: ${price}
+`;
+
+describe('schema-type-changed', () => {
+ it('should report a request that accepts fewer types, not a response that sends fewer', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('3.1.0', '{ type: number }'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('3.1.0', '{ type: integer }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'schema-type-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type",
+ "value": "number",
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "type",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type",
+ "value": "integer",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type",
+ "message": "Type narrowed from 'number' to 'integer'.",
+ "ruleId": "schema-type-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type",
+ "value": "number",
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "type",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type",
+ "value": "integer",
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a response that sends more types, not a request that accepts more', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('3.1.0', '{ type: integer }'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('3.1.0', '{ type: number }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'schema-type-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type",
+ "value": "integer",
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "type",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type",
+ "value": "number",
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type",
+ "value": "integer",
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "type",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type",
+ "value": "number",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type",
+ "message": "Type widened from 'integer' to 'number'.",
+ "ruleId": "schema-type-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a request that no longer accepts null in OpenAPI 3.0', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('3.0.3', '{ type: number, nullable: true }'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('3.0.3', '{ type: number }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'schema-type-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/nullable",
+ "value": true,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "nullable",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "value": undefined,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "message": "Type narrowed from 'number | null' to 'number'.",
+ "ruleId": "schema-type-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/nullable",
+ "value": true,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "nullable",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it("should read OpenAPI 3.0 `nullable: true` as the 3.1 `type: [..., 'null']`", async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('3.0.3', '{ type: number, nullable: true }'), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe('3.1.0', '{ type: [number, "null"] }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-type-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/openapi",
+ "value": "3.0.3",
+ },
+ "impact": "patch",
+ "key": "#/",
+ "kind": "modified",
+ "property": "openapi",
+ "revision": {
+ "location": "revision.yaml#/openapi",
+ "value": "3.1.0",
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type",
+ "value": "number",
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "type",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type",
+ "value": [
+ "number",
+ "null",
+ ],
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/nullable",
+ "value": true,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "nullable",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type",
+ "value": "number",
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "type",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type",
+ "value": [
+ "number",
+ "null",
+ ],
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/nullable",
+ "value": true,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "nullable",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a type that a schema without one now requires in a request', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('3.1.0', '{ description: Price }'), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe('3.1.0', '{ description: Price, type: number }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'schema-type-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "value": undefined,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "type",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type",
+ "value": "number",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type",
+ "message": "Type narrowed from 'any' to 'number'.",
+ "ruleId": "schema-type-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "type",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type",
+ "value": "number",
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a type that a response no longer keeps to', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('3.1.0', '{ description: Price, type: number }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(cafe('3.1.0', '{ description: Price }'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'schema-type-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type",
+ "value": "number",
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "kind": "modified",
+ "property": "type",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type",
+ "value": "number",
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "kind": "modified",
+ "property": "type",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "value": undefined,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+ "message": "Type widened from 'number' to 'any'.",
+ "ruleId": "schema-type-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/security-requirement-changed.test.ts b/packages/core/src/diff/rules/__tests__/security-requirement-changed.test.ts
new file mode 100644
index 0000000000..131b6b550a
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/security-requirement-changed.test.ts
@@ -0,0 +1,359 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (operation: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths:
+ /orders:
+ get: ${operation}
+ components:
+ securitySchemes:
+ ApiKey: { type: apiKey, in: header, name: X-Api-Key }
+ OAuth:
+ type: oauth2
+ flows:
+ clientCredentials:
+ tokenUrl: https://api.cafe.example/token
+ scopes: { orders:read: Read orders, orders:write: Write orders }
+`;
+
+const ok = 'responses: { 200: { description: OK } }';
+
+describe('security-requirement-changed', () => {
+ it('should report a security list that appears where there was none', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${ok} }`), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "impact": "major",
+ "key": "#/paths/~1orders/get/security",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/get/security",
+ "value": [
+ {
+ "ApiKey": [],
+ },
+ ],
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/get/security",
+ "message": "Authentication became required.",
+ "ruleId": "security-requirement-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report the first requirement filling a list that asked for none', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${ok}, security: [] }`), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "impact": "major",
+ "key": "#/paths/~1orders/get/security/0",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/get/security/0",
+ "value": {
+ "ApiKey": [],
+ },
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/get/security/0",
+ "message": "Authentication became required.",
+ "ruleId": "security-requirement-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report another way to authenticate', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe(`{ ${ok}, security: [{ ApiKey: [] }, { OAuth: [orders:read] }] }`),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "impact": "minor",
+ "key": "#/paths/~1orders/get/security/1",
+ "kind": "added",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/get/security/1",
+ "value": {
+ "OAuth": [
+ "orders:read",
+ ],
+ },
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report the scopes a requirement now asks for', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe(`{ ${ok}, security: [{ OAuth: [orders:read] }] }`),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe(`{ ${ok}, security: [{ OAuth: [orders:read, orders:write] }] }`),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/get/security/0/OAuth",
+ "value": [
+ "orders:read",
+ ],
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/get/security/0",
+ "kind": "modified",
+ "property": "OAuth",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth",
+ "value": [
+ "orders:read",
+ "orders:write",
+ ],
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth",
+ "message": "Security scheme \`OAuth\` requires new scopes: 'orders:write'.",
+ "ruleId": "security-requirement-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a requirement that now asks for one more scheme', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe(`{ ${ok}, security: [{ ApiKey: [], OAuth: [] }] }`),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/get/security/0",
+ "value": undefined,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/get/security/0",
+ "kind": "modified",
+ "property": "OAuth",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth",
+ "value": [],
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth",
+ "message": "Security requirement \`ApiKey\` now requires \`OAuth\`.",
+ "ruleId": "security-requirement-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a requirement that swapped its scheme', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe(`{ ${ok}, security: [{ OAuth: [] }] }`),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/get/security/0/ApiKey",
+ "value": [],
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/get/security/0",
+ "kind": "modified",
+ "property": "ApiKey",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/get/security/0",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/get/security/0",
+ "value": undefined,
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/get/security/0",
+ "kind": "modified",
+ "property": "OAuth",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth",
+ "value": [],
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth",
+ "message": "Security requirement \`ApiKey\` now requires \`OAuth\`.",
+ "ruleId": "security-requirement-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report an alternative that clients can no longer use', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe(`{ ${ok}, security: [{ ApiKey: [] }, { OAuth: [] }] }`),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe(`{ ${ok}, security: [{ OAuth: [] }] }`),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/get/security/0",
+ "value": {
+ "ApiKey": [],
+ },
+ },
+ "impact": "major",
+ "key": "#/paths/~1orders/get/security/0",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/paths/~1orders/get/security/0",
+ "message": "Security requirement \`ApiKey\` is no longer accepted.",
+ "ruleId": "security-requirement-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report a requirement that another one still covers, or a list that asks for none', async () => {
+ const covered = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe(`{ ${ok}, security: [{ ApiKey: [], OAuth: [] }] }`),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+ });
+ const optional = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(`{ ${ok}, security: [] }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(covered.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/get/security/0/OAuth",
+ "value": [],
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/get/security/0",
+ "kind": "modified",
+ "property": "OAuth",
+ "revision": {
+ "location": "revision.yaml#/paths/~1orders/get/security/0",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ expect(replaceSourceWithRefInChanges(optional.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1orders/get/security/0",
+ "value": {
+ "ApiKey": [],
+ },
+ },
+ "impact": "patch",
+ "key": "#/paths/~1orders/get/security/0",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts b/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts
new file mode 100644
index 0000000000..2428f7bc18
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts
@@ -0,0 +1,240 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (schemes: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ paths: {}
+ components:
+ securitySchemes: ${schemes}
+`;
+
+const apiKey = 'ApiKey: { type: apiKey, in: header, name: X-Api-Key }';
+const bearer = 'Bearer: { type: http, scheme: bearer }';
+
+describe('security-scheme-changed', () => {
+ it('should report a field clients authenticate with', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${apiKey} }`), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe('{ ApiKey: { type: apiKey, in: header, name: X-Cafe-Key } }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/components/securitySchemes/ApiKey/name",
+ "value": "X-Api-Key",
+ },
+ "impact": "major",
+ "key": "#/components/securitySchemes/ApiKey",
+ "kind": "modified",
+ "property": "name",
+ "revision": {
+ "location": "revision.yaml#/components/securitySchemes/ApiKey/name",
+ "value": "X-Cafe-Key",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/components/securitySchemes/ApiKey/name",
+ "message": "\`name\` of security scheme \`ApiKey\` changed from 'X-Api-Key' to 'X-Cafe-Key'.",
+ "ruleId": "security-scheme-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a scheme of another type once, not for every field it drags along', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${apiKey} }`), 'base.yaml'),
+ revision: makeDocumentFromString(
+ cafe('{ ApiKey: { type: http, scheme: bearer } }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/components/securitySchemes/ApiKey/type",
+ "value": "apiKey",
+ },
+ "impact": "major",
+ "key": "#/components/securitySchemes/ApiKey",
+ "kind": "modified",
+ "property": "type",
+ "revision": {
+ "location": "revision.yaml#/components/securitySchemes/ApiKey/type",
+ "value": "http",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/components/securitySchemes/ApiKey/type",
+ "message": "\`type\` of security scheme \`ApiKey\` changed from 'apiKey' to 'http'.",
+ "ruleId": "security-scheme-changed",
+ },
+ ],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/components/securitySchemes/ApiKey/in",
+ "value": "header",
+ },
+ "impact": "patch",
+ "key": "#/components/securitySchemes/ApiKey",
+ "kind": "modified",
+ "property": "in",
+ "revision": {
+ "location": "revision.yaml#/components/securitySchemes/ApiKey",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/components/securitySchemes/ApiKey/name",
+ "value": "X-Api-Key",
+ },
+ "impact": "patch",
+ "key": "#/components/securitySchemes/ApiKey",
+ "kind": "modified",
+ "property": "name",
+ "revision": {
+ "location": "revision.yaml#/components/securitySchemes/ApiKey",
+ "value": undefined,
+ },
+ "verdicts": [],
+ },
+ {
+ "base": {
+ "location": "base.yaml#/components/securitySchemes/ApiKey",
+ "value": undefined,
+ },
+ "impact": "patch",
+ "key": "#/components/securitySchemes/ApiKey",
+ "kind": "modified",
+ "property": "scheme",
+ "revision": {
+ "location": "revision.yaml#/components/securitySchemes/ApiKey/scheme",
+ "value": "bearer",
+ },
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a field that a scheme gains or loses', async () => {
+ const withFormat = 'Bearer: { type: http, scheme: bearer, bearerFormat: JWT }';
+ const gained = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${bearer} }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(`{ ${withFormat} }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }),
+ });
+ const lost = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${withFormat} }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(`{ ${bearer} }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(gained.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/components/securitySchemes/Bearer",
+ "value": undefined,
+ },
+ "impact": "major",
+ "key": "#/components/securitySchemes/Bearer",
+ "kind": "modified",
+ "property": "bearerFormat",
+ "revision": {
+ "location": "revision.yaml#/components/securitySchemes/Bearer/bearerFormat",
+ "value": "JWT",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/components/securitySchemes/Bearer/bearerFormat",
+ "message": "\`bearerFormat\` of security scheme \`Bearer\` was set to 'JWT'.",
+ "ruleId": "security-scheme-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ expect(replaceSourceWithRefInChanges(lost.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/components/securitySchemes/Bearer/bearerFormat",
+ "value": "JWT",
+ },
+ "impact": "major",
+ "key": "#/components/securitySchemes/Bearer",
+ "kind": "modified",
+ "property": "bearerFormat",
+ "revision": {
+ "location": "revision.yaml#/components/securitySchemes/Bearer",
+ "value": undefined,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/components/securitySchemes/Bearer",
+ "message": "\`bearerFormat\` of security scheme \`Bearer\` was removed.",
+ "ruleId": "security-scheme-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report a security scheme that is gone', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${apiKey}, ${bearer} }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(`{ ${apiKey} }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/components/securitySchemes/Bearer",
+ "value": {
+ "scheme": "bearer",
+ "type": "http",
+ },
+ },
+ "impact": "major",
+ "key": "#/components/securitySchemes/Bearer",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/components/securitySchemes/Bearer",
+ "message": "Security scheme \`Bearer\` was removed.",
+ "ruleId": "security-scheme-changed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/__tests__/server-removed.test.ts b/packages/core/src/diff/rules/__tests__/server-removed.test.ts
new file mode 100644
index 0000000000..b9862afa93
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/server-removed.test.ts
@@ -0,0 +1,376 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (servers?: string) => outdent`
+ asyncapi: 3.0.0
+ info: { title: Cafe kitchen, version: 1.0.0 }
+ ${servers === undefined ? '' : `servers: ${servers}`}
+ channels: {}
+`;
+
+const production = 'production: { host: broker.cafe.example, protocol: amqp }';
+const sandbox = 'sandbox: { host: sandbox.cafe.example, protocol: amqp }';
+
+describe('server-removed', () => {
+ it('should report a server that is gone', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${production}, ${sandbox} }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(`{ ${production} }`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'server-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/servers/sandbox",
+ "value": {
+ "host": "sandbox.cafe.example",
+ "protocol": "amqp",
+ },
+ },
+ "impact": "major",
+ "key": "#/servers/sandbox",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/servers/sandbox",
+ "message": "Server \`sandbox\` was removed.",
+ "ruleId": "server-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report every server leaving with the whole map', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`{ ${production} }`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(), 'revision.yaml'),
+ config: await createConfig({ diff: { 'server-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/servers",
+ "value": {
+ "production": {
+ "host": "broker.cafe.example",
+ "protocol": "amqp",
+ },
+ },
+ },
+ "impact": "major",
+ "key": "#/servers",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/servers",
+ "message": "All servers were removed.",
+ "ruleId": "server-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report an AsyncAPI server that moved to another host', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(
+ cafe('{ production: { host: kitchen.cafe.test, protocol: amqp } }'),
+ 'base.yaml'
+ ),
+ revision: makeDocumentFromString(
+ cafe('{ production: { host: kitchen.cafe.dev, protocol: amqp } }'),
+ 'revision.yaml'
+ ),
+ config: await createConfig({ diff: { 'server-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/servers/production/host",
+ "value": "kitchen.cafe.test",
+ },
+ "impact": "major",
+ "key": "#/servers/production",
+ "kind": "modified",
+ "property": "host",
+ "revision": {
+ "location": "revision.yaml#/servers/production/host",
+ "value": "kitchen.cafe.dev",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/servers/production/host",
+ "message": "\`host\` of server \`production\` changed from 'kitchen.cafe.test' to 'kitchen.cafe.dev'.",
+ "ruleId": "server-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report an AsyncAPI server that gained or lost its pathname', async () => {
+ const withoutPathname = '{ production: { host: broker.cafe.example, protocol: amqp } }';
+ const withPathname =
+ '{ production: { host: broker.cafe.example, pathname: /v1, protocol: amqp } }';
+ const config = await createConfig({ diff: { 'server-removed': 'major' } });
+
+ const gained = await diffDocuments({
+ base: makeDocumentFromString(cafe(withoutPathname), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(withPathname), 'revision.yaml'),
+ config,
+ });
+ const lost = await diffDocuments({
+ base: makeDocumentFromString(cafe(withPathname), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(withoutPathname), 'revision.yaml'),
+ config,
+ });
+
+ expect(replaceSourceWithRefInChanges(gained.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/servers/production",
+ "value": undefined,
+ },
+ "impact": "major",
+ "key": "#/servers/production",
+ "kind": "modified",
+ "property": "pathname",
+ "revision": {
+ "location": "revision.yaml#/servers/production/pathname",
+ "value": "/v1",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/servers/production/pathname",
+ "message": "\`pathname\` of server \`production\` was set to '/v1'.",
+ "ruleId": "server-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ expect(replaceSourceWithRefInChanges(lost.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/servers/production/pathname",
+ "value": "/v1",
+ },
+ "impact": "major",
+ "key": "#/servers/production",
+ "kind": "modified",
+ "property": "pathname",
+ "revision": {
+ "location": "revision.yaml#/servers/production",
+ "value": undefined,
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/servers/production",
+ "message": "\`pathname\` of server \`production\` was removed.",
+ "ruleId": "server-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report the components servers map', async () => {
+ const withComponents = (components: string) => outdent`
+ asyncapi: 3.0.0
+ info: { title: Cafe kitchen, version: 1.0.0 }
+ channels: {}
+ components: ${components}
+ `;
+
+ const result = await diffDocuments({
+ base: makeDocumentFromString(withComponents(`{ servers: { ${production} } }`), 'base.yaml'),
+ revision: makeDocumentFromString(withComponents('{}'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'server-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/components/servers",
+ "value": {
+ "production": {
+ "host": "broker.cafe.example",
+ "protocol": "amqp",
+ },
+ },
+ },
+ "impact": "patch",
+ "key": "#/components/servers",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+});
+
+describe('server-removed for OpenAPI', () => {
+ const cafe = (servers?: string, operationServers?: string) => outdent`
+ openapi: 3.1.0
+ info: { title: Cafe, version: 1.0.0 }
+ ${servers === undefined ? '' : `servers: ${servers}`}
+ paths:
+ /menu:
+ get:
+ ${operationServers === undefined ? '' : `servers: ${operationServers}`}
+ responses: { 200: { description: OK } }
+ `;
+ const production = '{ url: https://api.cafe.example }';
+ const sandbox = '{ url: https://sandbox.cafe.example }';
+
+ it('should report a server that is gone, whatever its position in the list', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`[${sandbox}, ${production}]`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(`[${production}]`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'server-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/servers/0",
+ "value": {
+ "url": "https://sandbox.cafe.example",
+ },
+ },
+ "impact": "major",
+ "key": "#/servers/0",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/servers/0",
+ "message": "Server \`https://sandbox.cafe.example\` was removed.",
+ "ruleId": "server-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should report the document losing its servers list', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`[${production}]`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(), 'revision.yaml'),
+ config: await createConfig({ diff: { 'server-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/servers",
+ "value": [
+ {
+ "url": "https://api.cafe.example",
+ },
+ ],
+ },
+ "impact": "major",
+ "key": "#/servers",
+ "kind": "removed",
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "base.yaml#/servers",
+ "message": "All servers were removed.",
+ "ruleId": "server-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+
+ it('should not report an operation losing its own servers, which falls back to the document', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe(`[${production}]`, `[${sandbox}]`), 'base.yaml'),
+ revision: makeDocumentFromString(cafe(`[${production}]`), 'revision.yaml'),
+ config: await createConfig({ diff: { 'server-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/paths/~1menu/get/servers",
+ "value": [
+ {
+ "url": "https://sandbox.cafe.example",
+ },
+ ],
+ },
+ "impact": "patch",
+ "key": "#/paths/~1menu/get/servers",
+ "kind": "removed",
+ "verdicts": [],
+ },
+ ]
+ `);
+ });
+
+ it('should report a server that moved to another URL', async () => {
+ const result = await diffDocuments({
+ base: makeDocumentFromString(cafe('[{ url: https://api.cafe.test }]'), 'base.yaml'),
+ revision: makeDocumentFromString(cafe('[{ url: https://api.cafe.dev }]'), 'revision.yaml'),
+ config: await createConfig({ diff: { 'server-removed': 'major' } }),
+ });
+
+ expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+ [
+ {
+ "base": {
+ "location": "base.yaml#/servers/0/url",
+ "value": "https://api.cafe.test",
+ },
+ "impact": "major",
+ "key": "#/servers/0",
+ "kind": "modified",
+ "property": "url",
+ "revision": {
+ "location": "revision.yaml#/servers/0/url",
+ "value": "https://api.cafe.dev",
+ },
+ "verdicts": [
+ {
+ "impact": "major",
+ "location": "revision.yaml#/servers/0/url",
+ "message": "Server \`https://api.cafe.test\` became \`https://api.cafe.dev\`.",
+ "ruleId": "server-removed",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/diff/rules/channel-address-changed.ts b/packages/core/src/diff/rules/channel-address-changed.ts
new file mode 100644
index 0000000000..d135619ac8
--- /dev/null
+++ b/packages/core/src/diff/rules/channel-address-changed.ts
@@ -0,0 +1,16 @@
+import { nameOf } from '../diff-node.js';
+import type { DiffRule } from '../types.js';
+
+export const ChannelAddressChanged: DiffRule = () => ({
+ Channel(change, { report }) {
+ if (change.kind !== 'modified' || change.property !== 'address') return;
+
+ const subject = `\`address\` of channel \`${nameOf(change.node)}\``;
+ const { value: before } = change.base;
+ const { value: after } = change.revision;
+
+ if (before === undefined) report({ message: `${subject} was set to '${after}'.` });
+ else if (after === undefined) report({ message: `${subject} was removed.` });
+ else report({ message: `${subject} changed from '${before}' to '${after}'.` });
+ },
+});
diff --git a/packages/core/src/diff/rules/channel-removed.ts b/packages/core/src/diff/rules/channel-removed.ts
new file mode 100644
index 0000000000..bc072d441b
--- /dev/null
+++ b/packages/core/src/diff/rules/channel-removed.ts
@@ -0,0 +1,16 @@
+import { nameOf } from '../diff-node.js';
+import type { DiffRule } from '../types.js';
+import { isRootField } from './utils.js';
+
+export const ChannelRemoved: DiffRule = () => ({
+ Channel(change, { report }) {
+ if (change.kind === 'removed') {
+ report({ message: `Channel \`${nameOf(change.node)}\` was removed.` });
+ }
+ },
+ NamedChannels(change, { report }) {
+ if (change.kind === 'removed' && isRootField(change.node)) {
+ report({ message: 'All channels were removed.' });
+ }
+ },
+});
diff --git a/packages/core/src/diff/rules/enum-values-added.ts b/packages/core/src/diff/rules/enum-values-added.ts
new file mode 100644
index 0000000000..8b7c245bbc
--- /dev/null
+++ b/packages/core/src/diff/rules/enum-values-added.ts
@@ -0,0 +1,26 @@
+import { itemsNotIn } from '../../utils/items-not-in.js';
+import type { DiffRule } from '../types.js';
+import { ofNamedSchema } from './utils.js';
+
+export const EnumValuesAdded: DiffRule = () => ({
+ Schema(change, { report, getDirections }) {
+ if (change.kind !== 'modified' || change.property !== 'enum') return;
+ if (!getDirections().includes('response')) return;
+
+ const { value: before } = change.base;
+ const { value: after } = change.revision;
+
+ if (before === undefined) return;
+
+ if (after === undefined) {
+ report({ message: `Enum${ofNamedSchema(change.node)} was removed.` });
+ return;
+ }
+
+ const added = itemsNotIn(after, before);
+ if (!added.length) return;
+
+ const values = added.map((value) => `'${value}'`).join(', ');
+ report({ message: `Enum${ofNamedSchema(change.node)} gained values: ${values}.` });
+ },
+});
diff --git a/packages/core/src/diff/rules/enum-values-removed.ts b/packages/core/src/diff/rules/enum-values-removed.ts
new file mode 100644
index 0000000000..bf86b12083
--- /dev/null
+++ b/packages/core/src/diff/rules/enum-values-removed.ts
@@ -0,0 +1,29 @@
+import { itemsNotIn } from '../../utils/items-not-in.js';
+import type { DiffRule } from '../types.js';
+import { ofNamedSchema } from './utils.js';
+
+export const EnumValuesRemoved: DiffRule = () => ({
+ Schema(change, { report, getDirections }) {
+ if (change.kind !== 'modified' || change.property !== 'enum') return;
+ if (!getDirections().includes('request')) return;
+
+ const { value: before } = change.base;
+ const { value: after } = change.revision;
+
+ if (after === undefined) return;
+
+ if (before === undefined) {
+ const listed = Array.isArray(after) ? after : [];
+ const values = listed.map((value) => `'${value}'`).join(', ');
+
+ report({ message: `Enum${ofNamedSchema(change.node)} was set to ${values}.` });
+ return;
+ }
+
+ const removed = itemsNotIn(before, after);
+ if (!removed.length) return;
+
+ const values = removed.map((value) => `'${value}'`).join(', ');
+ report({ message: `Enum${ofNamedSchema(change.node)} lost values: ${values}.` });
+ },
+});
diff --git a/packages/core/src/diff/rules/index.ts b/packages/core/src/diff/rules/index.ts
new file mode 100644
index 0000000000..4bd1f62b66
--- /dev/null
+++ b/packages/core/src/diff/rules/index.ts
@@ -0,0 +1,90 @@
+import type { DiffRule } from '../types.js';
+import { ChannelAddressChanged } from './channel-address-changed.js';
+import { ChannelRemoved } from './channel-removed.js';
+import { EnumValuesAdded } from './enum-values-added.js';
+import { EnumValuesRemoved } from './enum-values-removed.js';
+import { MediaTypeRemoved } from './media-type-removed.js';
+import { MessageContentTypeChanged } from './message-content-type-changed.js';
+import { MessageRemoved } from './message-removed.js';
+import { OperationActionChanged } from './operation-action-changed.js';
+import { OperationIdChanged } from './operation-id-changed.js';
+import { OperationRemoved } from './operation-removed.js';
+import { ParameterBecameRequired } from './parameter-became-required.js';
+import { ParameterRemoved } from './parameter-removed.js';
+import { ParameterSerializationChanged } from './parameter-serialization-changed.js';
+import { PathRemoved } from './path-removed.js';
+import { PropertyRemoved } from './property-removed.js';
+import { RequestBodyBecameRequired } from './request-body-became-required.js';
+import { RequestBodyRemoved } from './request-body-removed.js';
+import { RequiredPropertiesAdded } from './required-properties-added.js';
+import { RequiredPropertiesRemoved } from './required-properties-removed.js';
+import { ResponseHeaderRemoved } from './response-header-removed.js';
+import { ResponseRemoved } from './response-removed.js';
+import { SchemaCombinatorChanged } from './schema-combinator-changed.js';
+import { SchemaConstraintChanged } from './schema-constraint-changed.js';
+import { SchemaTypeChanged } from './schema-type-changed.js';
+import { SecurityRequirementChanged } from './security-requirement-changed.js';
+import { SecuritySchemeChanged } from './security-scheme-changed.js';
+import { ServerRemoved } from './server-removed.js';
+
+// An AsyncAPI 3 payload is a `Schema` node too.
+const schemaRules = {
+ 'schema-type-changed': SchemaTypeChanged,
+ 'enum-values-removed': EnumValuesRemoved,
+ 'enum-values-added': EnumValuesAdded,
+ 'required-properties-added': RequiredPropertiesAdded,
+ 'required-properties-removed': RequiredPropertiesRemoved,
+ 'property-removed': PropertyRemoved,
+ 'schema-constraint-changed': SchemaConstraintChanged,
+} satisfies Record;
+
+export const oas3Rules = {
+ 'operation-removed': OperationRemoved,
+ 'operation-id-changed': OperationIdChanged,
+ 'path-removed': PathRemoved,
+ 'parameter-removed': ParameterRemoved,
+ 'parameter-became-required': ParameterBecameRequired,
+ 'parameter-serialization-changed': ParameterSerializationChanged,
+ 'request-body-removed': RequestBodyRemoved,
+ 'request-body-became-required': RequestBodyBecameRequired,
+ 'response-removed': ResponseRemoved,
+ 'response-header-removed': ResponseHeaderRemoved,
+ 'media-type-removed': MediaTypeRemoved,
+ 'security-requirement-changed': SecurityRequirementChanged,
+ 'security-scheme-changed': SecuritySchemeChanged,
+ 'server-removed': ServerRemoved,
+ ...schemaRules,
+ 'schema-combinator-changed': SchemaCombinatorChanged,
+} satisfies Record;
+
+// No `schema-combinator-changed`: the JSON Schema type tree of AsyncAPI calls every combinator
+// list `SchemaList`, so the rule could not tell the keywords apart.
+export const async3Rules = {
+ 'channel-removed': ChannelRemoved,
+ 'channel-address-changed': ChannelAddressChanged,
+ 'message-removed': MessageRemoved,
+ 'message-content-type-changed': MessageContentTypeChanged,
+ 'operation-removed': OperationRemoved,
+ 'operation-action-changed': OperationActionChanged,
+ 'server-removed': ServerRemoved,
+ ...schemaRules,
+} satisfies Record;
+
+export const diffRuleSets = {
+ oas3: oas3Rules,
+ async3: async3Rules,
+};
+
+export type DiffRuleSets = typeof diffRuleSets;
+export type DiffFamily = keyof DiffRuleSets;
+export type DiffRuleId = {
+ [Family in DiffFamily]: keyof DiffRuleSets[Family] & string;
+}[DiffFamily];
+
+export function isDiffFamily(family: string): family is DiffFamily {
+ return Object.hasOwn(diffRuleSets, family);
+}
+
+export const diffRuleIds: string[] = [
+ ...new Set(Object.values(diffRuleSets).flatMap((rules) => Object.keys(rules))),
+];
diff --git a/packages/core/src/diff/rules/media-type-removed.ts b/packages/core/src/diff/rules/media-type-removed.ts
new file mode 100644
index 0000000000..c7a0c5d580
--- /dev/null
+++ b/packages/core/src/diff/rules/media-type-removed.ts
@@ -0,0 +1,23 @@
+import { nameOf } from '../diff-node.js';
+import type { DiffRule } from '../types.js';
+
+// A media type of a component nothing references travels neither way, so it breaks no client.
+export const MediaTypeRemoved: DiffRule = () => ({
+ MediaType(change, { report, getDirections }) {
+ if (getDirections().length === 0) return;
+
+ if (change.kind === 'removed') {
+ report({ message: `Media type \`${nameOf(change.node)}\` was removed.` });
+ }
+ if (change.kind === 'modified' && change.property === 'key') {
+ report({
+ message: `Media type \`${change.base.value}\` became \`${change.revision.value}\`.`,
+ });
+ }
+ },
+ MediaTypesMap(change, { report, getDirections }) {
+ if (change.kind === 'removed' && getDirections().length > 0) {
+ report({ message: 'All media types were removed.' });
+ }
+ },
+});
diff --git a/packages/core/src/diff/rules/message-content-type-changed.ts b/packages/core/src/diff/rules/message-content-type-changed.ts
new file mode 100644
index 0000000000..3f7142de7a
--- /dev/null
+++ b/packages/core/src/diff/rules/message-content-type-changed.ts
@@ -0,0 +1,16 @@
+import { nameOf } from '../diff-node.js';
+import type { DiffRule } from '../types.js';
+
+export const MessageContentTypeChanged: DiffRule = () => ({
+ Message(change, { report }) {
+ if (change.kind !== 'modified' || change.property !== 'contentType') return;
+
+ const subject = `\`contentType\` of message \`${nameOf(change.node)}\``;
+ const { value: before } = change.base;
+ const { value: after } = change.revision;
+
+ if (before === undefined) report({ message: `${subject} was set to '${after}'.` });
+ else if (after === undefined) report({ message: `${subject} was removed.` });
+ else report({ message: `${subject} changed from '${before}' to '${after}'.` });
+ },
+});
diff --git a/packages/core/src/diff/rules/message-removed.ts b/packages/core/src/diff/rules/message-removed.ts
new file mode 100644
index 0000000000..b9714f901f
--- /dev/null
+++ b/packages/core/src/diff/rules/message-removed.ts
@@ -0,0 +1,19 @@
+import { nameOf } from '../diff-node.js';
+import type { DiffRule } from '../types.js';
+
+export const MessageRemoved: DiffRule = () => ({
+ Message(change, { report }) {
+ if (change.kind === 'removed') {
+ report({ message: `Message \`${nameOf(change.node)}\` was removed.` });
+ }
+ },
+ Channel: {
+ NamedMessages(change, { report }) {
+ if (change.kind === 'removed') {
+ report({
+ message: `All messages of channel \`${nameOf(change.node.parent!)}\` were removed.`,
+ });
+ }
+ },
+ },
+});
diff --git a/packages/core/src/diff/rules/operation-action-changed.ts b/packages/core/src/diff/rules/operation-action-changed.ts
new file mode 100644
index 0000000000..7fc20b3e48
--- /dev/null
+++ b/packages/core/src/diff/rules/operation-action-changed.ts
@@ -0,0 +1,18 @@
+import { nameOf } from '../diff-node.js';
+import type { DiffRule } from '../types.js';
+
+// AsyncAPI only: an operation states its own direction, and swapping it turns every
+// message of the channel around.
+export const OperationActionChanged: DiffRule = () => ({
+ Operation(change, { report }) {
+ if (change.kind !== 'modified' || change.property !== 'action') return;
+
+ const subject = `\`action\` of operation \`${nameOf(change.node)}\``;
+ const { value: before } = change.base;
+ const { value: after } = change.revision;
+
+ if (before === undefined) report({ message: `${subject} was set to '${after}'.` });
+ else if (after === undefined) report({ message: `${subject} was removed.` });
+ else report({ message: `${subject} changed from '${before}' to '${after}'.` });
+ },
+});
diff --git a/packages/core/src/diff/rules/operation-id-changed.ts b/packages/core/src/diff/rules/operation-id-changed.ts
new file mode 100644
index 0000000000..ce575b77a7
--- /dev/null
+++ b/packages/core/src/diff/rules/operation-id-changed.ts
@@ -0,0 +1,24 @@
+import { nameOf } from '../diff-node.js';
+import type { DiffRule, DiffVisit } from '../types.js';
+
+const operationIdChanged: DiffVisit = (change, { report }) => {
+ if (change.kind !== 'modified' || change.property !== 'operationId') return;
+
+ const { value: before } = change.base;
+ const { value: after } = change.revision;
+ if (before === undefined) return;
+
+ const method = nameOf(change.node).toUpperCase();
+ const subject = `\`operationId\` of operation \`${method} ${nameOf(change.node.parent!)}\``;
+
+ if (after === undefined) report({ message: `${subject} was removed.` });
+ else report({ message: `${subject} changed from '${before}' to '${after}'.` });
+};
+
+// Generated clients name their methods after `operationId`, so a changed or removed one breaks the
+// code that calls them. A first `operationId` breaks nothing.
+export const OperationIdChanged: DiffRule = () => ({
+ Paths: { PathItem: { Operation: operationIdChanged } },
+ WebhooksMap: { PathItem: { Operation: operationIdChanged } },
+ CallbacksMap: { Callback: { PathItem: { Operation: operationIdChanged } } },
+});
diff --git a/packages/core/src/diff/rules/operation-removed.ts b/packages/core/src/diff/rules/operation-removed.ts
new file mode 100644
index 0000000000..96181446d5
--- /dev/null
+++ b/packages/core/src/diff/rules/operation-removed.ts
@@ -0,0 +1,31 @@
+import { nameOf } from '../diff-node.js';
+import type { DiffRule, DiffVisit } from '../types.js';
+import { isRootField } from './utils.js';
+
+// An OpenAPI operation is named by its method and its path, or the URL of its callback.
+const pathOperationRemoved: DiffVisit = (change, { report }) => {
+ if (change.kind !== 'removed') return;
+
+ const method = nameOf(change.node).toUpperCase();
+ report({ message: `Operation \`${method} ${nameOf(change.node.parent!)}\` was removed.` });
+};
+
+export const OperationRemoved: DiffRule = () => ({
+ Paths: { PathItem: { Operation: pathOperationRemoved } },
+ WebhooksMap: { PathItem: { Operation: pathOperationRemoved } },
+ CallbacksMap: { Callback: { PathItem: { Operation: pathOperationRemoved } } },
+ NamedOperations: {
+ Operation(change, { report }) {
+ if (change.kind === 'removed') {
+ report({ message: `Operation \`${nameOf(change.node)}\` was removed.` });
+ }
+ },
+ },
+ Root: {
+ NamedOperations(change, { report }) {
+ if (change.kind === 'removed' && isRootField(change.node)) {
+ report({ message: 'All operations were removed.' });
+ }
+ },
+ },
+});
diff --git a/packages/core/src/diff/rules/parameter-became-required.ts b/packages/core/src/diff/rules/parameter-became-required.ts
new file mode 100644
index 0000000000..a1b9f63aaf
--- /dev/null
+++ b/packages/core/src/diff/rules/parameter-became-required.ts
@@ -0,0 +1,42 @@
+import { fieldOf, locationOf } from '../../node-tree/access.js';
+import type { NodeEntry } from '../../node-tree/types.js';
+import type { Location } from '../../ref-utils.js';
+import { latestOf } from '../diff-node.js';
+import type { DiffRule } from '../types.js';
+
+// The first parameters of an operation arrive with the whole `parameters` list, so each required
+// one is reported on the list, at its own `required` field.
+export const ParameterBecameRequired: DiffRule = () => ({
+ Parameter(change, { report, getDirections }) {
+ if (!getDirections().includes('request')) return;
+
+ const parameter = latestOf(change.node);
+ const name = `\`${fieldOf(parameter, 'name')}\` ${fieldOf(parameter, 'in')} parameter`;
+
+ if (change.kind === 'added' && fieldOf(parameter, 'required') === true) {
+ report({ message: `Required ${name} was added.`, location: requiredFieldOf(parameter) });
+ }
+ if (
+ change.kind === 'modified' &&
+ change.property === 'required' &&
+ change.revision.value === true
+ ) {
+ report({ message: `${name} became required.` });
+ }
+ },
+ ParameterList(change, { report, getDirections }) {
+ if (change.kind !== 'added' || !getDirections().includes('request')) return;
+
+ for (const parameter of latestOf(change.node).children) {
+ if (fieldOf(parameter, 'required') !== true) continue;
+
+ const name = `\`${fieldOf(parameter, 'name')}\` ${fieldOf(parameter, 'in')} parameter`;
+ report({ message: `Required ${name} was added.`, location: requiredFieldOf(parameter) });
+ }
+ },
+});
+
+// A `$ref` parameter is required where it points.
+function requiredFieldOf(parameter: NodeEntry): Location {
+ return locationOf(parameter).child(['required']);
+}
diff --git a/packages/core/src/diff/rules/parameter-removed.ts b/packages/core/src/diff/rules/parameter-removed.ts
new file mode 100644
index 0000000000..70224559a0
--- /dev/null
+++ b/packages/core/src/diff/rules/parameter-removed.ts
@@ -0,0 +1,39 @@
+import { fieldOf } from '../../node-tree/access.js';
+import { latestOf } from '../diff-node.js';
+import type { DiffRule } from '../types.js';
+
+export const ParameterRemoved: DiffRule = () => ({
+ Parameter(change, { report, getDirections }) {
+ if (!getDirections().includes('request')) return;
+
+ if (change.kind === 'removed') {
+ const parameter = latestOf(change.node);
+ const name = `\`${fieldOf(parameter, 'name')}\` ${fieldOf(parameter, 'in')} parameter`;
+ report({ message: `${name} was removed.` });
+ }
+ if (change.kind !== 'modified') return;
+
+ // The client still sends the parameter as the base had it, so both reports name that one.
+ const oldName = fieldOf(change.node.base, 'name');
+ const oldIn = fieldOf(change.node.base, 'in');
+ const { value: before } = change.base;
+ const { value: after } = change.revision;
+
+ if (change.property === 'in') {
+ report({ message: `\`${oldName}\` ${before} parameter is now a ${after} parameter.` });
+ }
+
+ // A path parameter is named for the docs only, and a header name is not case-sensitive, so
+ // renaming those asks nothing of the client.
+ const caseOnly =
+ oldIn === 'header' && String(before).toLowerCase() === String(after).toLowerCase();
+ if (change.property === 'name' && oldIn !== 'path' && !caseOnly) {
+ report({ message: `\`${before}\` ${oldIn} parameter was renamed to \`${after}\`.` });
+ }
+ },
+ ParameterList(change, { report, getDirections }) {
+ if (change.kind === 'removed' && getDirections().includes('request')) {
+ report({ message: 'All parameters were removed.' });
+ }
+ },
+});
diff --git a/packages/core/src/diff/rules/parameter-serialization-changed.ts b/packages/core/src/diff/rules/parameter-serialization-changed.ts
new file mode 100644
index 0000000000..23599bc79e
--- /dev/null
+++ b/packages/core/src/diff/rules/parameter-serialization-changed.ts
@@ -0,0 +1,24 @@
+import { fieldOf } from '../../node-tree/access.js';
+import { latestOf } from '../diff-node.js';
+import type { DiffRule } from '../types.js';
+
+// How a value is put on the wire is part of the contract: a client that encoded
+// the old way is not understood after the change.
+const SERIALIZATION = new Set(['style', 'explode', 'allowReserved', 'allowEmptyValue']);
+
+export const ParameterSerializationChanged: DiffRule = () => ({
+ Parameter(change, { report, getDirections }) {
+ if (change.kind !== 'modified' || !SERIALIZATION.has(change.property)) return;
+ if (!getDirections().includes('request')) return;
+
+ const parameter = latestOf(change.node);
+ const name = `\`${fieldOf(parameter, 'name')}\` ${fieldOf(parameter, 'in')} parameter`;
+ const subject = `\`${change.property}\` of ${name}`;
+ const { value: before } = change.base;
+ const { value: after } = change.revision;
+
+ if (before === undefined) report({ message: `${subject} was set to '${after}'.` });
+ else if (after === undefined) report({ message: `${subject} was removed.` });
+ else report({ message: `${subject} changed from '${before}' to '${after}'.` });
+ },
+});
diff --git a/packages/core/src/diff/rules/path-removed.ts b/packages/core/src/diff/rules/path-removed.ts
new file mode 100644
index 0000000000..9d9683f285
--- /dev/null
+++ b/packages/core/src/diff/rules/path-removed.ts
@@ -0,0 +1,60 @@
+import { nameOf } from '../diff-node.js';
+import type { DiffRule } from '../types.js';
+
+// `/menu/{id}` and `/menu/{itemId}` are one URL: a template names its variables for the docs only.
+const templateShape = (path: unknown) => String(path).replace(/\{[^}]+\}/g, '{}');
+
+export const PathRemoved: DiffRule = () => ({
+ Paths: {
+ PathItem(change, { report }) {
+ if (change.kind === 'removed') {
+ report({ message: `Path \`${nameOf(change.node)}\` was removed.` });
+ }
+ if (change.kind !== 'modified' || change.property !== 'key') return;
+
+ const { value: before } = change.base;
+ const { value: after } = change.revision;
+ if (templateShape(before) !== templateShape(after)) {
+ report({ message: `Path \`${before}\` was renamed to \`${after}\`.` });
+ }
+ },
+ },
+ Root: {
+ Paths(change, { report }) {
+ if (change.kind === 'removed') report({ message: 'All paths were removed.' });
+ },
+ WebhooksMap(change, { report }) {
+ if (change.kind === 'removed') report({ message: 'All webhooks were removed.' });
+ },
+ },
+ // A webhook's key names it for the docs, so only its removal is reported.
+ WebhooksMap: {
+ PathItem(change, { report }) {
+ if (change.kind === 'removed') {
+ report({ message: `Webhook \`${nameOf(change.node)}\` was removed.` });
+ }
+ },
+ },
+ // A callback is the API calling the client back, judged like a webhook. The callbacks of
+ // `components` are reported where an operation uses them.
+ Operation: {
+ CallbacksMap(change, { report }) {
+ if (change.kind === 'removed') report({ message: 'All callbacks were removed.' });
+ },
+ Callback(change, { report }) {
+ if (change.kind === 'removed') {
+ report({ message: `Callback \`${nameOf(change.node)}\` was removed.` });
+ }
+ },
+ },
+ CallbacksMap: {
+ Callback: {
+ PathItem(change, { report }) {
+ if (change.kind !== 'removed') return;
+
+ const callback = nameOf(change.node.parent!);
+ report({ message: `Callback \`${callback}\` no longer calls \`${nameOf(change.node)}\`.` });
+ },
+ },
+ },
+});
diff --git a/packages/core/src/diff/rules/property-removed.ts b/packages/core/src/diff/rules/property-removed.ts
new file mode 100644
index 0000000000..4785a3ac0b
--- /dev/null
+++ b/packages/core/src/diff/rules/property-removed.ts
@@ -0,0 +1,49 @@
+import { fieldOf } from '../../node-tree/access.js';
+import { nameOf } from '../diff-node.js';
+import type { DiffNode, DiffRule, Direction } from '../types.js';
+import { ofNamedSchema } from './utils.js';
+
+export const PropertyRemoved: DiffRule = () => ({
+ SchemaProperties: {
+ Schema(change, { report, getDirections }) {
+ const objectSchema = change.node.parent?.parent;
+ const directions = getDirections();
+
+ if (!removalBreaksClients(objectSchema, directions)) return;
+
+ const owner = ofNamedSchema(objectSchema);
+
+ if (change.kind === 'removed') {
+ report({ message: `Property \`${nameOf(change.node)}\`${owner} was removed.` });
+ }
+ if (change.kind === 'modified' && change.property === 'key') {
+ const { value: before } = change.base;
+ const { value: after } = change.revision;
+ report({ message: `Property \`${before}\`${owner} was renamed to \`${after}\`.` });
+ }
+ },
+ },
+ Schema: {
+ SchemaProperties(change, { report, getDirections }) {
+ const objectSchema = change.node.parent;
+ const directions = getDirections();
+
+ if (change.kind === 'removed' && removalBreaksClients(objectSchema, directions)) {
+ report({ message: `All properties${ofNamedSchema(objectSchema)} were removed.` });
+ }
+ },
+ },
+});
+
+function removalBreaksClients(
+ objectSchema: DiffNode | null | undefined,
+ directions: Direction[]
+): boolean {
+ if (directions.includes('response')) return true;
+ if (!directions.includes('request')) return false;
+
+ return (
+ fieldOf(objectSchema?.revision, 'additionalProperties') === false ||
+ fieldOf(objectSchema?.revision, 'unevaluatedProperties') === false
+ );
+}
diff --git a/packages/core/src/diff/rules/request-body-became-required.ts b/packages/core/src/diff/rules/request-body-became-required.ts
new file mode 100644
index 0000000000..0bf7628d8e
--- /dev/null
+++ b/packages/core/src/diff/rules/request-body-became-required.ts
@@ -0,0 +1,10 @@
+import type { DiffRule } from '../types.js';
+
+export const RequestBodyBecameRequired: DiffRule = () => ({
+ RequestBody(change, { report, getDirections }) {
+ if (change.kind !== 'modified' || change.property !== 'required') return;
+ if (getDirections().includes('request') && change.revision.value === true) {
+ report({ message: 'Request body became required.' });
+ }
+ },
+});
diff --git a/packages/core/src/diff/rules/request-body-removed.ts b/packages/core/src/diff/rules/request-body-removed.ts
new file mode 100644
index 0000000000..03293ddee2
--- /dev/null
+++ b/packages/core/src/diff/rules/request-body-removed.ts
@@ -0,0 +1,9 @@
+import type { DiffRule } from '../types.js';
+
+export const RequestBodyRemoved: DiffRule = () => ({
+ RequestBody(change, { report, getDirections }) {
+ if (change.kind === 'removed' && getDirections().includes('request')) {
+ report({ message: 'Request body was removed.' });
+ }
+ },
+});
diff --git a/packages/core/src/diff/rules/required-properties-added.ts b/packages/core/src/diff/rules/required-properties-added.ts
new file mode 100644
index 0000000000..a80bf3e5f7
--- /dev/null
+++ b/packages/core/src/diff/rules/required-properties-added.ts
@@ -0,0 +1,20 @@
+import { itemsNotIn } from '../../utils/items-not-in.js';
+import type { DiffRule } from '../types.js';
+import { ofNamedSchema, propertiesMarkedAs } from './utils.js';
+
+export const RequiredPropertiesAdded: DiffRule = () => ({
+ Schema(change, { report, getDirections }) {
+ if (change.kind !== 'modified' || change.property !== 'required') return;
+ if (!getDirections().includes('request')) return;
+
+ // A request does not send a `readOnly` property, so requiring it asks nothing of the client.
+ const readOnly = propertiesMarkedAs(change.node, 'readOnly');
+ const added = itemsNotIn(change.revision.value, change.base.value).filter(
+ (name) => !readOnly.includes(String(name))
+ );
+ if (!added.length) return;
+
+ const names = added.map((name) => `\`${name}\``).join(', ');
+ report({ message: `Properties${ofNamedSchema(change.node)} became required: ${names}.` });
+ },
+});
diff --git a/packages/core/src/diff/rules/required-properties-removed.ts b/packages/core/src/diff/rules/required-properties-removed.ts
new file mode 100644
index 0000000000..b102f719ff
--- /dev/null
+++ b/packages/core/src/diff/rules/required-properties-removed.ts
@@ -0,0 +1,22 @@
+import { itemsNotIn } from '../../utils/items-not-in.js';
+import type { DiffRule } from '../types.js';
+import { ofNamedSchema, propertiesMarkedAs } from './utils.js';
+
+export const RequiredPropertiesRemoved: DiffRule = () => ({
+ Schema(change, { report, getDirections }) {
+ if (change.kind !== 'modified' || change.property !== 'required') return;
+ if (!getDirections().includes('response')) return;
+
+ // A response does not send a `writeOnly` property, so no client reads it.
+ const writeOnly = propertiesMarkedAs(change.node, 'writeOnly');
+ const removed = itemsNotIn(change.base.value, change.revision.value).filter(
+ (name) => !writeOnly.includes(String(name))
+ );
+ if (!removed.length) return;
+
+ const names = removed.map((name) => `\`${name}\``).join(', ');
+ report({
+ message: `Properties${ofNamedSchema(change.node)} are no longer required: ${names}.`,
+ });
+ },
+});
diff --git a/packages/core/src/diff/rules/response-header-removed.ts b/packages/core/src/diff/rules/response-header-removed.ts
new file mode 100644
index 0000000000..3882af2284
--- /dev/null
+++ b/packages/core/src/diff/rules/response-header-removed.ts
@@ -0,0 +1,29 @@
+import { nameOf } from '../diff-node.js';
+import type { DiffRule } from '../types.js';
+
+export const ResponseHeaderRemoved: DiffRule = () => ({
+ Response: {
+ Header(change, { report, getDirections }) {
+ if (!getDirections().includes('response')) return;
+
+ if (change.kind === 'removed') {
+ report({ message: `Response header \`${nameOf(change.node)}\` was removed.` });
+ }
+ if (change.kind !== 'modified' || change.property !== 'key') return;
+
+ // A header name is not case-sensitive.
+ const { value: before } = change.base;
+ const { value: after } = change.revision;
+ if (String(before).toLowerCase() !== String(after).toLowerCase()) {
+ report({ message: `Response header \`${before}\` was renamed to \`${after}\`.` });
+ }
+ },
+ HeadersMap(change, { report, getDirections }) {
+ if (change.kind === 'removed' && getDirections().includes('response')) {
+ report({
+ message: `All headers of response \`${nameOf(change.node.parent!)}\` were removed.`,
+ });
+ }
+ },
+ },
+});
diff --git a/packages/core/src/diff/rules/response-removed.ts b/packages/core/src/diff/rules/response-removed.ts
new file mode 100644
index 0000000000..761b020713
--- /dev/null
+++ b/packages/core/src/diff/rules/response-removed.ts
@@ -0,0 +1,18 @@
+import { nameOf } from '../diff-node.js';
+import type { DiffRule } from '../types.js';
+
+// A component response is returned by no operation until one references it.
+export const ResponseRemoved: DiffRule = () => ({
+ Responses: {
+ Response(change, { report }) {
+ if (change.kind === 'removed') {
+ report({ message: `Response \`${nameOf(change.node)}\` was removed.` });
+ }
+ if (change.kind === 'modified' && change.property === 'key') {
+ report({
+ message: `Response \`${change.base.value}\` became \`${change.revision.value}\`.`,
+ });
+ }
+ },
+ },
+});
diff --git a/packages/core/src/diff/rules/schema-combinator-changed.ts b/packages/core/src/diff/rules/schema-combinator-changed.ts
new file mode 100644
index 0000000000..6ea4750de1
--- /dev/null
+++ b/packages/core/src/diff/rules/schema-combinator-changed.ts
@@ -0,0 +1,38 @@
+import { nameOf } from '../diff-node.js';
+import type { DiffRule, DiffVisit } from '../types.js';
+import { ofNamedSchema } from './utils.js';
+
+// `oneOf`/`anyOf` list alternatives, so dropping one accepts less; `allOf` combines constraints,
+// so adding one accepts less.
+function combinatorChanged(combinator: 'allOf' | 'anyOf' | 'oneOf'): DiffVisit {
+ return (change, { report, getDirections }) => {
+ if (change.kind === 'modified') return;
+
+ const removed = change.kind === 'removed';
+ const acceptsLess = combinator === 'allOf' ? !removed : removed;
+ if (!getDirections().includes(acceptsLess ? 'request' : 'response')) return;
+
+ const owner = ofNamedSchema(change.node.parent?.parent);
+ report({
+ message: `\`${combinator}\` subschema${owner} was ${removed ? 'removed' : 'added'}.`,
+ });
+ };
+}
+
+// A whole list added constrains the schema, whichever keyword it is; removed, it frees it.
+const listChanged: DiffVisit = (change, { report, getDirections }) => {
+ if (change.kind === 'modified') return;
+
+ const added = change.kind === 'added';
+ if (!getDirections().includes(added ? 'request' : 'response')) return;
+
+ const owner = ofNamedSchema(change.node.parent);
+ report({ message: `\`${nameOf(change.node)}\`${owner} was ${added ? 'added' : 'removed'}.` });
+};
+
+export const SchemaCombinatorChanged: DiffRule = () => ({
+ Schema: { AllOf: listChanged, AnyOf: listChanged, OneOf: listChanged },
+ AllOf: { Schema: combinatorChanged('allOf') },
+ AnyOf: { Schema: combinatorChanged('anyOf') },
+ OneOf: { Schema: combinatorChanged('oneOf') },
+});
diff --git a/packages/core/src/diff/rules/schema-constraint-changed.ts b/packages/core/src/diff/rules/schema-constraint-changed.ts
new file mode 100644
index 0000000000..1ebd241fd4
--- /dev/null
+++ b/packages/core/src/diff/rules/schema-constraint-changed.ts
@@ -0,0 +1,202 @@
+import { fieldOf, keyKindOf } from '../../node-tree/access.js';
+import type { NodeEntry } from '../../node-tree/types.js';
+import { dequal } from '../../utils/dequal.js';
+import { latestOf } from '../diff-node.js';
+import type { Change, DiffRule, DiffRuleContext, DiffVisit, Direction } from '../types.js';
+import { ofNamedSchema } from './utils.js';
+
+type ModifiedChange = Extract;
+
+/** How a change moves the values a schema accepts; `unknown` where the two cannot be compared. */
+type Shift = 'fewer' | 'more' | 'unknown';
+
+const LOWER_LIMITS = new Set(['minLength', 'minItems', 'minProperties']);
+const UPPER_LIMITS = new Set(['maxLength', 'maxItems', 'maxProperties']);
+// Two values of these cannot be compared, so any change is taken as both fewer and more values.
+const OPAQUE = new Set([
+ 'multipleOf',
+ 'pattern',
+ 'format',
+ 'const',
+ 'contentEncoding',
+ 'contentMediaType',
+]);
+// Flags that constrain the values only when set to this value.
+const CONSTRAINING_FLAGS: Record = {
+ uniqueItems: true,
+ additionalProperties: false,
+ unevaluatedItems: false,
+ unevaluatedProperties: false,
+ items: false,
+};
+// `if` is not here: it constrains nothing on its own.
+const SUBSCHEMAS = new Set([
+ 'not',
+ 'then',
+ 'else',
+ 'contains',
+ 'propertyNames',
+ 'items',
+ 'additionalProperties',
+ 'unevaluatedItems',
+ 'unevaluatedProperties',
+ 'prefixItems',
+ 'dependentSchemas',
+ 'dependentRequired',
+]);
+
+type Bound = { value: number; exclusive: boolean };
+
+// `sign` makes a greater difference mean a tighter bound on both sides: a higher minimum and a
+// lower maximum each accept fewer values.
+const LOWER = {
+ name: 'Lower bound',
+ inclusive: 'minimum',
+ exclusive: 'exclusiveMinimum',
+ operator: '>',
+ sign: 1,
+};
+const UPPER = {
+ name: 'Upper bound',
+ inclusive: 'maximum',
+ exclusive: 'exclusiveMaximum',
+ operator: '<',
+ sign: -1,
+};
+type Side = typeof LOWER;
+
+// Accepting fewer values breaks the clients that send them; accepting more, the ones that read
+// them.
+function breaks(shift: Shift, directions: Direction[]): boolean {
+ return (
+ (shift !== 'more' && directions.includes('request')) ||
+ (shift !== 'fewer' && directions.includes('response'))
+ );
+}
+
+function shiftOf(keyword: string, before: unknown, after: unknown): Shift | undefined {
+ if (Object.hasOwn(CONSTRAINING_FLAGS, keyword)) {
+ const wasConstrained = before === CONSTRAINING_FLAGS[keyword];
+ const isConstrained = after === CONSTRAINING_FLAGS[keyword];
+ if (wasConstrained === isConstrained) return undefined;
+ return isConstrained ? 'fewer' : 'more';
+ }
+ if (!LOWER_LIMITS.has(keyword) && !UPPER_LIMITS.has(keyword) && !OPAQUE.has(keyword)) {
+ return undefined;
+ }
+ if (before === undefined) return 'fewer';
+ if (after === undefined) return 'more';
+ if (typeof before !== 'number' || typeof after !== 'number' || OPAQUE.has(keyword)) {
+ return 'unknown';
+ }
+ return LOWER_LIMITS.has(keyword) === after > before ? 'fewer' : 'more';
+}
+
+/** Above zero when `bound` accepts fewer values than `other`, below zero when it accepts more. */
+function compareTightness(bound: Bound, other: Bound, side: Side): number {
+ if (bound.value !== other.value) return (bound.value - other.value) * side.sign;
+ return Number(bound.exclusive) - Number(other.exclusive);
+}
+
+// OpenAPI 3.0 makes `minimum` exclusive with `exclusiveMinimum: true`; OpenAPI 3.1 gives
+// `exclusiveMinimum` a value of its own, and the tighter of the two keywords is the bound.
+function boundOf(schema: NodeEntry | undefined, side: Side): Bound | undefined {
+ const inclusive = fieldOf(schema, side.inclusive);
+ const exclusive = fieldOf(schema, side.exclusive);
+ if (typeof exclusive === 'number') {
+ const exclusiveBound = { value: exclusive, exclusive: true };
+ if (typeof inclusive !== 'number') return exclusiveBound;
+ const inclusiveBound = { value: inclusive, exclusive: false };
+ return compareTightness(inclusiveBound, exclusiveBound, side) > 0
+ ? inclusiveBound
+ : exclusiveBound;
+ }
+ if (typeof inclusive === 'number') return { value: inclusive, exclusive: exclusive === true };
+ return undefined;
+}
+
+/** Above zero when the side accepts fewer values after the change, below zero when more. */
+function tighteningOf(before: Bound | undefined, after: Bound | undefined, side: Side): number {
+ if (!before) return 1;
+ if (!after) return -1;
+ return compareTightness(after, before, side);
+}
+
+function describeBound(bound: Bound | undefined, side: Side): string | undefined {
+ return bound && `${side.operator}${bound.exclusive ? '' : '='} ${bound.value}`;
+}
+
+// A subschema constrains the values: added, the schema accepts fewer; removed, it accepts more.
+const judgeSubschema: DiffVisit = (change, { report, getDirections }) => {
+ const keyword = latestOf(change.node);
+ const key = String(keyword.key);
+ if (change.kind === 'modified' || keyKindOf(keyword) !== 'field' || !SUBSCHEMAS.has(key)) return;
+
+ const added = change.kind === 'added';
+ const parent = change.node.parent;
+ const other = added ? parent?.base : parent?.revision;
+ // A schema in place of `additionalProperties: false` accepts more; the flag's change reports it.
+ if (Object.hasOwn(CONSTRAINING_FLAGS, key) && fieldOf(other, key) === CONSTRAINING_FLAGS[key]) {
+ return;
+ }
+ if (!breaks(added ? 'fewer' : 'more', getDirections())) return;
+
+ report({ message: `\`${key}\`${ofNamedSchema(parent)} was ${added ? 'added' : 'removed'}.` });
+};
+
+function judgeKeyword(change: ModifiedChange, { report, getDirections }: DiffRuleContext): void {
+ const { value: before } = change.base;
+ const { value: after } = change.revision;
+ const shift = shiftOf(change.property, before, after);
+ if (!shift || !breaks(shift, getDirections())) return;
+
+ const subject = `\`${change.property}\`${ofNamedSchema(change.node)}`;
+ if (before === undefined) report({ message: `${subject} was set to '${after}'.` });
+ else if (after === undefined) report({ message: `${subject} was removed.` });
+ else report({ message: `${subject} changed from '${before}' to '${after}'.` });
+}
+
+// Both keywords of a side can change together, so the side is judged once: by its inclusive
+// keyword when that one changed.
+function judgeBound(
+ change: ModifiedChange,
+ side: Side,
+ { report, getDirections }: DiffRuleContext
+): void {
+ const { base, revision } = change.node;
+ const inclusiveChanged = !dequal(
+ fieldOf(base, side.inclusive),
+ fieldOf(revision, side.inclusive)
+ );
+ if (change.property === side.exclusive && inclusiveChanged) return;
+
+ const beforeBound = boundOf(base, side);
+ const afterBound = boundOf(revision, side);
+ if (!beforeBound && !afterBound) return;
+
+ const tightening = tighteningOf(beforeBound, afterBound, side);
+ if (tightening === 0 || !breaks(tightening > 0 ? 'fewer' : 'more', getDirections())) return;
+
+ const subject = `${side.name}${ofNamedSchema(change.node)}`;
+ const before = describeBound(beforeBound, side);
+ const after = describeBound(afterBound, side);
+
+ if (before === undefined) report({ message: `${subject} was set to '${after}'.` });
+ else if (after === undefined) report({ message: `${subject} was removed.` });
+ else report({ message: `${subject} changed from '${before}' to '${after}'.` });
+}
+
+export const SchemaConstraintChanged: DiffRule = () => ({
+ Schema(change, context) {
+ if (change.kind !== 'modified') return judgeSubschema(change, context);
+
+ const side = [LOWER, UPPER].find(
+ ({ inclusive, exclusive }) => change.property === inclusive || change.property === exclusive
+ );
+ if (side) judgeBound(change, side, context);
+ else judgeKeyword(change, context);
+ },
+ SchemaList: judgeSubschema,
+ SchemaMap: judgeSubschema,
+ DependentRequired: judgeSubschema,
+});
diff --git a/packages/core/src/diff/rules/schema-type-changed.ts b/packages/core/src/diff/rules/schema-type-changed.ts
new file mode 100644
index 0000000000..cf0d6b9524
--- /dev/null
+++ b/packages/core/src/diff/rules/schema-type-changed.ts
@@ -0,0 +1,53 @@
+import { fieldOf } from '../../node-tree/access.js';
+import { dequal } from '../../utils/dequal.js';
+import type { DiffRule } from '../types.js';
+import { ofNamedSchema } from './utils.js';
+
+// `integer` accepts a subset of what `number` does, so it is the one implicit widening among
+// the JSON Schema primitive types.
+const WIDER_TYPE: Partial> = { integer: 'number' };
+
+/**
+ * The types a schema accepts, folding OpenAPI 3.0's `nullable: true` into the 3.1 spelling
+ * (`type: [..., 'null']`) so the two compare as equal.
+ */
+function acceptedTypes(type: unknown, nullable?: unknown): string[] {
+ const declared = Array.isArray(type) ? type : type === undefined ? [] : [type];
+ const types = declared.filter((value): value is string => typeof value === 'string');
+ // Without a `type`, a schema already accepts `null` with every other type.
+ const addsNull = nullable === true && types.length > 0 && !types.includes('null');
+ return addsNull ? [...types, 'null'] : types;
+}
+
+function typesNotIn(types: string[], accepted: string[]): string[] {
+ return types.filter((type) => {
+ const wider = WIDER_TYPE[type];
+ return !accepted.includes(type) && (wider === undefined || !accepted.includes(wider));
+ });
+}
+
+export const SchemaTypeChanged: DiffRule = () => ({
+ Schema(change, { report, getDirections }) {
+ if (change.kind !== 'modified') return;
+
+ const { base, revision } = change.node;
+ // A change to `type` or `nullable` is judged on what the schema accepts as a whole, and once:
+ // by `type` when both changed.
+ const typeChanged = !dequal(fieldOf(base, 'type'), fieldOf(revision, 'type'));
+ if (change.property !== 'type' && (change.property !== 'nullable' || typeChanged)) return;
+
+ const before = acceptedTypes(fieldOf(base, 'type'), fieldOf(base, 'nullable'));
+ const after = acceptedTypes(fieldOf(revision, 'type'), fieldOf(revision, 'nullable'));
+ // An absent `type` accepts every type: adding one narrows the schema, and removing one widens it.
+ const narrowed = after.length > 0 && (!before.length || typesNotIn(before, after).length > 0);
+ const widened = before.length > 0 && (!after.length || typesNotIn(after, before).length > 0);
+ const described = `from '${before.join(' | ') || 'any'}' to '${after.join(' | ') || 'any'}'`;
+
+ if (getDirections().includes('request') && narrowed) {
+ report({ message: `Type${ofNamedSchema(change.node)} narrowed ${described}.` });
+ }
+ if (getDirections().includes('response') && widened) {
+ report({ message: `Type${ofNamedSchema(change.node)} widened ${described}.` });
+ }
+ },
+});
diff --git a/packages/core/src/diff/rules/security-requirement-changed.ts b/packages/core/src/diff/rules/security-requirement-changed.ts
new file mode 100644
index 0000000000..2b999380a5
--- /dev/null
+++ b/packages/core/src/diff/rules/security-requirement-changed.ts
@@ -0,0 +1,67 @@
+import { valueOf } from '../../node-tree/access.js';
+import { isPlainObject } from '../../utils/is-plain-object.js';
+import { itemsNotIn } from '../../utils/items-not-in.js';
+import type { DiffRule } from '../types.js';
+
+/** The schemes a client authenticates with, each with the scopes that its credentials hold. */
+type Credentials = Record;
+
+// A client passes a requirement when it has every scheme of it, with every scope that the scheme
+// asks for. An empty list, or a missing one, asks for no authentication.
+function passesOne(credentials: Credentials, requirements: unknown): boolean {
+ if (!Array.isArray(requirements) || requirements.length === 0) return true;
+ return requirements.some(
+ (requirement) =>
+ isPlainObject(requirement) &&
+ Object.entries(requirement).every(
+ ([scheme, scopes]) =>
+ scheme in credentials && itemsNotIn(scopes, credentials[scheme]).length === 0
+ )
+ );
+}
+
+// Security sits outside the request/response split, so the rule does not read the direction.
+// Each change asks one question: do the clients that authenticated before still pass one of the
+// requirements of the revision?
+export const SecurityRequirementChanged: DiffRule = () => ({
+ SecurityRequirementList(change, { report }) {
+ if (change.kind === 'added' && !passesOne({}, change.revision.value)) {
+ report({ message: 'Authentication became required.' });
+ }
+ },
+ SecurityRequirement(change, { report }) {
+ const list = change.node.parent;
+ const requirements = list?.revision && valueOf(list.revision);
+
+ if (change.kind === 'added') {
+ // Before, the list was empty and asked for no authentication; otherwise the new
+ // requirement only offers one more way to pass.
+ const before = list?.base && valueOf(list.base);
+ if (Array.isArray(before) && before.length === 0 && !passesOne({}, requirements)) {
+ report({ message: 'Authentication became required.' });
+ }
+ return;
+ }
+
+ const credentials = change.node.base && valueOf(change.node.base);
+ if (!isPlainObject(credentials) || passesOne(credentials, requirements)) return;
+
+ const schemes = Object.keys(credentials).join(' + ');
+ if (change.kind === 'removed') {
+ report({ message: `Security requirement \`${schemes}\` is no longer accepted.` });
+ } else if (change.base.value === undefined) {
+ // A scheme the requirement did not have: a client must now hold it too.
+ report({
+ message: `Security requirement \`${schemes}\` now requires \`${change.property}\`.`,
+ });
+ } else {
+ // The requirement keeps this scheme: its value is the list of scopes.
+ const added = itemsNotIn(change.revision.value, change.base.value);
+ if (!added.length) return;
+ const scopes = added.map((scope) => `'${scope}'`).join(', ');
+ report({
+ message: `Security scheme \`${change.property}\` requires new scopes: ${scopes}.`,
+ });
+ }
+ },
+});
diff --git a/packages/core/src/diff/rules/security-scheme-changed.ts b/packages/core/src/diff/rules/security-scheme-changed.ts
new file mode 100644
index 0000000000..22efbd240f
--- /dev/null
+++ b/packages/core/src/diff/rules/security-scheme-changed.ts
@@ -0,0 +1,34 @@
+import { fieldOf } from '../../node-tree/access.js';
+import { nameOf } from '../diff-node.js';
+import type { DiffRule } from '../types.js';
+
+const SCHEME_IDENTITY = new Set([
+ 'type',
+ 'scheme',
+ 'in',
+ 'name',
+ 'bearerFormat',
+ 'openIdConnectUrl',
+]);
+
+export const SecuritySchemeChanged: DiffRule = () => ({
+ SecurityScheme(change, { report }) {
+ if (change.kind === 'removed') {
+ report({ message: `Security scheme \`${nameOf(change.node)}\` was removed.` });
+ }
+ if (change.kind !== 'modified' || !SCHEME_IDENTITY.has(change.property)) return;
+
+ // Switching the scheme's `type` drags its other fields along (an apiKey has
+ // `in`/`name`, a bearer has `scheme`), so the type change speaks for them all.
+ const typeChanged = fieldOf(change.node.base, 'type') !== fieldOf(change.node.revision, 'type');
+ if (typeChanged && change.property !== 'type') return;
+
+ const subject = `\`${change.property}\` of security scheme \`${nameOf(change.node)}\``;
+ const { value: before } = change.base;
+ const { value: after } = change.revision;
+
+ if (before === undefined) report({ message: `${subject} was set to '${after}'.` });
+ else if (after === undefined) report({ message: `${subject} was removed.` });
+ else report({ message: `${subject} changed from '${before}' to '${after}'.` });
+ },
+});
diff --git a/packages/core/src/diff/rules/server-removed.ts b/packages/core/src/diff/rules/server-removed.ts
new file mode 100644
index 0000000000..f61491ae90
--- /dev/null
+++ b/packages/core/src/diff/rules/server-removed.ts
@@ -0,0 +1,43 @@
+import { fieldOf } from '../../node-tree/access.js';
+import { latestOf, nameOf } from '../diff-node.js';
+import type { DiffRule, DiffVisit } from '../types.js';
+import { isRootField } from './utils.js';
+
+const ADDRESS = new Set(['host', 'pathname', 'protocol']);
+
+// An OpenAPI path or operation that loses its own `servers` falls back to the document's, so only
+// the document's list counts.
+const allServersRemoved: DiffVisit = (change, { report }) => {
+ if (change.kind === 'removed' && isRootField(change.node)) {
+ report({ message: 'All servers were removed.' });
+ }
+};
+
+export const ServerRemoved: DiffRule = () => ({
+ Server(change, { report }) {
+ if (change.kind === 'removed') {
+ const url = fieldOf(latestOf(change.node), 'url');
+ const name = typeof url === 'string' ? url : nameOf(change.node);
+ report({ message: `Server \`${name}\` was removed.` });
+ }
+ if (change.kind !== 'modified') return;
+
+ // An OpenAPI server is known by its URL; an AsyncAPI one by its key, and it moves with its
+ // `host`, `pathname`, or `protocol`.
+ const { value: before } = change.base;
+ const { value: after } = change.revision;
+
+ if (change.property === 'url') {
+ report({ message: `Server \`${before}\` became \`${after}\`.` });
+ }
+ if (ADDRESS.has(change.property)) {
+ const subject = `\`${change.property}\` of server \`${nameOf(change.node)}\``;
+
+ if (before === undefined) report({ message: `${subject} was set to '${after}'.` });
+ else if (after === undefined) report({ message: `${subject} was removed.` });
+ else report({ message: `${subject} changed from '${before}' to '${after}'.` });
+ }
+ },
+ ServerMap: allServersRemoved,
+ ServerList: allServersRemoved,
+});
diff --git a/packages/core/src/diff/rules/utils.ts b/packages/core/src/diff/rules/utils.ts
new file mode 100644
index 0000000000..d32e1ca328
--- /dev/null
+++ b/packages/core/src/diff/rules/utils.ts
@@ -0,0 +1,34 @@
+import { fieldOf } from '../../node-tree/access.js';
+import type { NodeEntry } from '../../node-tree/types.js';
+import { comparedSidesOf, latestOf, nameOf, typeOf } from '../diff-node.js';
+import type { DiffNode } from '../types.js';
+
+/** `` of `Order` `` for a property or a component schema; nothing for an inline one. */
+export function ofNamedSchema(schema: DiffNode | null | undefined): string {
+ if (!schema) return '';
+
+ const container = latestOf(comparedSidesOf(schema)).parent?.type.name;
+ const isNamed = container === 'SchemaProperties' || container === 'NamedSchemas';
+ return isNamed ? ` of \`${nameOf(schema)}\`` : '';
+}
+
+export function propertiesMarkedAs(schema: DiffNode, keyword: 'readOnly' | 'writeOnly'): string[] {
+ const latestByName = new Map();
+ const sides = comparedSidesOf(schema);
+
+ for (const side of [sides.base, sides.revision]) {
+ const properties = side?.children.find((child) => child.type.name === 'SchemaProperties');
+ for (const property of properties?.children ?? []) {
+ latestByName.set(String(property.key), property);
+ }
+ }
+
+ return [...latestByName.keys()].filter(
+ (name) => fieldOf(latestByName.get(name), keyword) === true
+ );
+}
+
+// `components` has maps of the same types as the document; only the document's own count.
+export function isRootField(node: DiffNode): boolean {
+ return node.parent !== null && typeOf(node.parent) === 'Root';
+}
diff --git a/packages/core/src/diff/types.ts b/packages/core/src/diff/types.ts
new file mode 100644
index 0000000000..d0d51c3f83
--- /dev/null
+++ b/packages/core/src/diff/types.ts
@@ -0,0 +1,68 @@
+import type { NodeEntry } from '../node-tree/types.js';
+import type { SpecVersion } from '../oas-types.js';
+import type { Location } from '../ref-utils.js';
+import type { NormalizedProblem } from '../walk.js';
+
+export type Impact = 'patch' | 'minor' | 'major';
+
+export type Direction = 'request' | 'response';
+
+export type Pair = { base?: NodeEntry; revision?: NodeEntry };
+
+/** One place in both documents; `base` or `revision` is missing where it was added or removed. */
+export type DiffNode = {
+ base?: NodeEntry;
+ revision?: NodeEntry;
+ parent: DiffNode | null;
+ /** The places that reach this pair after `parent` did; it is compared under `parent` only. */
+ referencedBy: DiffNode[];
+};
+
+export type LocatedNode = { location: Location; value: unknown };
+
+export type Change = { key: string; node: DiffNode } & (
+ | { kind: 'added'; revision: LocatedNode }
+ | { kind: 'removed'; base: LocatedNode }
+ | {
+ kind: 'modified';
+ property: string;
+ base: LocatedNode;
+ revision: LocatedNode;
+ }
+);
+
+export type DiffRuleContext = {
+ report: (report: { message: string; location?: Location }) => void;
+ location: Location;
+ getDirections: () => Direction[];
+ specVersion: SpecVersion;
+};
+
+export type DiffVisit = (change: Change, context: DiffRuleContext) => void;
+export type DiffVisitor = { [type: string]: DiffVisit | DiffVisitor };
+export type DiffRule = () => DiffVisitor;
+
+export type RuleVerdict = {
+ ruleId: string;
+ impact: Impact;
+ message: string;
+ location: Location;
+};
+export type JudgedChange = Change & { impact: Impact; verdicts: RuleVerdict[] };
+export type DiffSummary = Record;
+
+export type DiffResult = {
+ files: { base: string; revision: string };
+ specVersions: {
+ base: SpecVersion;
+ revision: SpecVersion;
+ };
+ infoVersions: {
+ base?: string;
+ revision?: string;
+ };
+ summary: DiffSummary;
+ bump?: Impact;
+ changes: JudgedChange[];
+ problems: NormalizedProblem[];
+};
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 361ee28200..04263a8678 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -61,13 +61,14 @@ export { YamlParseError } from './errors/yaml-parse-error.js';
export { parseYaml, stringifyYaml } from './js-yaml/index.js';
export {
unescapePointerFragment,
+ parsePointer,
isRef,
isMappingRef,
isAbsoluteUrl,
getDir,
resolvePath,
escapePointerFragment,
- type Location,
+ Location,
} from './ref-utils.js';
export { detectSpec, getMajorSpecVersion } from './detect-spec.js';
export { getTypes, type SpecVersion, type SpecMajorVersion } from './oas-types.js';
@@ -129,6 +130,19 @@ export { bundleDocument, type ComponentNamesStrategy } from './bundle/bundle-doc
export { mapTypeToComponent } from './bundle/bundle-visitor.js';
export { type Assertions, type Assertion } from './rules/common/assertions/index.js';
export { logger, type LoggerInterface } from './logger.js';
+export { diffDocuments } from './diff/index.js';
+export {
+ type Change,
+ type DiffNode,
+ type DiffResult,
+ type DiffSummary,
+ type Impact,
+ type JudgedChange,
+ type LocatedNode,
+} from './diff/types.js';
+export { impactRank, impacts } from './diff/impact.js';
+export { displaySide } from './diff/changes.js';
+export { nameOf, typeOf } from './diff/diff-node.js';
export { HandledError, AbortFlowError } from './utils/error.js';
export { isSupportedExtension } from './utils/is-supported-extension.js';
export { isBrowser } from './env.js';
diff --git a/packages/core/src/node-tree/__tests__/access.test.ts b/packages/core/src/node-tree/__tests__/access.test.ts
new file mode 100644
index 0000000000..e2c99a9536
--- /dev/null
+++ b/packages/core/src/node-tree/__tests__/access.test.ts
@@ -0,0 +1,82 @@
+import { outdent } from 'outdent';
+
+import { resolverWithFiles } from '../../../__tests__/utils.js';
+import { enclosing, keyKindOf, locationOf, valueOf } from '../access.js';
+import { buildTree, cafe, nodeAt } from './utils.js';
+
+const menuItemDocument = () =>
+ cafe(outdent`
+ paths:
+ /menu:
+ get:
+ parameters:
+ - { name: item, in: query, schema: { $ref: '#/components/schemas/MenuItem' } }
+ components:
+ schemas:
+ MenuItem:
+ type: object
+ properties:
+ name: { type: string }
+ `);
+
+describe('node access', () => {
+ it('should give what a $ref points at, or else the node itself, for its value and location', async () => {
+ const { root } = await buildTree(menuItemDocument());
+ const menuItemRef = nodeAt(root, '#/paths/~1menu/get/parameters/0/schema')!;
+ const menuItem = nodeAt(root, '#/components/schemas/MenuItem')!;
+
+ expect(valueOf(menuItemRef)).toBe(menuItem.value);
+ expect(locationOf(menuItemRef)).toBe(menuItem.location);
+ expect(valueOf(menuItem)).toBe(menuItem.value);
+ expect(locationOf(menuItem)).toBe(menuItem.location);
+ });
+
+ it('should tell a field of the type from a map entry and a list item', async () => {
+ const { root } = await buildTree(menuItemDocument());
+
+ expect(keyKindOf(nodeAt(root, '#/paths')!)).toBe('field');
+ expect(keyKindOf(nodeAt(root, '#/paths/~1menu')!)).toBe('entry');
+ expect(keyKindOf(nodeAt(root, '#/paths/~1menu/get/parameters/0')!)).toBe('item');
+ });
+
+ it('should climb from a node in another file to the $ref that reaches it', async () => {
+ const externalRefResolver = resolverWithFiles({ 'menu.yaml': 'get: {}' });
+ const { root } = await buildTree(
+ cafe(outdent`
+ paths:
+ /menu: { $ref: menu.yaml }
+ `),
+ externalRefResolver
+ );
+ const getOperation = nodeAt(root, '#/paths/~1menu')!.resolved!.children[0];
+
+ expect(enclosing(getOperation, 'Paths')).toBe(nodeAt(root, '#/paths'));
+ expect(enclosing(getOperation, 'Components')).toBeUndefined();
+ });
+
+ it('should end the climb where a $ref leads back into the node', async () => {
+ const externalRefResolver = resolverWithFiles({
+ 'menu.yaml': '{ $ref: ./item.yaml, summary: Menu }',
+ 'item.yaml': outdent`
+ get:
+ responses:
+ '200': { description: OK }
+ callbacks:
+ onEvent:
+ '{$request.body#/url}': { $ref: '#' }
+ `,
+ });
+ const { root } = await buildTree(
+ cafe(outdent`
+ paths:
+ /menu: { $ref: ./menu.yaml }
+ `),
+ externalRefResolver
+ );
+ const item = nodeAt(root, '#/paths/~1menu')!.resolved!.resolved!;
+ const responses = nodeAt(item, '#/get/responses')!;
+
+ expect(enclosing(responses, 'Paths')).toBe(nodeAt(root, '#/paths'));
+ expect(enclosing(responses, 'Components')).toBeUndefined();
+ });
+});
diff --git a/packages/core/src/node-tree/__tests__/index.test.ts b/packages/core/src/node-tree/__tests__/index.test.ts
new file mode 100644
index 0000000000..7b5dc614b8
--- /dev/null
+++ b/packages/core/src/node-tree/__tests__/index.test.ts
@@ -0,0 +1,221 @@
+import { outdent } from 'outdent';
+
+import { resolverWithFiles } from '../../../__tests__/utils.js';
+import { isRef } from '../../ref-utils.js';
+import type { NodeEntry } from '../types.js';
+import { buildTree, cafe, nodeAt } from './utils.js';
+
+function refsIn(node: NodeEntry, seen = new Set()): NodeEntry[] {
+ if (seen.has(node)) return [];
+ seen.add(node);
+ const reached = node.resolved ? [...node.children, node.resolved] : node.children;
+ return [...(isRef(node.value) ? [node] : []), ...reached.flatMap((child) => refsIn(child, seen))];
+}
+
+describe('buildNodeTree', () => {
+ it('should record a node with its type, key and value under the node it is written in', async () => {
+ const { root } = await buildTree(
+ cafe(outdent`
+ paths:
+ /menu:
+ get:
+ parameters:
+ - { name: limit, in: query }
+ `)
+ );
+
+ expect(nodeAt(root, '#/paths/~1menu/get/parameters/0')).toMatchObject({
+ type: { name: 'Parameter' },
+ key: 0,
+ value: { name: 'limit', in: 'query' },
+ parent: nodeAt(root, '#/paths/~1menu/get/parameters'),
+ });
+ });
+
+ it('should make a $ref a node of the type its place expects, pointing at its target', async () => {
+ const { root } = await buildTree(
+ cafe(outdent`
+ paths:
+ /menu:
+ get:
+ parameters:
+ - { name: item, in: query, schema: { $ref: '#/components/schemas/MenuItem' } }
+ components:
+ schemas:
+ MenuItem: { type: string }
+ `)
+ );
+
+ expect(nodeAt(root, '#/paths/~1menu/get/parameters/0/schema')).toMatchObject({
+ type: { name: 'Schema' },
+ resolved: { location: { pointer: '#/components/schemas/MenuItem' } },
+ });
+ });
+
+ it('should place a target where it is written, not under the $ref that reaches it first', async () => {
+ const { root } = await buildTree(
+ cafe(outdent`
+ paths:
+ /menu:
+ get:
+ parameters:
+ - { name: item, in: query, schema: { $ref: '#/components/schemas/MenuItem' } }
+ components:
+ schemas:
+ MenuItem: { type: string }
+ `)
+ );
+
+ expect(nodeAt(root, '#/components/schemas/MenuItem')).toMatchObject({
+ key: 'MenuItem',
+ parent: nodeAt(root, '#/components/schemas'),
+ });
+ });
+
+ it('should place a target where it is written when a $ref before it in the same map reaches it first', async () => {
+ const { root } = await buildTree(
+ cafe(outdent`
+ paths: {}
+ components:
+ schemas:
+ Receipt: { $ref: '#/components/schemas/Order' }
+ Order: { type: object }
+ `)
+ );
+
+ expect(nodeAt(root, '#/components/schemas/Order')).toMatchObject({
+ key: 'Order',
+ parent: nodeAt(root, '#/components/schemas'),
+ });
+ });
+
+ it('should place a node inside a component where it is written when a $ref reaches it first', async () => {
+ const { root } = await buildTree(
+ cafe(outdent`
+ paths:
+ /orders:
+ get:
+ parameters:
+ - { name: total, in: query, schema: { $ref: '#/components/schemas/Order/properties/total' } }
+ components:
+ schemas:
+ Order:
+ properties:
+ total: { type: number }
+ `)
+ );
+
+ expect(nodeAt(root, '#/components/schemas/Order/properties/total')).toMatchObject({
+ key: 'total',
+ parent: nodeAt(root, '#/components/schemas/Order/properties'),
+ });
+ });
+
+ it('should leave a $ref that does not resolve without a target and report it', async () => {
+ const { root, problems } = await buildTree(
+ cafe(outdent`
+ paths:
+ /menu:
+ get:
+ parameters:
+ - { name: item, in: query, schema: { $ref: '#/components/schemas/Dessert' } }
+ `)
+ );
+ const dessertRef = '#/paths/~1menu/get/parameters/0/schema';
+
+ expect(nodeAt(root, dessertRef)!.resolved).toBeUndefined();
+ expect(problems).toMatchObject([
+ { ruleId: 'no-unresolved-refs', location: [{ pointer: dessertRef }] },
+ ]);
+ });
+
+ it('should keep a target in another file without a parent, named by the $ref that reaches it', async () => {
+ const externalRefResolver = resolverWithFiles({ 'menu.yaml': 'get: {}' });
+
+ const { root } = await buildTree(
+ cafe(outdent`
+ paths:
+ /menu: { $ref: menu.yaml }
+ `),
+ externalRefResolver
+ );
+
+ const menuRef = nodeAt(root, '#/paths/~1menu')!;
+ expect(menuRef.resolved).toMatchObject({ key: '/menu', parent: null });
+ expect(menuRef.resolved!.referencedFrom).toBe(menuRef);
+ });
+
+ it('should give the root no way up when a $ref inside it points at it', async () => {
+ const { root } = await buildTree(
+ cafe(outdent`
+ paths: {}
+ components:
+ schemas:
+ Everything: { $ref: '#/' }
+ `)
+ );
+
+ expect(root.referencedFrom).toBeUndefined();
+ });
+
+ it('should link every $ref that meets its target before the target is built', async () => {
+ const externalRefResolver = resolverWithFiles({
+ 'menu.yaml': '{ $ref: ./item.yaml, summary: Menu }',
+ 'item.yaml': outdent`
+ get:
+ callbacks:
+ onEvent:
+ '{$request.body#/url}': { $ref: ./menu.yaml }
+ `,
+ });
+
+ const { root } = await buildTree(
+ cafe(outdent`
+ paths:
+ /menu: { $ref: ./menu.yaml }
+ /other: { $ref: ./item.yaml }
+ `),
+ externalRefResolver
+ );
+
+ const refs = refsIn(root);
+ expect(refs).toHaveLength(4);
+ expect(refs.filter((ref) => !ref.resolved).map((ref) => ref.location.pointer)).toEqual([]);
+ });
+
+ it('should record the keys written next to a $ref under that $ref', async () => {
+ const { root } = await buildTree(
+ cafe(outdent`
+ paths: {}
+ components:
+ schemas:
+ Drink: { type: object }
+ Coffee:
+ $ref: '#/components/schemas/Drink'
+ properties:
+ roast: { type: string }
+ `)
+ );
+
+ expect(nodeAt(root, '#/components/schemas/Coffee')!.children).toMatchObject([
+ { key: 'properties' },
+ ]);
+ });
+
+ it('should point a $ref at a $ref with keys written next to it, not at the end of the chain', async () => {
+ const { root } = await buildTree(
+ cafe(outdent`
+ paths: {}
+ components:
+ schemas:
+ DailySpecial: { $ref: '#/components/schemas/Latte' }
+ Latte: { $ref: '#/components/schemas/Coffee', description: Coffee with milk }
+ Coffee: { type: object }
+ `)
+ );
+
+ expect(nodeAt(root, '#/components/schemas/DailySpecial')!.resolved).toBe(
+ nodeAt(root, '#/components/schemas/Latte')
+ );
+ });
+});
diff --git a/packages/core/src/node-tree/__tests__/utils.ts b/packages/core/src/node-tree/__tests__/utils.ts
new file mode 100644
index 0000000000..960feb2611
--- /dev/null
+++ b/packages/core/src/node-tree/__tests__/utils.ts
@@ -0,0 +1,40 @@
+import { detectSpec } from '../../detect-spec.js';
+import { getTypes } from '../../oas-types.js';
+import {
+ BaseResolver,
+ makeDocumentFromString,
+ resolveDocument,
+ type Document,
+} from '../../resolve.js';
+import { normalizeTypes } from '../../types/index.js';
+import type { WalkContext } from '../../walk.js';
+import { buildNodeTree } from '../index.js';
+import type { NodeEntry } from '../types.js';
+
+export const cafe = (description: string) =>
+ makeDocumentFromString(
+ `openapi: 3.1.0\ninfo: { title: Cafe, version: 1.0.0 }\n${description}`,
+ 'cafe.yaml'
+ );
+
+export async function buildTree(document: Document, externalRefResolver = new BaseResolver()) {
+ const specVersion = detectSpec(document.parsed);
+ const types = normalizeTypes(getTypes(specVersion));
+ const resolvedRefMap = await resolveDocument({
+ rootDocument: document,
+ rootType: types.Root,
+ externalRefResolver,
+ });
+ const ctx: WalkContext = { problems: [], specVersion, visitorsData: {} };
+ const root = buildNodeTree({ document, types, resolvedRefMap, ctx });
+ return { root, problems: ctx.problems };
+}
+
+export function nodeAt(node: NodeEntry, pointer: string): NodeEntry | undefined {
+ if (node.location.pointer === pointer) return node;
+ for (const child of node.children) {
+ const found = nodeAt(child, pointer);
+ if (found) return found;
+ }
+ return undefined;
+}
diff --git a/packages/core/src/node-tree/access.ts b/packages/core/src/node-tree/access.ts
new file mode 100644
index 0000000000..09a8789aa0
--- /dev/null
+++ b/packages/core/src/node-tree/access.ts
@@ -0,0 +1,39 @@
+import type { Location } from '../ref-utils.js';
+import { isPlainObject } from '../utils/is-plain-object.js';
+import type { NodeEntry, NodeValue } from './types.js';
+
+/** The nearest node of that type, the node itself included. */
+export function enclosing(node: NodeEntry, typeName: string): NodeEntry | undefined {
+ // A `$ref` inside a file can lead back to the file's root, so each node is climbed once.
+ const climbed = new Set();
+ let current: NodeEntry | undefined = node;
+
+ while (current && !climbed.has(current)) {
+ if (current.type.name === typeName) return current;
+ climbed.add(current);
+ current = current.parent ?? current.referencedFrom;
+ }
+
+ return undefined;
+}
+
+// A `$ref` stands for the node it points at.
+export function valueOf(node: NodeEntry): NodeValue {
+ return (node.resolved ?? node).value;
+}
+
+export function locationOf(node: NodeEntry): Location {
+ return (node.resolved ?? node).location;
+}
+
+export function fieldOf(node: NodeEntry | undefined, name: string): unknown {
+ const value = node && valueOf(node);
+ return isPlainObject(value) ? value[name] : undefined;
+}
+
+export function keyKindOf(node: NodeEntry): 'field' | 'entry' | 'item' {
+ if (typeof node.key === 'number') return 'item';
+
+ const isField = !!node.parent && Object.hasOwn(node.parent.type.properties, node.key);
+ return isField ? 'field' : 'entry';
+}
diff --git a/packages/core/src/node-tree/index.ts b/packages/core/src/node-tree/index.ts
new file mode 100644
index 0000000000..0aaa8d4102
--- /dev/null
+++ b/packages/core/src/node-tree/index.ts
@@ -0,0 +1,106 @@
+import type { Document, ResolvedRefMap } from '../resolve.js';
+import { NoUnresolvedRefs } from '../rules/common/no-unresolved-refs.js';
+import type { NormalizedNodeType } from '../types/index.js';
+import { isPlainObject } from '../utils/is-plain-object.js';
+import {
+ normalizeVisitors,
+ type BaseVisitor,
+ type NestedVisitObject,
+ type RuleInstanceConfig,
+} from '../visitors.js';
+import { walkDocument, type UserContext, type WalkContext } from '../walk.js';
+import type { NodeEntry, NodeValue } from './types.js';
+
+export function buildNodeTree(opts: {
+ document: Document;
+ types: Record;
+ resolvedRefMap: ResolvedRefMap;
+ ctx: WalkContext;
+}): NodeEntry {
+ const { document, types, resolvedRefMap, ctx } = opts;
+
+ const nodes = new Map();
+ const waitingRefs = new Map();
+
+ const add = (value: NodeValue, { type, key, location }: UserContext, parent?: NodeEntry) => {
+ const node: NodeEntry = {
+ type,
+ key,
+ location,
+ value,
+ parent: parent ?? null,
+ children: [],
+ };
+
+ parent?.children.push(node);
+ nodes.set(value, node);
+
+ for (const waiting of waitingRefs.get(value) ?? []) waiting.resolved = node;
+
+ return node;
+ };
+
+ const visitor: BaseVisitor = {
+ any: {
+ enter(value: unknown, context) {
+ const isContainer = isPlainObject(value) || Array.isArray(value);
+
+ if (!isContainer || nodes.has(value)) return;
+
+ const reachedFrom = nodes.get(context.rawNode);
+
+ if (reachedFrom) {
+ add(value, context).referencedFrom = reachedFrom;
+ } else {
+ add(value, context, nodes.get(context.parent));
+ }
+ },
+ // A target the walker reached through a `$ref` before its own place joins it here
+ leave(value: unknown) {
+ const container = nodes.get(value);
+
+ if (!container) return;
+
+ for (const [key, childValue] of Object.entries(container.value)) {
+ const child = nodes.get(childValue);
+
+ if (!child || child.parent) continue;
+
+ child.parent = container;
+ child.key = Array.isArray(container.value) ? Number(key) : key;
+ container.children.push(child);
+ }
+ },
+ },
+ ref(value: { $ref: string }, context, resolved) {
+ if (nodes.has(value)) return;
+
+ const ref = add(value, context, nodes.get(context.parent));
+ const target = resolved.chain?.[0]?.node ?? resolved.node;
+ const resolvedNode = nodes.get(target);
+
+ if (resolvedNode) {
+ ref.resolved = resolvedNode;
+ } else {
+ waitingRefs.set(target, [...(waitingRefs.get(target) ?? []), ref]);
+ }
+ },
+ };
+
+ const visitors: Array<
+ RuleInstanceConfig & { visitor: NestedVisitObject }
+ > = [
+ { severity: 'warn', ruleId: 'node-tree', visitor },
+ { severity: 'error', ruleId: 'no-unresolved-refs', visitor: NoUnresolvedRefs({}) },
+ ];
+
+ walkDocument({
+ document,
+ rootType: types.Root,
+ normalizedVisitors: normalizeVisitors(visitors, types),
+ resolvedRefMap,
+ ctx,
+ });
+
+ return nodes.get(document.parsed)!;
+}
diff --git a/packages/core/src/node-tree/types.ts b/packages/core/src/node-tree/types.ts
new file mode 100644
index 0000000000..6c31f8429a
--- /dev/null
+++ b/packages/core/src/node-tree/types.ts
@@ -0,0 +1,18 @@
+import type { Location } from '../ref-utils.js';
+import type { NormalizedNodeType } from '../types/index.js';
+
+export type NodeValue = Record | unknown[];
+
+export type NodeEntry = {
+ type: NormalizedNodeType;
+ /** The root of another file takes the key of the `$ref` that reaches it. */
+ key: string | number;
+ location: Location;
+ value: NodeValue;
+ parent: NodeEntry | null;
+ children: NodeEntry[];
+ /** The target of a `$ref`. */
+ resolved?: NodeEntry;
+ /** For a node with no parent, such as the root of another file: the `$ref` it was reached by. */
+ referencedFrom?: NodeEntry;
+};
diff --git a/packages/core/src/rules/utils.ts b/packages/core/src/rules/utils.ts
index a79f983724..ec0e2314de 100644
--- a/packages/core/src/rules/utils.ts
+++ b/packages/core/src/rules/utils.ts
@@ -1,5 +1,5 @@
import type { Context as AjvContext } from '@redocly/ajv/dist/2020.js';
-import { default as levenshtein } from 'js-levenshtein';
+import { distance as levenshtein } from 'fastest-levenshtein';
import { isRef, Location } from '../ref-utils.js';
import type { ResolvedRefChainHop } from '../resolve.js';
diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts
index ecab4839f2..f9c2b5a07f 100644
--- a/packages/core/src/types/redocly-yaml.ts
+++ b/packages/core/src/types/redocly-yaml.ts
@@ -3,6 +3,7 @@ import type { JSONSchema } from 'json-schema-to-ts';
import path from 'node:path';
import type { Config, RawGovernanceConfig } from '../config/index.js';
+import { diffRuleIds } from '../diff/rules/index.js';
import { graphqlNodeKinds } from '../graphql/node-kinds.js';
import { specVersions, getTypes } from '../oas-types.js';
import { isAbsoluteUrl } from '../ref-utils.js';
@@ -319,6 +320,12 @@ const configGovernanceProperties: Record<
arazzo1_1Decorators: 'Decorators',
overlay1Decorators: 'Decorators',
openrpc1Decorators: 'Decorators',
+
+ diff: 'DiffRules',
+ oas3_0Diff: 'DiffRules',
+ oas3_1Diff: 'DiffRules',
+ oas3_2Diff: 'DiffRules',
+ async3Diff: 'DiffRules',
};
const ConfigGovernance: NodeType = {
@@ -355,7 +362,15 @@ const createConfigApisProperties = (nodeTypes: Record): NodeTy
...nodeTypes['rootRedoclyConfigSchema.apis_additionalProperties'],
properties: {
...nodeTypes['rootRedoclyConfigSchema.apis_additionalProperties']?.properties,
- ...omit(ConfigGovernance.properties, ['plugins']), // plugins are not allowed in apis
+ // plugins are not allowed in apis, and diff compares two apis, so it is configured once.
+ ...omit(ConfigGovernance.properties, [
+ 'plugins',
+ 'diff',
+ 'oas3_0Diff',
+ 'oas3_1Diff',
+ 'oas3_2Diff',
+ 'async3Diff',
+ ]),
// TODO: move `client` and `clientOutput` into the Redocly config schema (@redocly/config).
client: 'Client',
clientOutput: { type: 'string' },
@@ -455,6 +470,16 @@ const Rules: NodeType = {
},
};
+const DiffRules: NodeType = {
+ properties: {},
+ description:
+ 'The `diff` block sets the semver impact of each diff rule: `off`, `patch`, `minor`, or `major`.',
+ additionalProperties: (_value: unknown, key: string) =>
+ diffRuleIds.includes(key) || isCustomRuleId(key)
+ ? { enum: ['off', 'patch', 'minor', 'major'] }
+ : undefined,
+};
+
const BuiltinRule: NodeType = {
properties: {
severity: { enum: ['error', 'warn', 'off'] },
@@ -836,6 +861,7 @@ const CoreConfigTypes: Record = {
Decorators,
Preprocessors,
Assertions,
+ DiffRules,
};
// FIXME: remove this once we remove `theme` from the schema
diff --git a/packages/core/src/utils/__tests__/is-scalar.test.ts b/packages/core/src/utils/__tests__/is-scalar.test.ts
new file mode 100644
index 0000000000..b48c34752a
--- /dev/null
+++ b/packages/core/src/utils/__tests__/is-scalar.test.ts
@@ -0,0 +1,12 @@
+import { isScalarArray } from '../is-scalar.js';
+
+describe('isScalarArray', () => {
+ it.each([
+ [[], true],
+ [['coffee', 1, true, null], true],
+ [[{ name: 'coffee' }], false],
+ ['coffee', false],
+ ])('tells whether %j is an array of scalars', (value, isArrayOfScalars) => {
+ expect(isScalarArray(value)).toBe(isArrayOfScalars);
+ });
+});
diff --git a/packages/core/src/utils/is-scalar.ts b/packages/core/src/utils/is-scalar.ts
new file mode 100644
index 0000000000..89b990c03c
--- /dev/null
+++ b/packages/core/src/utils/is-scalar.ts
@@ -0,0 +1,12 @@
+export function isScalar(value: unknown): boolean {
+ return (
+ value === null ||
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ typeof value === 'boolean'
+ );
+}
+
+export function isScalarArray(value: unknown): boolean {
+ return Array.isArray(value) && value.every(isScalar);
+}
diff --git a/packages/core/src/utils/items-not-in.ts b/packages/core/src/utils/items-not-in.ts
new file mode 100644
index 0000000000..fdc301d56c
--- /dev/null
+++ b/packages/core/src/utils/items-not-in.ts
@@ -0,0 +1,6 @@
+/** The items of `list` that `other` does not have. */
+export function itemsNotIn(list: unknown, other: unknown): unknown[] {
+ if (!Array.isArray(list)) return [];
+ const otherItems = Array.isArray(other) ? other : [];
+ return list.filter((item) => !otherItems.includes(item));
+}
diff --git a/tests/e2e/diff/asyncapi3/base.yaml b/tests/e2e/diff/asyncapi3/base.yaml
new file mode 100644
index 0000000000..75d1150692
--- /dev/null
+++ b/tests/e2e/diff/asyncapi3/base.yaml
@@ -0,0 +1,36 @@
+asyncapi: 3.0.0
+info: { title: Cafe kitchen, version: 1.0.0 }
+servers:
+ production: { host: kitchen.cafe.test, protocol: mqtt }
+ staging: { host: staging.cafe.test, protocol: mqtt }
+channels:
+ orderPlaced:
+ address: orders/placed
+ messages:
+ orderPlaced:
+ contentType: application/json
+ payload:
+ type: object
+ properties:
+ orderId: { type: string }
+ items: { type: array }
+ orderReady:
+ address: orders/ready
+ messages:
+ orderReady:
+ payload: { type: object, properties: { orderId: { type: string } } }
+ menuChanged:
+ address: menu/changed
+ messages:
+ menuChanged:
+ payload: { type: object }
+operations:
+ onOrderPlaced:
+ action: receive
+ channel: { $ref: '#/channels/orderPlaced' }
+ sendOrderReady:
+ action: send
+ channel: { $ref: '#/channels/orderReady' }
+ sendMenuChanged:
+ action: send
+ channel: { $ref: '#/channels/menuChanged' }
diff --git a/tests/e2e/diff/asyncapi3/revision.yaml b/tests/e2e/diff/asyncapi3/revision.yaml
new file mode 100644
index 0000000000..23afe4bdf4
--- /dev/null
+++ b/tests/e2e/diff/asyncapi3/revision.yaml
@@ -0,0 +1,28 @@
+asyncapi: 3.0.0
+info: { title: Cafe kitchen, version: 1.0.0 }
+servers:
+ production: { host: kitchen.cafe.test, protocol: mqtt }
+channels:
+ orderPlaced:
+ address: orders/new
+ messages:
+ orderPlaced:
+ contentType: application/avro
+ payload:
+ type: object
+ required: [items]
+ properties:
+ orderId: { type: string }
+ items: { type: array }
+ orderReady:
+ address: orders/ready
+ messages:
+ orderReady:
+ payload: { type: object, properties: {} }
+operations:
+ onOrderPlaced:
+ action: receive
+ channel: { $ref: '#/channels/orderPlaced' }
+ sendOrderReady:
+ action: receive
+ channel: { $ref: '#/channels/orderReady' }
diff --git a/tests/e2e/diff/asyncapi3/snapshot.txt b/tests/e2e/diff/asyncapi3/snapshot.txt
new file mode 100644
index 0000000000..b1aaee1d31
--- /dev/null
+++ b/tests/e2e/diff/asyncapi3/snapshot.txt
@@ -0,0 +1,42 @@
+channel menuChanged
+ ✖ major removed
+ Channel `menuChanged` was removed. (channel-removed)
+ at base.yaml:23:5
+
+channel orderPlaced
+ ✖ major modified address
+ `address` of channel `orderPlaced` changed from 'orders/placed' to 'orders/new'. (channel-address-changed)
+ at revision.yaml:7:14
+ ✖ major modified messages/orderPlaced · contentType
+ `contentType` of message `orderPlaced` changed from 'application/json' to 'application/avro'. (message-content-type-changed)
+ at revision.yaml:10:22
+ ✖ major modified messages/orderPlaced/payload · required
+ Properties became required: `items`. (required-properties-added)
+ at revision.yaml:13:21
+
+channel orderReady
+ ✖ major removed messages/orderReady/payload/properties/orderId
+ Property `orderId` was removed. (property-removed)
+ at base.yaml:21:57
+
+operation sendMenuChanged
+ ✖ major removed
+ Operation `sendMenuChanged` was removed. (operation-removed)
+ at base.yaml:35:5
+
+operation sendOrderReady
+ ✖ major modified action
+ `action` of operation `sendOrderReady` changed from 'send' to 'receive'. (operation-action-changed)
+ at revision.yaml:27:13
+
+server staging
+ ✖ major removed
+ Server `staging` was removed. (server-removed)
+ at base.yaml:5:12
+
+8 major, 0 minor, 0 patch.
+
+
+base.yaml vs revision.yaml: diff processed in ms
+
+❌ Diff failed with 8 major changes.
diff --git a/tests/e2e/diff/config-file/base.yaml b/tests/e2e/diff/config-file/base.yaml
new file mode 100644
index 0000000000..d9fc441b18
--- /dev/null
+++ b/tests/e2e/diff/config-file/base.yaml
@@ -0,0 +1,14 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.2.0 }
+paths:
+ /menu:
+ get:
+ summary: List the menu
+ parameters:
+ - { name: limit, in: query, schema: { type: integer } }
+ responses:
+ '200': { description: OK }
+ /orders/{orderId}:
+ delete:
+ responses:
+ '204': { description: Cancelled }
diff --git a/tests/e2e/diff/config-file/redocly.yaml b/tests/e2e/diff/config-file/redocly.yaml
new file mode 100644
index 0000000000..94ad796aa0
--- /dev/null
+++ b/tests/e2e/diff/config-file/redocly.yaml
@@ -0,0 +1,2 @@
+extends:
+ - diff-recommended
diff --git a/tests/e2e/diff/config-file/relaxed.yaml b/tests/e2e/diff/config-file/relaxed.yaml
new file mode 100644
index 0000000000..1f4efc2ea8
--- /dev/null
+++ b/tests/e2e/diff/config-file/relaxed.yaml
@@ -0,0 +1,7 @@
+# Picked with --config in place of redocly.yaml: here a removed path and a parameter that became
+# required only count as minor changes.
+extends:
+ - diff-recommended
+diff:
+ path-removed: minor
+ parameter-became-required: minor
diff --git a/tests/e2e/diff/config-file/revision.yaml b/tests/e2e/diff/config-file/revision.yaml
new file mode 100644
index 0000000000..68e0ea18b1
--- /dev/null
+++ b/tests/e2e/diff/config-file/revision.yaml
@@ -0,0 +1,14 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.3.0 }
+paths:
+ /menu:
+ get:
+ summary: List the whole menu
+ parameters:
+ - { name: limit, in: query, required: true, schema: { type: integer } }
+ responses:
+ '200': { description: OK }
+ /specials:
+ get:
+ responses:
+ '200': { description: OK }
diff --git a/tests/e2e/diff/config-file/snapshot.txt b/tests/e2e/diff/config-file/snapshot.txt
new file mode 100644
index 0000000000..672bbb19f4
--- /dev/null
+++ b/tests/e2e/diff/config-file/snapshot.txt
@@ -0,0 +1,26 @@
+info
+ · patch modified version
+ at revision.yaml:2:31
+
+GET /menu
+ ✔ minor modified parameters/0 · required
+ `limit` query parameter became required. (parameter-became-required)
+ at revision.yaml:8:47
+ · patch modified summary
+ at revision.yaml:6:16
+
+/orders/{orderId}
+ ✔ minor removed
+ Path `/orders/{orderId}` was removed. (path-removed)
+ at base.yaml:12:5
+
+/specials
+ ✔ minor added
+ at revision.yaml:12:5
+
+0 major, 3 minor, 2 patch.
+
+
+base.yaml vs revision.yaml: diff processed in ms
+
+✅ Diff passed.
diff --git a/tests/e2e/diff/configuration/base.yaml b/tests/e2e/diff/configuration/base.yaml
new file mode 100644
index 0000000000..4f1c8df49b
--- /dev/null
+++ b/tests/e2e/diff/configuration/base.yaml
@@ -0,0 +1,65 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.2.0 }
+servers:
+ - url: https://api.cafe.test
+security:
+ - apiKey: []
+paths:
+ /menu:
+ get:
+ summary: List the menu
+ parameters:
+ - { name: limit, in: query, schema: { type: integer } }
+ - { name: search, in: query, schema: { type: string } }
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } }
+ /menu/{id}:
+ get:
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string } }
+ responses:
+ '200': { description: OK }
+ delete:
+ responses:
+ '204': { description: Deleted }
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Order' }
+ responses:
+ '201':
+ description: Created
+ headers:
+ Location: { schema: { type: string } }
+webhooks:
+ orderReady:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema:
+ { type: object, properties: { orderId: { type: string }, eta: { type: string } } }
+ responses:
+ '200': { description: OK }
+components:
+ securitySchemes:
+ apiKey: { type: apiKey, in: header, name: X-Key }
+ schemas:
+ MenuItem:
+ type: object
+ properties:
+ name: { type: string }
+ price: { type: number }
+ status: { type: string, enum: [available, sold-out] }
+ Order:
+ type: object
+ required: [items]
+ properties:
+ items: { type: array, items: { type: string } }
+ note: { type: string, maxLength: 200 }
diff --git a/tests/e2e/diff/configuration/redocly.yaml b/tests/e2e/diff/configuration/redocly.yaml
new file mode 100644
index 0000000000..30a442fec8
--- /dev/null
+++ b/tests/e2e/diff/configuration/redocly.yaml
@@ -0,0 +1,12 @@
+extends:
+ - diff-recommended
+apis:
+ cafe@v1:
+ root: base.yaml
+ cafe@v2:
+ root: revision.yaml
+# The cafe accepts new enum values in its responses, and treats a dropped header as a patch.
+diff:
+ enum-values-added: off
+oas3_1Diff:
+ response-header-removed: patch
diff --git a/tests/e2e/diff/configuration/revision.yaml b/tests/e2e/diff/configuration/revision.yaml
new file mode 100644
index 0000000000..24ebf27912
--- /dev/null
+++ b/tests/e2e/diff/configuration/revision.yaml
@@ -0,0 +1,62 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.3.0 }
+servers:
+ - url: https://api.cafe.test
+security:
+ - apiKey: []
+paths:
+ /menu:
+ get:
+ summary: List the whole menu
+ parameters:
+ - { name: limit, in: query, required: true, schema: { type: integer } }
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } }
+ /menu/{menuItemId}:
+ get:
+ parameters:
+ - { name: menuItemId, in: path, required: true, schema: { type: string } }
+ responses:
+ '200': { description: OK }
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Order' }
+ responses:
+ '201':
+ description: Created
+ /specials:
+ get:
+ responses:
+ '200': { description: OK }
+webhooks:
+ orderReady:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { type: object, properties: { orderId: { type: string } } }
+ responses:
+ '200': { description: OK }
+components:
+ securitySchemes:
+ apiKey: { type: apiKey, in: header, name: X-Key }
+ schemas:
+ MenuItem:
+ type: object
+ properties:
+ name: { type: string }
+ status: { type: string, enum: [available, sold-out, seasonal] }
+ Order:
+ type: object
+ required: [items, pickupAt]
+ properties:
+ items: { type: array, items: { type: string } }
+ note: { type: string, maxLength: 100 }
+ pickupAt: { type: string, format: date-time }
diff --git a/tests/e2e/diff/configuration/snapshot-skip-rule.txt b/tests/e2e/diff/configuration/snapshot-skip-rule.txt
new file mode 100644
index 0000000000..723dcc321a
--- /dev/null
+++ b/tests/e2e/diff/configuration/snapshot-skip-rule.txt
@@ -0,0 +1,57 @@
+info
+ · patch modified version
+ at revision.yaml:2:31
+
+GET /menu
+ ✖ major modified parameters/0 · required
+ `limit` query parameter became required. (parameter-became-required)
+ at revision.yaml:12:47
+ ✖ major removed parameters/1
+ `search` query parameter was removed. (parameter-removed)
+ at base.yaml:13:11
+ · patch modified summary
+ at revision.yaml:10:16
+ · patch removed responses/200/content/application/json/schema/items/properties/price
+ at base.yaml:58:16
+ · patch modified responses/200/content/application/json/schema/items/properties/status · enum
+ at revision.yaml:55:39
+
+/menu/{menuItemId}
+ · patch modified key
+ at revision.yaml:20:5
+
+DELETE /menu/{menuItemId}
+ · patch removed
+ at base.yaml:27:7
+
+GET /menu/{menuItemId}
+ · patch modified parameters/0 · name
+ at revision.yaml:22:19
+
+POST /orders
+ ✖ major modified requestBody/content/application/json/schema · required
+ Properties of `Order` became required: `pickupAt`. (required-properties-added)
+ at revision.yaml:58:17
+ ✖ major modified requestBody/content/application/json/schema/properties/note · maxLength
+ `maxLength` of `note` changed from '200' to '100'. (schema-constraint-changed)
+ at revision.yaml:61:42
+ ✔ minor added requestBody/content/application/json/schema/properties/pickupAt
+ at revision.yaml:62:19
+ · patch removed responses/201/headers
+ All headers of response `201` were removed. (response-header-removed)
+ at base.yaml:39:13
+
+/specials
+ ✔ minor added
+ at revision.yaml:35:5
+
+POST orderReady (webhook)
+ · patch removed requestBody/content/application/json/schema/properties/eta
+ at base.yaml:47:77
+
+4 major, 2 minor, 9 patch.
+
+
+./tests/e2e/diff/configuration/base.yaml vs ./tests/e2e/diff/configuration/revision.yaml: diff processed in ms
+
+❌ Diff failed with 4 major changes.
diff --git a/tests/e2e/diff/configuration/snapshot.txt b/tests/e2e/diff/configuration/snapshot.txt
new file mode 100644
index 0000000000..deed73bd93
--- /dev/null
+++ b/tests/e2e/diff/configuration/snapshot.txt
@@ -0,0 +1,60 @@
+info
+ · patch modified version
+ at revision.yaml:2:31
+
+GET /menu
+ ✖ major modified parameters/0 · required
+ `limit` query parameter became required. (parameter-became-required)
+ at revision.yaml:12:47
+ ✖ major removed parameters/1
+ `search` query parameter was removed. (parameter-removed)
+ at base.yaml:13:11
+ ✖ major removed responses/200/content/application/json/schema/items/properties/price
+ Property `price` of `MenuItem` was removed. (property-removed)
+ at base.yaml:58:16
+ · patch modified summary
+ at revision.yaml:10:16
+ · patch modified responses/200/content/application/json/schema/items/properties/status · enum
+ at revision.yaml:55:39
+
+/menu/{menuItemId}
+ · patch modified key
+ at revision.yaml:20:5
+
+DELETE /menu/{menuItemId}
+ ✖ major removed
+ Operation `DELETE /menu/{menuItemId}` was removed. (operation-removed)
+ at base.yaml:27:7
+
+GET /menu/{menuItemId}
+ · patch modified parameters/0 · name
+ at revision.yaml:22:19
+
+POST /orders
+ ✖ major modified requestBody/content/application/json/schema · required
+ Properties of `Order` became required: `pickupAt`. (required-properties-added)
+ at revision.yaml:58:17
+ ✖ major modified requestBody/content/application/json/schema/properties/note · maxLength
+ `maxLength` of `note` changed from '200' to '100'. (schema-constraint-changed)
+ at revision.yaml:61:42
+ ✔ minor added requestBody/content/application/json/schema/properties/pickupAt
+ at revision.yaml:62:19
+ · patch removed responses/201/headers
+ All headers of response `201` were removed. (response-header-removed)
+ at base.yaml:39:13
+
+/specials
+ ✔ minor added
+ at revision.yaml:35:5
+
+POST orderReady (webhook)
+ ✖ major removed requestBody/content/application/json/schema/properties/eta
+ Property `eta` was removed. (property-removed)
+ at base.yaml:47:77
+
+7 major, 2 minor, 6 patch.
+
+
+./tests/e2e/diff/configuration/base.yaml vs ./tests/e2e/diff/configuration/revision.yaml: diff processed in ms
+
+❌ Diff failed with 7 major changes.
diff --git a/tests/e2e/diff/diff.test.ts b/tests/e2e/diff/diff.test.ts
new file mode 100644
index 0000000000..b79b9b55ac
--- /dev/null
+++ b/tests/e2e/diff/diff.test.ts
@@ -0,0 +1,195 @@
+import { readFileSync, rmSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { getCommandOutput, getParams, cleanupOutput } from '../helpers.js';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const indexEntryPoint = join(process.cwd(), 'packages/cli/lib/index.js');
+
+describe('diff', () => {
+ test('should list what breaks per endpoint and fail on a major change', async () => {
+ const testPath = join(__dirname, 'openapi-breaking-changes');
+ const args = getParams(indexEntryPoint, ['diff', 'base.yaml', 'revision.yaml']);
+
+ const result = getCommandOutput(args, { testPath });
+ await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt'));
+ });
+
+ test('should fail --check-version when info.version is bumped less than the changes require', async () => {
+ const testPath = join(__dirname, 'openapi-breaking-changes');
+ const args = getParams(indexEntryPoint, [
+ 'diff',
+ 'base.yaml',
+ 'revision.yaml',
+ '--check-version',
+ '--fail-on=none',
+ ]);
+
+ const result = getCommandOutput(args, { testPath });
+ await expect(cleanupOutput(result)).toMatchFileSnapshot(
+ join(testPath, 'snapshot-check-version.txt')
+ );
+ });
+
+ test('should pass a revision that only adds to the API and bumps info.version', async () => {
+ const testPath = join(__dirname, 'openapi-compatible-changes');
+ const args = getParams(indexEntryPoint, [
+ 'diff',
+ 'base.yaml',
+ 'revision.yaml',
+ '--check-version',
+ ]);
+
+ const result = getCommandOutput(args, { testPath });
+ await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt'));
+ });
+
+ test('should fail on the impact --fail-on names', async () => {
+ const testPath = join(__dirname, 'openapi-compatible-changes');
+ const args = getParams(indexEntryPoint, [
+ 'diff',
+ 'base.yaml',
+ 'revision.yaml',
+ '--fail-on=minor',
+ ]);
+
+ const result = getCommandOutput(args, { testPath });
+ await expect(cleanupOutput(result)).toMatchFileSnapshot(
+ join(testPath, 'snapshot-fail-on-minor.txt')
+ );
+ });
+
+ test.each(['markdown', 'html'])(
+ 'should write a %s report to the file --output names',
+ async (format) => {
+ const testPath = join(__dirname, 'markdown-and-html');
+ const args = getParams(indexEntryPoint, [
+ 'diff',
+ 'base.yaml',
+ 'revision.yaml',
+ `--format=${format}`,
+ `--output=report.${format}`,
+ ]);
+
+ getCommandOutput(args, { testPath });
+ const report = readFileSync(join(testPath, `report.${format}`), 'utf-8');
+ rmSync(join(testPath, `report.${format}`));
+ await expect(report).toMatchFileSnapshot(join(testPath, `snapshot-${format}.txt`));
+ }
+ );
+
+ test.each(['json', 'next-version'])(
+ 'should print the %s format for a script to read',
+ async (format) => {
+ const testPath = join(__dirname, 'json-and-next-version');
+ const args = getParams(indexEntryPoint, [
+ 'diff',
+ 'base.yaml',
+ 'revision.yaml',
+ `--format=${format}`,
+ ]);
+
+ const result = getCommandOutput(args, { testPath });
+ await expect(cleanupOutput(result)).toMatchFileSnapshot(
+ join(testPath, `snapshot-${format}.txt`)
+ );
+ }
+ );
+
+ test('should annotate each breaking change on GitHub', async () => {
+ const testPath = join(__dirname, 'github-actions');
+ const args = getParams(indexEntryPoint, [
+ 'diff',
+ 'base.yaml',
+ 'revision.yaml',
+ '--format=github-actions',
+ ]);
+
+ const result = getCommandOutput(args, { testPath });
+ await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt'));
+ });
+
+ test('should refuse to write github-actions annotations to a file', async () => {
+ const testPath = join(__dirname, 'github-actions');
+ const args = getParams(indexEntryPoint, [
+ 'diff',
+ 'base.yaml',
+ 'revision.yaml',
+ '--format=github-actions',
+ '--output=annotations.txt',
+ ]);
+
+ const result = getCommandOutput(args, { testPath });
+ await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot-output.txt'));
+ });
+
+ test('should judge an AsyncAPI 3 change per channel, operation and server', async () => {
+ const testPath = join(__dirname, 'asyncapi3');
+ const args = getParams(indexEntryPoint, ['diff', 'base.yaml', 'revision.yaml']);
+
+ const result = getCommandOutput(args, { testPath });
+ await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt'));
+ });
+
+ test('should not report a move from OpenAPI 3.0 to 3.1 that describes the same API', async () => {
+ const testPath = join(__dirname, 'openapi-3-0-to-3-1');
+ const args = getParams(indexEntryPoint, ['diff', 'base.yaml', 'revision.yaml']);
+
+ const result = getCommandOutput(args, { testPath });
+ await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt'));
+ });
+
+ test('should point each change at the file and line that has it', async () => {
+ const testPath = join(__dirname, 'multi-file');
+ const args = getParams(indexEntryPoint, ['diff', 'base/openapi.yaml', 'revision/openapi.yaml']);
+
+ const result = getCommandOutput(args, { testPath });
+ await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt'));
+ });
+
+ test('should compare two APIs by their aliases and judge them with the rules of redocly.yaml', async () => {
+ const testPath = join(__dirname, 'configuration');
+ const args = getParams(indexEntryPoint, ['diff', 'cafe@v1', 'cafe@v2']);
+
+ const result = getCommandOutput(args, { testPath });
+ await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt'));
+ });
+
+ test('should leave out the rules --skip-rule names', async () => {
+ const testPath = join(__dirname, 'configuration');
+ const args = getParams(indexEntryPoint, [
+ 'diff',
+ 'cafe@v1',
+ 'cafe@v2',
+ '--skip-rule=operation-removed',
+ '--skip-rule=property-removed',
+ ]);
+
+ const result = getCommandOutput(args, { testPath });
+ await expect(cleanupOutput(result)).toMatchFileSnapshot(
+ join(testPath, 'snapshot-skip-rule.txt')
+ );
+ });
+
+ test('should use the config file --config names', async () => {
+ const testPath = join(__dirname, 'config-file');
+ const args = getParams(indexEntryPoint, [
+ 'diff',
+ 'base.yaml',
+ 'revision.yaml',
+ '--config=relaxed.yaml',
+ ]);
+
+ const result = getCommandOutput(args, { testPath });
+ await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt'));
+ });
+
+ test('should judge a change with a diff rule from a plugin', async () => {
+ const testPath = join(__dirname, 'plugin');
+ const args = getParams(indexEntryPoint, ['diff', 'base.yaml', 'revision.yaml']);
+
+ const result = getCommandOutput(args, { testPath });
+ await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt'));
+ });
+});
diff --git a/tests/e2e/diff/github-actions/base.yaml b/tests/e2e/diff/github-actions/base.yaml
new file mode 100644
index 0000000000..d9fc441b18
--- /dev/null
+++ b/tests/e2e/diff/github-actions/base.yaml
@@ -0,0 +1,14 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.2.0 }
+paths:
+ /menu:
+ get:
+ summary: List the menu
+ parameters:
+ - { name: limit, in: query, schema: { type: integer } }
+ responses:
+ '200': { description: OK }
+ /orders/{orderId}:
+ delete:
+ responses:
+ '204': { description: Cancelled }
diff --git a/tests/e2e/diff/github-actions/revision.yaml b/tests/e2e/diff/github-actions/revision.yaml
new file mode 100644
index 0000000000..68e0ea18b1
--- /dev/null
+++ b/tests/e2e/diff/github-actions/revision.yaml
@@ -0,0 +1,14 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.3.0 }
+paths:
+ /menu:
+ get:
+ summary: List the whole menu
+ parameters:
+ - { name: limit, in: query, required: true, schema: { type: integer } }
+ responses:
+ '200': { description: OK }
+ /specials:
+ get:
+ responses:
+ '200': { description: OK }
diff --git a/tests/e2e/diff/github-actions/snapshot-output.txt b/tests/e2e/diff/github-actions/snapshot-output.txt
new file mode 100644
index 0000000000..0fabf18317
--- /dev/null
+++ b/tests/e2e/diff/github-actions/snapshot-output.txt
@@ -0,0 +1,3 @@
+
+The github-actions format prints to stdout only. To write a report to a file, use one of these formats: stylish, json, markdown, html, next-version.
+
diff --git a/tests/e2e/diff/github-actions/snapshot.txt b/tests/e2e/diff/github-actions/snapshot.txt
new file mode 100644
index 0000000000..dc3db812fe
--- /dev/null
+++ b/tests/e2e/diff/github-actions/snapshot.txt
@@ -0,0 +1,7 @@
+::error title=parameter-became-required,file=revision.yaml,line=8,col=47,endLine=8,endColumn=51::`limit` query parameter became required.
+::error title=path-removed,file=base.yaml,line=12,col=5,endLine=14,endColumn=42::Path `/orders/{orderId}` was removed.
+
+
+base.yaml vs revision.yaml: diff processed in ms
+
+❌ Diff failed with 2 major changes.
diff --git a/tests/e2e/diff/json-and-next-version/base.yaml b/tests/e2e/diff/json-and-next-version/base.yaml
new file mode 100644
index 0000000000..d9fc441b18
--- /dev/null
+++ b/tests/e2e/diff/json-and-next-version/base.yaml
@@ -0,0 +1,14 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.2.0 }
+paths:
+ /menu:
+ get:
+ summary: List the menu
+ parameters:
+ - { name: limit, in: query, schema: { type: integer } }
+ responses:
+ '200': { description: OK }
+ /orders/{orderId}:
+ delete:
+ responses:
+ '204': { description: Cancelled }
diff --git a/tests/e2e/diff/json-and-next-version/revision.yaml b/tests/e2e/diff/json-and-next-version/revision.yaml
new file mode 100644
index 0000000000..68e0ea18b1
--- /dev/null
+++ b/tests/e2e/diff/json-and-next-version/revision.yaml
@@ -0,0 +1,14 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.3.0 }
+paths:
+ /menu:
+ get:
+ summary: List the whole menu
+ parameters:
+ - { name: limit, in: query, required: true, schema: { type: integer } }
+ responses:
+ '200': { description: OK }
+ /specials:
+ get:
+ responses:
+ '200': { description: OK }
diff --git a/tests/e2e/diff/json-and-next-version/snapshot-json.txt b/tests/e2e/diff/json-and-next-version/snapshot-json.txt
new file mode 100644
index 0000000000..28fc2ccab6
--- /dev/null
+++ b/tests/e2e/diff/json-and-next-version/snapshot-json.txt
@@ -0,0 +1,161 @@
+{
+ "reportVersion": "1",
+ "files": {
+ "base": "base.yaml",
+ "revision": "revision.yaml"
+ },
+ "specVersions": {
+ "base": "oas3_1",
+ "revision": "oas3_1"
+ },
+ "infoVersions": {
+ "base": "1.2.0",
+ "revision": "1.3.0"
+ },
+ "summary": {
+ "major": 2,
+ "minor": 1,
+ "patch": 2
+ },
+ "bump": "major",
+ "changes": [
+ {
+ "key": "#/info",
+ "typeName": "Info",
+ "kind": "modified",
+ "impact": "patch",
+ "verdicts": [],
+ "property": "version",
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/info/version",
+ "line": 2,
+ "col": 31,
+ "value": "1.2.0"
+ },
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/info/version",
+ "line": 2,
+ "col": 31,
+ "value": "1.3.0"
+ }
+ },
+ {
+ "key": "#/paths/~1menu/get",
+ "typeName": "Operation",
+ "kind": "modified",
+ "impact": "patch",
+ "verdicts": [],
+ "property": "summary",
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1menu/get/summary",
+ "line": 6,
+ "col": 16,
+ "value": "List the menu"
+ },
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/paths/~1menu/get/summary",
+ "line": 6,
+ "col": 16,
+ "value": "List the whole menu"
+ }
+ },
+ {
+ "key": "#/paths/~1menu/get/parameters/0",
+ "typeName": "Parameter",
+ "kind": "modified",
+ "impact": "major",
+ "verdicts": [
+ {
+ "ruleId": "parameter-became-required",
+ "impact": "major",
+ "message": "`limit` query parameter became required.",
+ "location": {
+ "file": "revision.yaml",
+ "pointer": "#/paths/~1menu/get/parameters/0/required",
+ "line": 8,
+ "col": 47
+ }
+ }
+ ],
+ "property": "required",
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1menu/get/parameters/0",
+ "line": 8,
+ "col": 11
+ },
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/paths/~1menu/get/parameters/0/required",
+ "line": 8,
+ "col": 47,
+ "value": true
+ }
+ },
+ {
+ "key": "#/paths/~1orders~1{orderId}",
+ "typeName": "PathItem",
+ "kind": "removed",
+ "impact": "major",
+ "verdicts": [
+ {
+ "ruleId": "path-removed",
+ "impact": "major",
+ "message": "Path `/orders/{orderId}` was removed.",
+ "location": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1orders~1{orderId}",
+ "line": 12,
+ "col": 5
+ }
+ }
+ ],
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1orders~1{orderId}",
+ "line": 12,
+ "col": 5,
+ "value": {
+ "delete": {
+ "responses": {
+ "204": {
+ "description": "Cancelled"
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "key": "#/paths/~1specials",
+ "typeName": "PathItem",
+ "kind": "added",
+ "impact": "minor",
+ "verdicts": [],
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/paths/~1specials",
+ "line": 12,
+ "col": 5,
+ "value": {
+ "get": {
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+}
+
+
+base.yaml vs revision.yaml: diff processed in ms
+
+❌ Diff failed with 2 major changes.
diff --git a/tests/e2e/diff/json-and-next-version/snapshot-next-version.txt b/tests/e2e/diff/json-and-next-version/snapshot-next-version.txt
new file mode 100644
index 0000000000..d48c5b1845
--- /dev/null
+++ b/tests/e2e/diff/json-and-next-version/snapshot-next-version.txt
@@ -0,0 +1,6 @@
+2.0.0
+
+
+base.yaml vs revision.yaml: diff processed in ms
+
+❌ Diff failed with 2 major changes.
diff --git a/tests/e2e/diff/markdown-and-html/base.yaml b/tests/e2e/diff/markdown-and-html/base.yaml
new file mode 100644
index 0000000000..d9fc441b18
--- /dev/null
+++ b/tests/e2e/diff/markdown-and-html/base.yaml
@@ -0,0 +1,14 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.2.0 }
+paths:
+ /menu:
+ get:
+ summary: List the menu
+ parameters:
+ - { name: limit, in: query, schema: { type: integer } }
+ responses:
+ '200': { description: OK }
+ /orders/{orderId}:
+ delete:
+ responses:
+ '204': { description: Cancelled }
diff --git a/tests/e2e/diff/markdown-and-html/revision.yaml b/tests/e2e/diff/markdown-and-html/revision.yaml
new file mode 100644
index 0000000000..68e0ea18b1
--- /dev/null
+++ b/tests/e2e/diff/markdown-and-html/revision.yaml
@@ -0,0 +1,14 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.3.0 }
+paths:
+ /menu:
+ get:
+ summary: List the whole menu
+ parameters:
+ - { name: limit, in: query, required: true, schema: { type: integer } }
+ responses:
+ '200': { description: OK }
+ /specials:
+ get:
+ responses:
+ '200': { description: OK }
diff --git a/tests/e2e/diff/markdown-and-html/snapshot-html.txt b/tests/e2e/diff/markdown-and-html/snapshot-html.txt
new file mode 100644
index 0000000000..7ff0ff15c2
--- /dev/null
+++ b/tests/e2e/diff/markdown-and-html/snapshot-html.txt
@@ -0,0 +1,158 @@
+
+
+
+
+API diff
+
+
+
+API diff: base.yaml → revision.yaml
+
+ 2 major
+ 1 minor
+ 2 patch
+ requires a major bump
+ OpenAPI 3.1
+
+
+
+
+ patch
+ modified
+ #/info · version
+
+
+ {
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/info/version",
+ "line": 2,
+ "col": 31,
+ "value": "1.2.0"
+ },
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/info/version",
+ "line": 2,
+ "col": 31,
+ "value": "1.3.0"
+ }
+}
+
+
+
+
+ patch
+ modified
+ #/paths/~1menu/get · summary
+
+
+ {
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1menu/get/summary",
+ "line": 6,
+ "col": 16,
+ "value": "List the menu"
+ },
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/paths/~1menu/get/summary",
+ "line": 6,
+ "col": 16,
+ "value": "List the whole menu"
+ }
+}
+
+
+
+
+ major
+ modified
+ #/paths/~1menu/get/parameters/0 · required
+ `limit` query parameter became required. parameter-became-required
+
+ {
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1menu/get/parameters/0",
+ "line": 8,
+ "col": 11
+ },
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/paths/~1menu/get/parameters/0/required",
+ "line": 8,
+ "col": 47,
+ "value": true
+ }
+}
+
+
+
+
+ major
+ removed
+ #/paths/~1orders~1{orderId}
+ Path `/orders/{orderId}` was removed. path-removed
+
+ {
+ "base": {
+ "file": "base.yaml",
+ "pointer": "#/paths/~1orders~1{orderId}",
+ "line": 12,
+ "col": 5,
+ "value": {
+ "delete": {
+ "responses": {
+ "204": {
+ "description": "Cancelled"
+ }
+ }
+ }
+ }
+ }
+}
+
+
+
+
+ minor
+ added
+ #/paths/~1specials
+
+
+ {
+ "revision": {
+ "file": "revision.yaml",
+ "pointer": "#/paths/~1specials",
+ "line": 12,
+ "col": 5,
+ "value": {
+ "get": {
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ }
+ }
+ }
+ }
+}
+
+
+
\ No newline at end of file
diff --git a/tests/e2e/diff/markdown-and-html/snapshot-markdown.txt b/tests/e2e/diff/markdown-and-html/snapshot-markdown.txt
new file mode 100644
index 0000000000..7d1a39ced3
--- /dev/null
+++ b/tests/e2e/diff/markdown-and-html/snapshot-markdown.txt
@@ -0,0 +1,11 @@
+## API diff: base.yaml → revision.yaml
+
+**2** major · **1** minor · **2** patch · requires a **major** bump
+
+| Impact | Change | Pointer | Location | Details |
+| --- | --- | --- | --- | --- |
+| ⚪ patch | modified | `#/info · version` | revision.yaml:2:31 | |
+| ⚪ patch | modified | `#/paths/~1menu/get · summary` | revision.yaml:6:16 | |
+| 🔴 major | modified | `#/paths/~1menu/get/parameters/0 · required` | revision.yaml:8:47 | \`limit\` query parameter became required. `parameter-became-required` |
+| 🔴 major | removed | `#/paths/~1orders~1{orderId}` | base.yaml:12:5 | Path \`/orders/{orderId}\` was removed. `path-removed` |
+| 🟢 minor | added | `#/paths/~1specials` | revision.yaml:12:5 | |
\ No newline at end of file
diff --git a/tests/e2e/diff/multi-file/base/openapi.yaml b/tests/e2e/diff/multi-file/base/openapi.yaml
new file mode 100644
index 0000000000..93260314ac
--- /dev/null
+++ b/tests/e2e/diff/multi-file/base/openapi.yaml
@@ -0,0 +1,11 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.0.0 }
+paths:
+ /menu:
+ get:
+ responses:
+ '200':
+ description: The menu
+ content:
+ application/json:
+ schema: { $ref: ./schemas/item.yaml }
diff --git a/tests/e2e/diff/multi-file/base/schemas/item.yaml b/tests/e2e/diff/multi-file/base/schemas/item.yaml
new file mode 100644
index 0000000000..a360c5d1e8
--- /dev/null
+++ b/tests/e2e/diff/multi-file/base/schemas/item.yaml
@@ -0,0 +1,4 @@
+type: object
+properties:
+ name: { type: string }
+ price: { type: number }
diff --git a/tests/e2e/diff/multi-file/revision/openapi.yaml b/tests/e2e/diff/multi-file/revision/openapi.yaml
new file mode 100644
index 0000000000..93260314ac
--- /dev/null
+++ b/tests/e2e/diff/multi-file/revision/openapi.yaml
@@ -0,0 +1,11 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.0.0 }
+paths:
+ /menu:
+ get:
+ responses:
+ '200':
+ description: The menu
+ content:
+ application/json:
+ schema: { $ref: ./schemas/item.yaml }
diff --git a/tests/e2e/diff/multi-file/revision/schemas/item.yaml b/tests/e2e/diff/multi-file/revision/schemas/item.yaml
new file mode 100644
index 0000000000..98029bf67d
--- /dev/null
+++ b/tests/e2e/diff/multi-file/revision/schemas/item.yaml
@@ -0,0 +1,3 @@
+type: object
+properties:
+ name: { type: integer }
diff --git a/tests/e2e/diff/multi-file/snapshot.txt b/tests/e2e/diff/multi-file/snapshot.txt
new file mode 100644
index 0000000000..5decd4d0af
--- /dev/null
+++ b/tests/e2e/diff/multi-file/snapshot.txt
@@ -0,0 +1,14 @@
+GET /menu
+ ✖ major modified responses/200/content/application/json/schema/properties/name · type
+ Type of `name` widened from 'string' to 'integer'. (schema-type-changed)
+ at revision/schemas/item.yaml:3:17
+ ✖ major removed responses/200/content/application/json/schema/properties/price
+ Property `price` was removed. (property-removed)
+ at base/schemas/item.yaml:4:10
+
+2 major, 0 minor, 0 patch.
+
+
+base/openapi.yaml vs revision/openapi.yaml: diff processed in ms
+
+❌ Diff failed with 2 major changes.
diff --git a/tests/e2e/diff/openapi-3-0-to-3-1/base.yaml b/tests/e2e/diff/openapi-3-0-to-3-1/base.yaml
new file mode 100644
index 0000000000..232ecc3755
--- /dev/null
+++ b/tests/e2e/diff/openapi-3-0-to-3-1/base.yaml
@@ -0,0 +1,21 @@
+openapi: 3.0.3
+info: { title: Cafe, version: 1.0.0 }
+paths:
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Order' }
+ responses:
+ '201':
+ description: Created
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Order' }
+components:
+ schemas:
+ Order:
+ type: object
+ properties:
+ note: { type: string, nullable: true }
diff --git a/tests/e2e/diff/openapi-3-0-to-3-1/revision.yaml b/tests/e2e/diff/openapi-3-0-to-3-1/revision.yaml
new file mode 100644
index 0000000000..500571b66e
--- /dev/null
+++ b/tests/e2e/diff/openapi-3-0-to-3-1/revision.yaml
@@ -0,0 +1,21 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.0.0 }
+paths:
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Order' }
+ responses:
+ '201':
+ description: Created
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Order' }
+components:
+ schemas:
+ Order:
+ type: object
+ properties:
+ note: { type: [string, 'null'] }
diff --git a/tests/e2e/diff/openapi-3-0-to-3-1/snapshot.txt b/tests/e2e/diff/openapi-3-0-to-3-1/snapshot.txt
new file mode 100644
index 0000000000..e34cbdda4d
--- /dev/null
+++ b/tests/e2e/diff/openapi-3-0-to-3-1/snapshot.txt
@@ -0,0 +1,16 @@
+document
+ · patch modified openapi
+ at revision.yaml:1:10
+
+POST /orders
+ · patch modified requestBody/content/application/json/schema/properties/note · nullable
+ at revision.yaml:21:15
+ · patch modified requestBody/content/application/json/schema/properties/note · type
+ at revision.yaml:21:23
+
+0 major, 0 minor, 3 patch.
+
+
+base.yaml vs revision.yaml: diff processed in ms
+
+✅ Diff passed.
diff --git a/tests/e2e/diff/openapi-breaking-changes/base.yaml b/tests/e2e/diff/openapi-breaking-changes/base.yaml
new file mode 100644
index 0000000000..4f1c8df49b
--- /dev/null
+++ b/tests/e2e/diff/openapi-breaking-changes/base.yaml
@@ -0,0 +1,65 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.2.0 }
+servers:
+ - url: https://api.cafe.test
+security:
+ - apiKey: []
+paths:
+ /menu:
+ get:
+ summary: List the menu
+ parameters:
+ - { name: limit, in: query, schema: { type: integer } }
+ - { name: search, in: query, schema: { type: string } }
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } }
+ /menu/{id}:
+ get:
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string } }
+ responses:
+ '200': { description: OK }
+ delete:
+ responses:
+ '204': { description: Deleted }
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Order' }
+ responses:
+ '201':
+ description: Created
+ headers:
+ Location: { schema: { type: string } }
+webhooks:
+ orderReady:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema:
+ { type: object, properties: { orderId: { type: string }, eta: { type: string } } }
+ responses:
+ '200': { description: OK }
+components:
+ securitySchemes:
+ apiKey: { type: apiKey, in: header, name: X-Key }
+ schemas:
+ MenuItem:
+ type: object
+ properties:
+ name: { type: string }
+ price: { type: number }
+ status: { type: string, enum: [available, sold-out] }
+ Order:
+ type: object
+ required: [items]
+ properties:
+ items: { type: array, items: { type: string } }
+ note: { type: string, maxLength: 200 }
diff --git a/tests/e2e/diff/openapi-breaking-changes/revision.yaml b/tests/e2e/diff/openapi-breaking-changes/revision.yaml
new file mode 100644
index 0000000000..24ebf27912
--- /dev/null
+++ b/tests/e2e/diff/openapi-breaking-changes/revision.yaml
@@ -0,0 +1,62 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.3.0 }
+servers:
+ - url: https://api.cafe.test
+security:
+ - apiKey: []
+paths:
+ /menu:
+ get:
+ summary: List the whole menu
+ parameters:
+ - { name: limit, in: query, required: true, schema: { type: integer } }
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } }
+ /menu/{menuItemId}:
+ get:
+ parameters:
+ - { name: menuItemId, in: path, required: true, schema: { type: string } }
+ responses:
+ '200': { description: OK }
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Order' }
+ responses:
+ '201':
+ description: Created
+ /specials:
+ get:
+ responses:
+ '200': { description: OK }
+webhooks:
+ orderReady:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { type: object, properties: { orderId: { type: string } } }
+ responses:
+ '200': { description: OK }
+components:
+ securitySchemes:
+ apiKey: { type: apiKey, in: header, name: X-Key }
+ schemas:
+ MenuItem:
+ type: object
+ properties:
+ name: { type: string }
+ status: { type: string, enum: [available, sold-out, seasonal] }
+ Order:
+ type: object
+ required: [items, pickupAt]
+ properties:
+ items: { type: array, items: { type: string } }
+ note: { type: string, maxLength: 100 }
+ pickupAt: { type: string, format: date-time }
diff --git a/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt b/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt
new file mode 100644
index 0000000000..60fa49eab4
--- /dev/null
+++ b/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt
@@ -0,0 +1,61 @@
+info
+ · patch modified version
+ at revision.yaml:2:31
+
+GET /menu
+ ✖ major modified parameters/0 · required
+ `limit` query parameter became required. (parameter-became-required)
+ at revision.yaml:12:47
+ ✖ major removed parameters/1
+ `search` query parameter was removed. (parameter-removed)
+ at base.yaml:13:11
+ ✖ major removed responses/200/content/application/json/schema/items/properties/price
+ Property `price` of `MenuItem` was removed. (property-removed)
+ at base.yaml:58:16
+ ✖ major modified responses/200/content/application/json/schema/items/properties/status · enum
+ Enum of `status` gained values: 'seasonal'. (enum-values-added)
+ at revision.yaml:55:39
+ · patch modified summary
+ at revision.yaml:10:16
+
+/menu/{menuItemId}
+ · patch modified key
+ at revision.yaml:20:5
+
+DELETE /menu/{menuItemId}
+ ✖ major removed
+ Operation `DELETE /menu/{menuItemId}` was removed. (operation-removed)
+ at base.yaml:27:7
+
+GET /menu/{menuItemId}
+ · patch modified parameters/0 · name
+ at revision.yaml:22:19
+
+POST /orders
+ ✖ major modified requestBody/content/application/json/schema · required
+ Properties of `Order` became required: `pickupAt`. (required-properties-added)
+ at revision.yaml:58:17
+ ✖ major modified requestBody/content/application/json/schema/properties/note · maxLength
+ `maxLength` of `note` changed from '200' to '100'. (schema-constraint-changed)
+ at revision.yaml:61:42
+ ✖ major removed responses/201/headers
+ All headers of response `201` were removed. (response-header-removed)
+ at base.yaml:39:13
+ ✔ minor added requestBody/content/application/json/schema/properties/pickupAt
+ at revision.yaml:62:19
+
+/specials
+ ✔ minor added
+ at revision.yaml:35:5
+
+POST orderReady (webhook)
+ ✖ major removed requestBody/content/application/json/schema/properties/eta
+ Property `eta` was removed. (property-removed)
+ at base.yaml:47:77
+
+9 major, 2 minor, 4 patch.
+
+
+base.yaml vs revision.yaml: diff processed in ms
+
+❌ info.version went 1.2.0 → 1.3.0, but these changes require a major bump (2.0.0).
diff --git a/tests/e2e/diff/openapi-breaking-changes/snapshot.txt b/tests/e2e/diff/openapi-breaking-changes/snapshot.txt
new file mode 100644
index 0000000000..eb5410a943
--- /dev/null
+++ b/tests/e2e/diff/openapi-breaking-changes/snapshot.txt
@@ -0,0 +1,61 @@
+info
+ · patch modified version
+ at revision.yaml:2:31
+
+GET /menu
+ ✖ major modified parameters/0 · required
+ `limit` query parameter became required. (parameter-became-required)
+ at revision.yaml:12:47
+ ✖ major removed parameters/1
+ `search` query parameter was removed. (parameter-removed)
+ at base.yaml:13:11
+ ✖ major removed responses/200/content/application/json/schema/items/properties/price
+ Property `price` of `MenuItem` was removed. (property-removed)
+ at base.yaml:58:16
+ ✖ major modified responses/200/content/application/json/schema/items/properties/status · enum
+ Enum of `status` gained values: 'seasonal'. (enum-values-added)
+ at revision.yaml:55:39
+ · patch modified summary
+ at revision.yaml:10:16
+
+/menu/{menuItemId}
+ · patch modified key
+ at revision.yaml:20:5
+
+DELETE /menu/{menuItemId}
+ ✖ major removed
+ Operation `DELETE /menu/{menuItemId}` was removed. (operation-removed)
+ at base.yaml:27:7
+
+GET /menu/{menuItemId}
+ · patch modified parameters/0 · name
+ at revision.yaml:22:19
+
+POST /orders
+ ✖ major modified requestBody/content/application/json/schema · required
+ Properties of `Order` became required: `pickupAt`. (required-properties-added)
+ at revision.yaml:58:17
+ ✖ major modified requestBody/content/application/json/schema/properties/note · maxLength
+ `maxLength` of `note` changed from '200' to '100'. (schema-constraint-changed)
+ at revision.yaml:61:42
+ ✖ major removed responses/201/headers
+ All headers of response `201` were removed. (response-header-removed)
+ at base.yaml:39:13
+ ✔ minor added requestBody/content/application/json/schema/properties/pickupAt
+ at revision.yaml:62:19
+
+/specials
+ ✔ minor added
+ at revision.yaml:35:5
+
+POST orderReady (webhook)
+ ✖ major removed requestBody/content/application/json/schema/properties/eta
+ Property `eta` was removed. (property-removed)
+ at base.yaml:47:77
+
+9 major, 2 minor, 4 patch.
+
+
+base.yaml vs revision.yaml: diff processed in ms
+
+❌ Diff failed with 9 major changes.
diff --git a/tests/e2e/diff/openapi-compatible-changes/base.yaml b/tests/e2e/diff/openapi-compatible-changes/base.yaml
new file mode 100644
index 0000000000..4f1c8df49b
--- /dev/null
+++ b/tests/e2e/diff/openapi-compatible-changes/base.yaml
@@ -0,0 +1,65 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.2.0 }
+servers:
+ - url: https://api.cafe.test
+security:
+ - apiKey: []
+paths:
+ /menu:
+ get:
+ summary: List the menu
+ parameters:
+ - { name: limit, in: query, schema: { type: integer } }
+ - { name: search, in: query, schema: { type: string } }
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } }
+ /menu/{id}:
+ get:
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string } }
+ responses:
+ '200': { description: OK }
+ delete:
+ responses:
+ '204': { description: Deleted }
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Order' }
+ responses:
+ '201':
+ description: Created
+ headers:
+ Location: { schema: { type: string } }
+webhooks:
+ orderReady:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema:
+ { type: object, properties: { orderId: { type: string }, eta: { type: string } } }
+ responses:
+ '200': { description: OK }
+components:
+ securitySchemes:
+ apiKey: { type: apiKey, in: header, name: X-Key }
+ schemas:
+ MenuItem:
+ type: object
+ properties:
+ name: { type: string }
+ price: { type: number }
+ status: { type: string, enum: [available, sold-out] }
+ Order:
+ type: object
+ required: [items]
+ properties:
+ items: { type: array, items: { type: string } }
+ note: { type: string, maxLength: 200 }
diff --git a/tests/e2e/diff/openapi-compatible-changes/revision.yaml b/tests/e2e/diff/openapi-compatible-changes/revision.yaml
new file mode 100644
index 0000000000..48d1c84f03
--- /dev/null
+++ b/tests/e2e/diff/openapi-compatible-changes/revision.yaml
@@ -0,0 +1,70 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.3.0 }
+servers:
+ - url: https://api.cafe.test
+security:
+ - apiKey: []
+paths:
+ /menu:
+ get:
+ summary: List the menu
+ parameters:
+ - { name: limit, in: query, schema: { type: integer } }
+ - { name: search, in: query, schema: { type: string } }
+ - { name: category, in: query, schema: { type: string } }
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } }
+ /menu/{id}:
+ get:
+ parameters:
+ - { name: id, in: path, required: true, schema: { type: string } }
+ responses:
+ '200': { description: OK }
+ delete:
+ responses:
+ '204': { description: Deleted }
+ /specials:
+ get:
+ responses:
+ '200': { description: OK }
+ /orders:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Order' }
+ responses:
+ '201':
+ description: Created
+ headers:
+ Location: { schema: { type: string } }
+webhooks:
+ orderReady:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema:
+ { type: object, properties: { orderId: { type: string }, eta: { type: string } } }
+ responses:
+ '200': { description: OK }
+components:
+ securitySchemes:
+ apiKey: { type: apiKey, in: header, name: X-Key }
+ schemas:
+ MenuItem:
+ type: object
+ properties:
+ name: { type: string }
+ price: { type: number }
+ status: { type: string, enum: [available, sold-out] }
+ Order:
+ type: object
+ required: [items]
+ properties:
+ items: { type: array, items: { type: string } }
+ note: { type: string, maxLength: 200 }
diff --git a/tests/e2e/diff/openapi-compatible-changes/snapshot-fail-on-minor.txt b/tests/e2e/diff/openapi-compatible-changes/snapshot-fail-on-minor.txt
new file mode 100644
index 0000000000..2542b0714f
--- /dev/null
+++ b/tests/e2e/diff/openapi-compatible-changes/snapshot-fail-on-minor.txt
@@ -0,0 +1,18 @@
+info
+ · patch modified version
+ at revision.yaml:2:31
+
+GET /menu
+ ✔ minor added parameters/2
+ at revision.yaml:14:11
+
+/specials
+ ✔ minor added
+ at revision.yaml:31:5
+
+0 major, 2 minor, 1 patch.
+
+
+base.yaml vs revision.yaml: diff processed in ms
+
+❌ Diff failed with 2 minor changes.
diff --git a/tests/e2e/diff/openapi-compatible-changes/snapshot.txt b/tests/e2e/diff/openapi-compatible-changes/snapshot.txt
new file mode 100644
index 0000000000..9704fdfc3b
--- /dev/null
+++ b/tests/e2e/diff/openapi-compatible-changes/snapshot.txt
@@ -0,0 +1,18 @@
+info
+ · patch modified version
+ at revision.yaml:2:31
+
+GET /menu
+ ✔ minor added parameters/2
+ at revision.yaml:14:11
+
+/specials
+ ✔ minor added
+ at revision.yaml:31:5
+
+0 major, 2 minor, 1 patch.
+
+
+base.yaml vs revision.yaml: diff processed in ms
+
+✅ Diff passed.
diff --git a/tests/e2e/diff/plugin/base.yaml b/tests/e2e/diff/plugin/base.yaml
new file mode 100644
index 0000000000..db01368d95
--- /dev/null
+++ b/tests/e2e/diff/plugin/base.yaml
@@ -0,0 +1,8 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.0.0 }
+paths:
+ /menu:
+ get:
+ tags: [menu, public]
+ responses:
+ '200': { description: OK }
diff --git a/tests/e2e/diff/plugin/plugins/cafe.cjs b/tests/e2e/diff/plugin/plugins/cafe.cjs
new file mode 100644
index 0000000000..880895fc54
--- /dev/null
+++ b/tests/e2e/diff/plugin/plugins/cafe.cjs
@@ -0,0 +1,18 @@
+// Clients of the cafe route operations by their tags, so removing one is a breaking change.
+function TagRemoved() {
+ return {
+ Operation(change, { report }) {
+ if (change.kind !== 'modified' || change.property !== 'tags') return;
+ const after = change.revision.value ?? [];
+ const removed = (change.base.value ?? []).filter((tag) => !after.includes(tag));
+ if (removed.length) report({ message: `Tags removed: ${removed.join(', ')}.` });
+ },
+ };
+}
+
+module.exports = function cafePlugin() {
+ return {
+ id: 'cafe',
+ diff: { oas3: { 'tag-removed': TagRemoved } },
+ };
+};
diff --git a/tests/e2e/diff/plugin/redocly.yaml b/tests/e2e/diff/plugin/redocly.yaml
new file mode 100644
index 0000000000..ff7b3ea68c
--- /dev/null
+++ b/tests/e2e/diff/plugin/redocly.yaml
@@ -0,0 +1,6 @@
+extends:
+ - diff-recommended
+plugins:
+ - plugins/cafe.cjs
+diff:
+ cafe/tag-removed: major
diff --git a/tests/e2e/diff/plugin/revision.yaml b/tests/e2e/diff/plugin/revision.yaml
new file mode 100644
index 0000000000..91b4b4bd5d
--- /dev/null
+++ b/tests/e2e/diff/plugin/revision.yaml
@@ -0,0 +1,8 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.0.0 }
+paths:
+ /menu:
+ get:
+ tags: [menu]
+ responses:
+ '200': { description: OK }
diff --git a/tests/e2e/diff/plugin/snapshot.txt b/tests/e2e/diff/plugin/snapshot.txt
new file mode 100644
index 0000000000..13e52842f0
--- /dev/null
+++ b/tests/e2e/diff/plugin/snapshot.txt
@@ -0,0 +1,11 @@
+GET /menu
+ ✖ major modified tags
+ Tags removed: public. (cafe/tag-removed)
+ at revision.yaml:6:13
+
+1 major, 0 minor, 0 patch.
+
+
+base.yaml vs revision.yaml: diff processed in ms
+
+❌ Diff failed with 1 major change.