diff --git a/.changeset/purple-onions-shave.md b/.changeset/purple-onions-shave.md new file mode 100644 index 0000000000..45c7315860 --- /dev/null +++ b/.changeset/purple-onions-shave.md @@ -0,0 +1,5 @@ +--- +'@redocly/cli': minor +--- + +Added an experimental `diff` command that compares two API descriptions and reports what was added, removed, and changed. diff --git a/docs/@v2/commands/diff.md b/docs/@v2/commands/diff.md new file mode 100644 index 0000000000..d0abb1fcaf --- /dev/null +++ b/docs/@v2/commands/diff.md @@ -0,0 +1,210 @@ +# `diff` + +## Introduction + +{% admonition type="warning" name="Important" %} +The `diff` command is considered an experimental feature. +This means it's still a work in progress and may go through major changes, including its output formats and rule ids. +{% /admonition %} + +The `diff` command compares two API descriptions. +It reports what you added, removed, and changed. +For OpenAPI 3.x and AsyncAPI 3 it also marks each change as breaking or non-breaking. +Use it to find a breaking change before your consumers do. + +## Usage + +```bash +redocly diff +redocly diff v1/openapi.yaml v2/openapi.yaml +redocly diff https://example.com/openapi.yaml openapi.yaml --format=json +redocly diff main@v1 main@v2 --fail-on=breaking +``` + +## Options + +| Option | Type | Description | +| ------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| base | string | **REQUIRED.** Path, URL, or config alias of the base (older) API description. | +| revision | string | **REQUIRED.** Path, URL, or config alias of the revision (newer) API description. | +| --config | string | Specify path to the [configuration file](../configuration/index.md). | +| --fail-on | string | Exit with code `1` when changes at this level are found.
**Possible values:** `breaking`, `none`. Default value is `breaking`. | +| --format | string | Format for the output.
**Possible values:** `stylish`, `json`, `markdown`, `html`, `codeframe`, `checkstyle`, `codeclimate`, `summary`, `github-actions`, `junit`. 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 a file instead of stdout. Supported by the `stylish`, `json`, `markdown`, and `html` formats. | +| --version | boolean | Show version number. | + +## How it works + +- The command bundles both descriptions. + This resolves every external `$ref` before the comparison starts. +- Some list items have a natural identity. + For example, a parameter is identified by its `in` and `name` values. + The command matches these items by that identity, so a different order is not a change. +- The command reports a change to a shared component once, at the location of the component. + To decide if that change is breaking, the command looks at where you use the component: in requests, in responses, or in both. +- If the command finds a change that it cannot judge, it reports `breaking`. + A `$ref` that points to a different target is such a change. +- The command compares the structure of every specification type that Redocly CLI supports. + It marks changes as breaking or non-breaking for OpenAPI 3.x and AsyncAPI 3. +- The API sends the requests under `callbacks` and `webhooks`, so the direction below those keys is the opposite one. + The command judges their request body the way it judges a response. + It judges their responses the way it judges a request. +- AsyncAPI 3 declares the direction instead of implying it from the position: + - An operation with `action: receive` gets the message from another application. + The command judges its payload the way it judges a request body. + - An operation with `action: send` produces the message. + The command judges its payload the way it judges a response. + - A `reply` travels in the opposite direction. + - A channel takes its direction from the operations that reference it. + +{% admonition type="info" name="Limitations" %} +The command finds the common breaking changes that the rule catalog below describes. +It does not find every possible breaking change. + +If you rename a component, such as a schema or a parameter, the command reports a removal and an addition. +It does not match the new component to the old one. +The report marks the new `$ref` target as `breaking` with the rule `ref-target-changed`. + +The command cannot compare two documents from different specification families, such as OpenAPI 2.0 and OpenAPI 3.1. + +The subschemas inside `allOf`, `oneOf`, and `anyOf` have no natural identity. +The command matches them by position, so a different order can read as a change. + +`readOnly` and `writeOnly` do not refine the direction. +If you use a component in a request and in a response, the command judges it under both directions and keeps the more severe verdict. + +Items that do have an identity, such as `servers` matched by URL, keep their verdict when you reorder them. +The command reports no change, although the order of `servers` can carry a meaning. + +A comparison between OpenAPI 3.0 and OpenAPI 3.1 can report differences that are only syntax. +For example, `nullable: true` and `type: [..., "null"]` describe the same schema. + +In AsyncAPI 3, a channel that no operation references has no direction. +The command reports the changes to its payload without a breaking verdict. + +In AsyncAPI 3, the command compares channel parameters, bindings, and `securitySchemes`, but no rule judges them yet. +{% /admonition %} + +## Breaking change rules + +### OpenAPI 3.x + +| Rule id | Description | +| --------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `additional-properties-changed` | The `additionalProperties` value decides which extra properties an object accepts. | +| `enum-values-added` | Adding enum values to response data may send clients values they never handled. | +| `enum-values-removed` | Removing enum values restricts what clients may send. | +| `media-type-removed` | Removing a media type breaks clients that produce or consume it. | +| `numeric-range-changed` | Moving a numeric bound changes which values the API accepts or returns. | +| `operation-removed` | Removing an operation breaks all of its consumers. | +| `parameter-added-required` | Adding a new required parameter breaks clients that do not send it. | +| `parameter-became-required` | Marking an existing request parameter as required breaks clients that omit it. | +| `parameter-removed` | Removing a request parameter breaks clients that send it. | +| `parameter-serialization-changed` | Changing how a parameter is serialized breaks clients that encode it the old way. | +| `path-removed` | Removing a path breaks all consumers of its operations. | +| `property-removed-from-response` | Removing a response property breaks clients that read it. | +| `ref-target-changed` | The `$ref` points to a different target. The diff cannot check that the new target is equivalent. | +| `request-body-became-required` | Requiring a body that used to be optional breaks clients that send none. | +| `request-body-removed` | When the request body is removed, the API no longer reads the data that clients send. | +| `required-properties-added` | Requiring new request properties breaks clients that do not send them. | +| `required-properties-removed` | A response property that is no longer required can be absent, which breaks clients that read it. | +| `response-header-removed` | Removing a response header breaks clients that read it. | +| `response-removed` | Removing a response breaks clients that handle it. | +| `schema-combinator-changed` | Adding or dropping a subschema changes which shapes the API accepts. | +| `schema-format-changed` | A format constrains the accepted values beyond the type itself. | +| `schema-type-changed` | A narrower type rejects values that clients send. A wider type returns values that clients do not handle. | +| `security-requirement-added` | Requiring authentication where there was none breaks every existing client. | +| `security-scheme-changed` | Changing how a scheme authenticates breaks clients that implemented the old way. | +| `security-scheme-removed` | Removing a scheme leaves clients with no way to authenticate through it. | +| `security-scopes-added` | A new required scope breaks clients whose credentials do not include it. | +| `string-length-changed` | Changing a string constraint changes which values the API accepts or returns. | + +### AsyncAPI 3 + +An AsyncAPI 3 payload is a schema, so every schema rule above also applies to it. +The direction comes from the `action` value of the operation. +The command also runs `operation-removed` and `ref-target-changed` for AsyncAPI 3. +These rules apply to AsyncAPI 3 only: + +| Rule id | Description | +| ------------------------------ | -------------------------------------------------------------------------------- | +| `channel-address-changed` | The address is what clients publish to and subscribe on. | +| `channel-removed` | Removing a channel leaves its publishers and subscribers with nowhere to go. | +| `message-content-type-changed` | A message in another content type cannot be decoded by existing clients. | +| `message-removed` | Removing a message breaks every application that sends or receives it. | +| `operation-action-changed` | Swapping send and receive reverses which side of the channel the API is on. | +| `server-removed` | Removing a server leaves clients connected to a host that no longer serves them. | + +## Verdicts + +Each change carries every verdict that a rule gave it, in a `verdicts` array. +A verdict has three fields: + +- `ruleId`: the rule that gave the verdict, for example `parameter-became-required` +- `compat`: the classification, either `breaking` or `non-breaking` +- `message`: one sentence that says what the change does + +More than one rule can judge the same change. +The `compat` field of the change itself holds the most severe of those verdicts. +Every output format shows that field. + +### Locations + +Each change reports the file, the line, and the column of the affected node on both sides: `base` and `revision`. + +The `stylish` format groups the changes per operation, for example `GET /pets`. +Each change carries one `file:line:col` reference that you can click. +The reference points to the base file for a removal, and to the revision file for every other change. + +A description can span several files. +If the command pulled a node in through a `$ref` to another file, the reference points to line `1`, column `1` of the root file. + +### Path parameter renaming + +If you rename a path parameter, for example from `/pets/{id}` to `/pets/{petId}`, the command treats the path as the same endpoint. +It does not report a removal and an addition. +The report holds two non-breaking changes: one for the path template, and one for the `name` of the parameter. + +If more than one path differs only in the name of a parameter, the match is ambiguous. +The command then compares those paths by their literal keys. + +A renamed path can hold operations with `callbacks`. +If a path item inside such a callback declares a parameter with the name you renamed, the report can show that callback parameter as removed and added. +This is a structural change in the report only, not a change to your API. + +## Examples + +### Fail a CI pipeline on breaking changes + +By default the command exits with code `1` when it finds a breaking change. +Use it as a pull request check: + +```bash +redocly diff main-openapi.yaml pr-openapi.yaml +# exit code 1 when breaking changes are found +``` + +### Generate an HTML report + +To write a report that you can share, use `--format=html` together with `--output`: + +```bash +redocly diff v1.yaml v2.yaml --format=html -o diff-report.html +``` + +### Annotate a pull request + +The `codeframe`, `checkstyle`, `codeclimate`, `summary`, `github-actions`, and `junit` formats come from the `lint` command. +Your existing CI integrations accept the diff report as it is. +With `github-actions`, every breaking change becomes an annotation on the pull request: + +```bash +redocly diff main-openapi.yaml pr-openapi.yaml --format=github-actions +``` + +These formats describe the breaking changes only, because each entry carries a severity. +Use `json` when you need every change, including the non-breaking ones. +These formats print to stdout and do not support `--output`. +The `junit` report names its test suite `redocly lint`. diff --git a/docs/@v2/commands/index.md b/docs/@v2/commands/index.md index 3d4107239e..ae0b3d69bb 100644 --- a/docs/@v2/commands/index.md +++ b/docs/@v2/commands/index.md @@ -14,6 +14,7 @@ Documentation commands: API management commands: - [`bundle`](bundle.md) Bundle API description. +- [`diff`](diff.md) Compare two API descriptions and detect breaking changes [experimental feature]. - [`generate-client`](generate-client.md) Generate a typed TypeScript client from an OpenAPI description [experimental feature]. - [`join`](join.md) Join API descriptions [experimental feature]. - [`score`](score.md) Score an API for integration simplicity and AI agent readiness. diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index a7720f844e..673a7c4632 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 diff --git a/docs/superpowers/plans/2026-07-07-diff-command-improvements.md b/docs/superpowers/plans/2026-07-07-diff-command-improvements.md new file mode 100644 index 0000000000..896098dea1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-diff-command-improvements.md @@ -0,0 +1,1629 @@ +# Diff Command Crucial Improvements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the silent `--fail-on` failure, keep every rule verdict per change, add real file/line/col locations, simplify the severity model to `breaking`/`non-breaking`, group stylish output per operation, align `--format` typing with core's `OutputFormat`, drop dead code, and match renamed path parameters. + +**Architecture:** All work stays in `packages/cli/src/commands/diff` except a one-line union extension in `packages/core/src/format/format.ts`. The engine pipeline becomes: collect → **align** (compare-stage aliasing of unambiguously renamed path templates into the base pointer space, emitting an explicit rename change) → compare → classify (now keeping ALL verdicts per change) → **locate** (attach `file`/`line`/`col` to each change side via `Source` + `getLineColLocation`). Collect is NOT modified for path matching — stable pointers stay truthful to each document, and renames are visible as explicit changes. + +**Tech Stack:** TypeScript (ESM, `.js` import suffixes required), vitest, `@redocly/openapi-core` public API only. + +## Global Constraints + +- The diff engine must consume ONLY the public `@redocly/openapi-core` API; the sole change to `packages/core` in this plan is adding `'html'` to the `OutputFormat` union (Task 4). +- The JSON output stays `version: '1'` — the command is experimental and unreleased, so shape changes need no compatibility shim. +- All imports use explicit `.js` suffixes (ESM). +- Run unit tests with: `VITEST_SUITE=unit npx vitest run ` from the repo root. +- Typecheck with: `npm run typecheck` from the repo root. +- Commit after every task with a conventional-commit message ending in: + `Co-Authored-By: Claude Fable 5 ` +- Severity model decision (pre-approved by the user): `Compat = 'breaking' | 'non-breaking'`. The former `warning` level (used only by `ref-target-changed`) is folded into `breaking` — a `$ref` retarget cannot be statically verified as compatible, so the conservative verdict is breaking. +- Path-rename matching happens at the COMPARE stage (decision by the user): collect-time key rewriting is forbidden — it risks collisions and hides the fact that a rename happened. + +## File Structure + +| File | Action | Responsibility | +| ------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/cli/src/commands/diff/engine/types.ts` | Modify | `Compat` shrinks to 2 values; `DiffSummary` loses `warning`; `Change` gets `verdicts: ChangeVerdict[]` (drops `ruleIds`/`message`); `ChangeSide` gains `file`/`line`/`col`; drop `worstOf` + `warning()` | +| `packages/cli/src/commands/diff/engine/output-schema.ts` | Delete | Unused outside tests | +| `packages/cli/src/commands/diff/engine/classify/index.ts` | Modify | Keep every verdict, worst-first; `compat` = worst | +| `packages/cli/src/commands/diff/engine/classify/rules/ref-rules.ts` | Modify | `ref-target-changed` becomes `breaking` | +| `packages/cli/src/commands/diff/engine/align-paths.ts` | Create | Compare-stage aliasing of renamed path templates | +| `packages/cli/src/commands/diff/engine/locate.ts` | Create | Attach `file`/`line`/`col` to change sides | +| `packages/cli/src/commands/diff/engine/index.ts` | Modify | 2-bucket summary; call `alignRenamedPaths` + `locateChanges`; emit rename changes | +| `packages/cli/src/commands/diff/fail-on.ts` | Create | `DiffFailOn` type + pure `getDiffFailure()` gate | +| `packages/cli/src/commands/diff/index.ts` | Modify | Print failure via logger, `--output` note, `printExecutionTime`, `DiffOutputFormat = Extract` | +| `packages/cli/src/commands/diff/serializers/stylish.ts` | Rewrite | Group changes per `METHOD /path`, render ALL verdicts, clickable `file:line:col` | +| `packages/cli/src/commands/diff/serializers/{markdown,html}.ts` | Modify | Drop `warning` labels; render all verdicts | +| `packages/core/src/format/format.ts` | Modify | Add `'html'` to `OutputFormat` union | +| `packages/cli/src/index.ts` | Modify | yargs: `--fail-on` choices `breaking`/`none`; format choices typed via `DiffOutputFormat` | +| `docs/@v2/commands/diff.md` | Modify | Reflect all of the above | +| Existing tests under `commands/diff/**/__tests__/` | Modify | Follow each task | + +--- + +### Task 1: Two-level `Compat` + dead-code removal + +**Files:** + +- Modify: `packages/cli/src/commands/diff/engine/types.ts` +- Modify: `packages/cli/src/commands/diff/engine/classify/rules/ref-rules.ts` +- Modify: `packages/cli/src/commands/diff/engine/index.ts` (summary reduce) +- Delete: `packages/cli/src/commands/diff/engine/output-schema.ts` +- Modify: `packages/cli/src/commands/diff/index.ts` (`DiffFailOn`, gate expression) +- Modify: `packages/cli/src/index.ts` (`--fail-on` choices) +- Modify: `packages/cli/src/commands/diff/serializers/stylish.ts`, `markdown.ts`, `html.ts` (drop `warning` entries) +- Test: `engine/__tests__/types.test.ts`, `engine/__tests__/rules-schema.test.ts`, `engine/__tests__/diff-documents.test.ts`, `__tests__/serializers.test.ts`, `__tests__/serializers-rich.test.ts` + +**Interfaces:** + +- Produces: `type Compat = 'breaking' | 'non-breaking'`; `interface DiffSummary { breaking: number; nonBreaking: number }`; `type DiffFailOn = 'breaking' | 'none'` (still in `diff/index.ts` for now; Task 3 moves it to `fail-on.ts`). +- `worstOf`, `warning()`, `DIFF_OUTPUT_SCHEMA` no longer exist. + +- [ ] **Step 1: Update the type-helper test to the two-level model** + +Replace the whole of `packages/cli/src/commands/diff/engine/__tests__/types.test.ts` with: + +```ts +import { compatRank, breaking } from '../types.js'; + +describe('diff types helpers', () => { + it('ranks compat levels', () => { + expect(compatRank('breaking')).toBeGreaterThan(compatRank('non-breaking')); + }); + + it('builds breaking verdicts', () => { + expect(breaking('boom')).toEqual({ compat: 'breaking', message: 'boom' }); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/types.test.ts` +Expected: red (typecheck or test failure) before Step 3. + +- [ ] **Step 3: Shrink `Compat` in `engine/types.ts`** + +```ts +export type Compat = 'breaking' | 'non-breaking'; +``` + +```ts +export interface DiffSummary { + breaking: number; + nonBreaking: number; +} +``` + +```ts +const COMPAT_RANK: Record = { breaking: 1, 'non-breaking': 0 }; +``` + +Delete the `worstOf` and `warning` functions entirely. Keep `compatRank` and `breaking`. + +- [ ] **Step 4: Make `ref-target-changed` breaking** + +Replace `packages/cli/src/commands/diff/engine/classify/rules/ref-rules.ts` with: + +```ts +import { breaking, type DiffRule } from '../../types.js'; + +// Pointer-aligned comparison cannot verify whether two different targets are +// content-equivalent (spec §7.3, §13) — the conservative verdict is breaking. +export const refTargetChanged: DiffRule = { + id: 'ref-target-changed', + description: + 'A $ref now points to a different target; content equivalence cannot be verified automatically.', + visit(change, ctx) { + if (change.kind !== 'changed' || !change.property) return; + const wasRef = change.property in (ctx.base(change.pointer)?.refs ?? {}); + const isRefNow = change.property in (ctx.revision(change.pointer)?.refs ?? {}); + if (!wasRef && !isRefNow) return; + return breaking( + `Reference target changed from '${change.base?.value}' to '${change.revision?.value}' — content equivalence cannot be verified.` + ); + }, +}; +``` + +- [ ] **Step 5: Two-bucket summary in `engine/index.ts`** + +Replace the summary reduce with: + +```ts +const summary = changes.reduce( + (acc, change) => { + if (change.compat === 'breaking') acc.breaking++; + else acc.nonBreaking++; + return acc; + }, + { breaking: 0, nonBreaking: 0 } +); +``` + +- [ ] **Step 6: Delete the unused output schema** + +```bash +git rm packages/cli/src/commands/diff/engine/output-schema.ts +``` + +In `engine/__tests__/diff-documents.test.ts`: delete the entire `it('validates against the published output schema', …)` block and the two imports it used (`Ajv` and `DIFF_OUTPUT_SCHEMA`). Update the summary assertion in the first test to: + +```ts +expect(result.summary).toEqual({ breaking: 1, nonBreaking: 2 }); +``` + +- [ ] **Step 7: Update the CLI gate and yargs choices** + +In `packages/cli/src/commands/diff/index.ts`: + +```ts +export type DiffFailOn = 'breaking' | 'none'; +``` + +and replace the failure block at the end of `handleDiff` with: + +```ts +const failed = argv['fail-on'] === 'breaking' && result.summary.breaking > 0; +if (failed) { + throw new AbortFlowError('Diff failed.'); +} +``` + +In `packages/cli/src/index.ts`, `diff` command options, change `fail-on` to: + +```ts + 'fail-on': { + description: 'Exit with a non-zero code when changes of this level are found.', + choices: ['breaking', 'none'] as ReadonlyArray<'breaking' | 'none'>, + default: 'breaking' as const, + }, +``` + +- [ ] **Step 8: Drop `warning` from all three serializers** + +`serializers/stylish.ts` (Task 6 rewrites this file — here only make it compile): + +```ts +const SEVERITY_ORDER: Compat[] = ['breaking', 'non-breaking']; + +const ICONS: Record = { + breaking: red('✖ breaking '), + 'non-breaking': green('✔ non-breaking'), +}; +``` + +and the summary line: + +```ts +const { breaking, nonBreaking } = result.summary; +lines.push('', `${red(`${breaking} breaking`)}, ${green(`${nonBreaking} non-breaking`)}.`); +``` + +Remove the now-unused `yellow` import. + +`serializers/markdown.ts`: + +```ts +const IMPACT_LABEL: Record = { + breaking: '🔴 breaking', + 'non-breaking': '🟢 non-breaking', +}; +``` + +and the summary line: + +```ts + `**${breaking}** breaking · **${nonBreaking}** non-breaking`, +``` + +(destructure only `breaking` and `nonBreaking` from `result.summary`). + +`serializers/html.ts`: + +```ts +const IMPACT_CLASS: Record = { + breaking: 'breaking', + 'non-breaking': 'ok', +}; +``` + +Remove the `.warning .badge` CSS rule and the warning `` from the summary paragraph; destructure only `breaking` and `nonBreaking`. + +- [ ] **Step 9: Update remaining test expectations** + +- `engine/__tests__/rules-schema.test.ts:111`: change `.toBe('warning')` → `.toBe('breaking')`. +- `__tests__/serializers.test.ts`: in the fixture, change the change object with `compat: 'warning'` to `compat: 'breaking'`; set `summary: { breaking: 2, nonBreaking: 1 }`; replace the three ordering assertions with `expect(breakingIndex).toBeLessThan(nonBreakingIndex);` keeping `breakingIndex`/`nonBreakingIndex` lookups; change `expect(output).toContain('1 warning')` to `expect(output).toContain('2 breaking')`. +- `__tests__/serializers-rich.test.ts`: change the fixture summary to `{ breaking: 1, nonBreaking: 0 }`. If any assertion mentions `warning`, delete it. + +- [ ] **Step 10: Verify green** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff && npm run typecheck` +Expected: PASS, no type errors. + +- [ ] **Step 11: Commit** + +```bash +git add -A packages/cli +git commit -m "refactor(cli): simplify diff compat model to breaking/non-breaking + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: Keep every rule verdict per change + +Today `classifyChanges` keeps a flat `ruleIds` list but only the WORST verdict's message survives. Replace both fields with a `verdicts` array holding every verdict (worst-first); `compat` stays the worst verdict's level. + +**Files:** + +- Modify: `packages/cli/src/commands/diff/engine/types.ts` +- Modify: `packages/cli/src/commands/diff/engine/classify/index.ts` +- Modify: `packages/cli/src/commands/diff/serializers/stylish.ts`, `markdown.ts`, `html.ts` +- Test: `engine/__tests__/classify.test.ts`, `engine/__tests__/diff-documents.test.ts`, `__tests__/serializers.test.ts`, `__tests__/serializers-rich.test.ts` + +**Interfaces:** + +- Consumes: `Compat`/`compatRank` from Task 1. +- Produces: + +```ts +export interface Verdict { + compat: Compat; + message: string; +} + +export interface ChangeVerdict extends Verdict { + ruleId: string; +} +``` + +`Change` drops `ruleIds`/`message` and gains `verdicts?: ChangeVerdict[]` (present only when at least one rule fired; sorted worst-first, ties by `ruleId`). `RawChange = Omit`. + +- [ ] **Step 1: Write the failing test** + +In `engine/__tests__/classify.test.ts`: + +- In the first test, replace the `ruleIds`/`message` assertions with: + +```ts +expect(change.verdicts).toEqual([ + { ruleId: 'operation-removed', compat: 'breaking', message: 'Operation was removed.' }, +]); +``` + +- In the second test, replace the `ruleIds` assertion with: + +```ts +expect(change.verdicts).toEqual([ + { ruleId: 'path-removed', compat: 'breaking', message: 'Path was removed.' }, +]); +``` + +- In the third test, replace `expect(change.ruleIds).toBeUndefined();` with `expect(change.verdicts).toBeUndefined();`. +- Add a new test proving MULTIPLE verdicts survive (a component schema used in both a request and a response, whose enum both loses and gains values, fires both enum rules): + +```ts +it('keeps every verdict when multiple rules fire, worst-first', () => { + const usage = new UsageIndex([ + { + site: '#/paths/~1x/get/parameters/{query:q}/schema', + target: '#/components/schemas/S', + }, + { + site: '#/paths/~1x/get/responses/200/content/application~1json/schema', + target: '#/components/schemas/S', + }, + ]); + const changes: RawChange[] = [ + { + pointer: '#/components/schemas/S', + property: 'enum', + kind: 'changed', + typeName: 'Schema', + base: { pointer: '#/components/schemas/S/enum', value: ['a', 'b'] }, + revision: { pointer: '#/components/schemas/S/enum', value: ['a', 'c'] }, + }, + ]; + const [change] = classifyChanges({ + changes, + specVersion: 'oas3_1', + base: new Map(), + revision: new Map(), + usage, + }); + expect(change.compat).toBe('breaking'); + expect(change.verdicts?.map((v) => v.ruleId)).toEqual([ + 'enum-values-added', + 'enum-values-removed', + ]); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/classify.test.ts` +Expected: FAIL — `verdicts` is undefined on the result. + +- [ ] **Step 3: Update `types.ts` and `classify/index.ts`** + +In `engine/types.ts`: add `ChangeVerdict` (shown in Interfaces), and update `Change`: + +```ts +export interface Change { + pointer: string; // stable node pointer — the change's identity + property?: string; // set for property-level changes + kind: ChangeKind; + typeName: string; + base?: ChangeSide; // absent for added + revision?: ChangeSide; // absent for removed + compat: Compat; // worst verdict's level; 'non-breaking' when no rule fired + verdicts?: ChangeVerdict[]; // every rule verdict, worst-first +} + +// What compare() emits — classification fields are filled later by classify(). +export type RawChange = Omit; +``` + +In `classify/index.ts`, replace the per-change body of the `changes.map` callback with: + +```ts +const rules = registry[change.typeName] ?? []; +const verdicts: ChangeVerdict[] = []; + +for (const polarity of expandPolarity(getPolarity(change.pointer, usage))) { + const ctx = { + polarity, + specVersion, + base: (pointer: string) => base.get(pointer), + revision: (pointer: string) => revision.get(pointer), + }; + for (const rule of rules) { + const verdict = rule.visit(change, ctx); + if (!verdict) continue; + // a 'both'-polarity node can fire the same rule twice with the same message + if (!verdicts.some((v) => v.ruleId === rule.id && v.message === verdict.message)) { + verdicts.push({ ruleId: rule.id, ...verdict }); + } + } +} + +verdicts.sort( + (a, b) => compatRank(b.compat) - compatRank(a.compat) || a.ruleId.localeCompare(b.ruleId) +); + +return { + ...change, + compat: verdicts[0]?.compat ?? 'non-breaking', + ...(verdicts.length ? { verdicts } : {}), +}; +``` + +(import `ChangeVerdict` from `../types.js`; the `Verdict` import stays for the rule return type). + +- [ ] **Step 4: Update the serializers to render all verdicts** + +`serializers/stylish.ts` (still the flat layout until Task 6): + +```ts +for (const change of sorted) { + const verdicts = change.verdicts ?? []; + const messages = verdicts.length + ? gray(` — ${verdicts.map((v) => `${v.message} (${v.ruleId})`).join(' ')}`) + : ''; + lines.push(`${ICONS[change.compat]} ${bold(change.kind)} ${label(change)}${messages}`); +} +``` + +`serializers/markdown.ts`, details cell: + +```ts +const details = (change.verdicts ?? []) + .map((v) => `${escapeCell(v.message)} \`${v.ruleId}\``) + .join('
'); +``` + +`serializers/html.ts`, in `renderChange` replace the `msg` and `rules` spans with: + +```ts + ${(change.verdicts ?? []) + .map( + (v) => + `${escapeHtml(v.message)} ${escapeHtml( + v.ruleId + )}` + ) + .join(' ')} +``` + +- [ ] **Step 5: Update remaining tests** + +- `engine/__tests__/diff-documents.test.ts`: replace the `ruleIds` assertion with: + +```ts +expect(becameRequired.verdicts).toEqual([ + { + ruleId: 'parameter-became-required', + compat: 'breaking', + message: 'Parameter became required.', + }, +]); +``` + +- `__tests__/serializers.test.ts` and `__tests__/serializers-rich.test.ts`: in fixtures, replace every `ruleIds: [...]` + `message: '...'` pair with `verdicts: [{ ruleId: '...', compat: , message: '...' }]`; assertions that look for rule ids or messages in output remain valid. + +- [ ] **Step 6: Verify green** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff && npm run typecheck` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/commands/diff +git commit -m "feat(cli): keep every rule verdict on diff changes + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: Visible `--fail-on` failure, `--output` note, execution time + +**Files:** + +- Create: `packages/cli/src/commands/diff/fail-on.ts` +- Modify: `packages/cli/src/commands/diff/index.ts` +- Test: `packages/cli/src/commands/diff/__tests__/fail-on.test.ts` + +**Interfaces:** + +- Consumes: `DiffSummary` from Task 1. +- Produces: `export type DiffFailOn = 'breaking' | 'none'` and `export function getDiffFailure(summary: DiffSummary, failOn: DiffFailOn): string | undefined` in `fail-on.ts`. `diff/index.ts` re-exports `DiffFailOn` so `packages/cli/src/index.ts` imports keep working. + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/src/commands/diff/__tests__/fail-on.test.ts`: + +```ts +import { getDiffFailure } from '../fail-on.js'; + +describe('getDiffFailure', () => { + it('fails when breaking changes exist and fail-on is breaking', () => { + expect(getDiffFailure({ breaking: 2, nonBreaking: 1 }, 'breaking')).toBe( + '❌ Diff failed with 2 breaking changes.' + ); + expect(getDiffFailure({ breaking: 1, nonBreaking: 0 }, 'breaking')).toBe( + '❌ Diff failed with 1 breaking change.' + ); + }); + + it('passes when there are no breaking changes', () => { + expect(getDiffFailure({ breaking: 0, nonBreaking: 5 }, 'breaking')).toBeUndefined(); + }); + + it('never fails when fail-on is none', () => { + expect(getDiffFailure({ breaking: 3, nonBreaking: 0 }, 'none')).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/__tests__/fail-on.test.ts` +Expected: FAIL — `fail-on.js` does not exist. + +- [ ] **Step 3: Implement `fail-on.ts`** + +```ts +import { pluralize } from '@redocly/openapi-core'; + +import type { DiffSummary } from './engine/types.js'; + +export type DiffFailOn = 'breaking' | 'none'; + +export function getDiffFailure(summary: DiffSummary, failOn: DiffFailOn): string | undefined { + if (failOn === 'breaking' && summary.breaking > 0) { + return `❌ Diff failed with ${summary.breaking} breaking ${pluralize( + 'change', + summary.breaking + )}.`; + } + return undefined; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/__tests__/fail-on.test.ts` +Expected: PASS. + +- [ ] **Step 5: Wire into the handler** + +In `packages/cli/src/commands/diff/index.ts`: + +- Delete the local `export type DiffFailOn = …` line; instead add: + +```ts +import { getDiffFailure, type DiffFailOn } from './fail-on.js'; + +export type { DiffFailOn }; +``` + +- Extend the existing `../../utils/miscellaneous.js` import with `printExecutionTime`. +- Replace `handleDiff` with: + +```ts +export async function handleDiff({ argv, config, collectSpecData }: CommandArgs) { + const startedAt = performance.now(); + const [{ path: basePath }] = await getFallbackApisOrExit([argv.base], config); + const [{ path: revisionPath }] = await getFallbackApisOrExit([argv.revision], config); + + const { bundle: baseDocument } = await bundle({ config, ref: basePath }); + const { bundle: revisionDocument } = await bundle({ config, ref: revisionPath }); + collectSpecData?.(revisionDocument.parsed); + + let result: DiffResult; + try { + result = diffDocuments({ base: baseDocument, revision: revisionDocument, config }); + } catch (error) { + if (error instanceof DiffError) { + return exitWithError(error.message); + } + throw error; + } + + const output = SERIALIZERS[argv.format](result); + if (argv.output) { + writeFileSync(argv.output, output); + logger.info(`Diff report written to ${argv.output}.\n`); + } else { + logger.output(output + '\n'); + } + + printExecutionTime('diff', startedAt, `${basePath} vs ${revisionPath}`); + + const failure = getDiffFailure(result.summary, argv['fail-on']); + if (failure) { + logger.error(`${failure}\n`); + throw new AbortFlowError('Diff failed.'); + } +} +``` + +(The message is printed with `logger.error` BEFORE throwing because `commandWrapper` swallows `AbortFlowError` messages — see `packages/cli/src/wrapper.ts:94-95`. This mirrors lint's `printLintTotals` + bare `AbortFlowError` convention.) + +- [ ] **Step 6: Verify green** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff && npm run typecheck` +Expected: PASS. + +- [ ] **Step 7: Manual smoke check** + +```bash +npm run compile +node packages/cli/bin/cli.js diff resources/cafe.yaml resources/__cafe-pre-release.yaml --fail-on=breaking; echo "exit: $?" +``` + +Expected: if the two cafe versions contain breaking changes, stderr shows `❌ Diff failed with N breaking change(s).` and `exit: 1`; with `--fail-on=none` the same invocation prints `exit: 0`. Either way the execution-time line (`… diff processed in …ms`) must appear. + +- [ ] **Step 8: Commit** + +```bash +git add packages/cli/src/commands/diff packages/cli/src/index.ts +git commit -m "fix(cli): report diff --fail-on failure visibly and print execution time + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 4: Extend core `OutputFormat` with `html`; derive `DiffOutputFormat` + +**Files:** + +- Modify: `packages/core/src/format/format.ts:50-59` +- Modify: `packages/cli/src/commands/diff/index.ts` +- Modify: `packages/cli/src/index.ts:99-101` + +**Interfaces:** + +- Produces: core `OutputFormat` union includes `'html'`; `export type DiffOutputFormat = Extract` in `diff/index.ts` (same four values as before — `SERIALIZERS` keeps working unchanged). + +- [ ] **Step 1: Add `'html'` to the core union** + +In `packages/core/src/format/format.ts`: + +```ts +export type OutputFormat = + | 'codeframe' + | 'stylish' + | 'json' + | 'checkstyle' + | 'codeclimate' + | 'summary' + | 'github-actions' + | 'markdown' + | 'junit' + | 'html'; +``` + +(`formatProblems`'s switch simply has no `html` case; no command passes it there, so behavior is unchanged.) + +- [ ] **Step 2: Derive `DiffOutputFormat` from core** + +In `packages/cli/src/commands/diff/index.ts` replace the local union with: + +```ts +import type { OutputFormat } from '@redocly/openapi-core'; + +export type DiffOutputFormat = Extract; +``` + +In `packages/cli/src/index.ts` diff command, type the choices via the derived type: + +```ts + format: { + description: 'Use a specific output format.', + choices: ['stylish', 'json', 'markdown', 'html'] as ReadonlyArray, + default: 'stylish' as const, + }, +``` + +(add `DiffOutputFormat` to the existing type-import from `./commands/diff/index.js`). + +- [ ] **Step 3: Verify green** + +Run: `npm run typecheck && VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/core/src/format/format.ts packages/cli/src/commands/diff/index.ts packages/cli/src/index.ts +git commit -m "refactor(cli): derive diff output format from core OutputFormat + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 5: Locations on change sides (`file`/`line`/`col`) + +**Files:** + +- Create: `packages/cli/src/commands/diff/engine/locate.ts` +- Modify: `packages/cli/src/commands/diff/engine/types.ts` (`ChangeSide`) +- Modify: `packages/cli/src/commands/diff/engine/index.ts` (call `locateChanges`) +- Test: `packages/cli/src/commands/diff/engine/__tests__/locate.test.ts`, extend `diff-documents.test.ts` + +**Interfaces:** + +- Consumes: `Document.source: Source` (each side's bundled document), core `getLineColLocation(location: { source, pointer, reportOnKey }) => { start: { line, col } }`, core `Source` (has `.absoluteRef`). +- Produces: `export function locateChanges(changes: Change[], baseSource: Source, revisionSource: Source): Change[]`; `ChangeSide` becomes: + +```ts +export interface ChangeSide { + pointer: string; // real JSON Pointer in this document + file?: string; // absoluteRef of the side's document — filled by locateChanges() + line?: number; // 1-based — filled by locateChanges() + col?: number; // 1-based — filled by locateChanges() + value?: unknown; +} +``` + +Known limitation to note in a comment: pointers that only exist in the bundled document (nodes inlined from other files) do not resolve in the root source AST — `getLineColLocation` falls back to `1:1`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/src/commands/diff/engine/__tests__/locate.test.ts`: + +```ts +import { makeDocumentFromString } from '@redocly/openapi-core'; +import { outdent } from 'outdent'; + +import { locateChanges } from '../locate.js'; +import type { Change } from '../types.js'; + +const BASE_YAML = outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } +`; + +const REVISION_YAML = outdent` + openapi: 3.1.0 + info: + title: T2 + version: '1' +`; + +describe('locateChanges', () => { + it('attaches file, line, and col to each present side', () => { + const base = makeDocumentFromString(BASE_YAML, 'base.yaml'); + const revision = makeDocumentFromString(REVISION_YAML, 'rev.yaml'); + const changes: Change[] = [ + { + pointer: '#/info', + property: 'title', + kind: 'changed', + typeName: 'Info', + base: { pointer: '#/info/title', value: 'T' }, + revision: { pointer: '#/info/title', value: 'T2' }, + compat: 'non-breaking', + }, + ]; + + const [located] = locateChanges(changes, base.source, revision.source); + expect(located.base).toMatchObject({ file: 'base.yaml', line: 2 }); + expect(located.revision).toMatchObject({ file: 'rev.yaml', line: 3 }); + expect(located.revision?.col).toBeGreaterThan(1); + }); + + it('falls back to 1:1 for pointers missing from the source', () => { + const base = makeDocumentFromString(BASE_YAML, 'base.yaml'); + const revision = makeDocumentFromString(REVISION_YAML, 'rev.yaml'); + const changes: Change[] = [ + { + pointer: '#/components/schemas/Ghost', + kind: 'removed', + typeName: 'Schema', + base: { pointer: '#/components/schemas/Ghost', value: {} }, + compat: 'breaking', + }, + ]; + + const [located] = locateChanges(changes, base.source, revision.source); + expect(located.base).toMatchObject({ file: 'base.yaml', line: 1, col: 1 }); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/locate.test.ts` +Expected: FAIL — `locate.js` does not exist. + +- [ ] **Step 3: Add the `ChangeSide` fields and implement `locate.ts`** + +Update `ChangeSide` in `engine/types.ts` exactly as shown in Interfaces above. + +Create `packages/cli/src/commands/diff/engine/locate.ts`: + +```ts +import { getLineColLocation } from '@redocly/openapi-core'; + +import type { Source } from '@redocly/openapi-core'; +import type { Change, ChangeSide } from './types.js'; + +// Nodes inlined by bundling do not exist in the root source AST; +// getLineColLocation falls back to 1:1 for such pointers. +function locateSide(side: ChangeSide, source: Source): ChangeSide { + const { start } = getLineColLocation({ source, pointer: side.pointer, reportOnKey: false }); + return { ...side, file: source.absoluteRef, line: start.line, col: start.col }; +} + +export function locateChanges( + changes: Change[], + baseSource: Source, + revisionSource: Source +): Change[] { + return changes.map((change) => ({ + ...change, + ...(change.base ? { base: locateSide(change.base, baseSource) } : {}), + ...(change.revision ? { revision: locateSide(change.revision, revisionSource) } : {}), + })); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/locate.test.ts` +Expected: PASS. If a `line` assertion is off by one, inspect the actual value — `reportOnKey: false` locates the VALUE node — and fix the implementation (not the test) unless the actual location is correct on inspection. + +- [ ] **Step 5: Wire into `diffDocuments`** + +In `packages/cli/src/commands/diff/engine/index.ts`: + +```ts +import { locateChanges } from './locate.js'; +``` + +and wrap the classify call: + +```ts +const changes = locateChanges( + classifyChanges({ + changes: rawChanges, + specVersion: revisionVersion, + base: baseCollected.entries, + revision: revisionCollected.entries, + usage, + }), + base.source, + revision.source +); +``` + +- [ ] **Step 6: Extend the integration test** + +In `engine/__tests__/diff-documents.test.ts`, change the two `makeDocumentFromString` calls in the first test to use `'base.yaml'` and `'rev.yaml'` as the second argument, and add after the existing `becameRequired` assertions: + +```ts +expect(becameRequired.base).toMatchObject({ file: 'base.yaml' }); +expect(becameRequired.revision).toMatchObject({ file: 'rev.yaml' }); +expect(becameRequired.revision?.line).toBeGreaterThan(1); +``` + +- [ ] **Step 7: Verify green** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff && npm run typecheck` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add packages/cli/src/commands/diff +git commit -m "feat(cli): attach file/line/col locations to diff change sides + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 6: Stylish output grouped per operation, rendering all verdicts + +**Files:** + +- Rewrite: `packages/cli/src/commands/diff/serializers/stylish.ts` +- Test: `packages/cli/src/commands/diff/__tests__/serializers.test.ts` (stylish portion) + +**Interfaces:** + +- Consumes: `Change.base/revision` with `file`/`line`/`col` from Task 5; `Change.verdicts` from Task 2; core `unescapePointerFragment`. +- Produces: `stylishDiff(result: DiffResult): string` (signature unchanged). Display rules: group header derived from the DISPLAY SIDE's real pointer (base side for `removed`, revision side otherwise) so real path templates are shown; change label derived from the stable pointer with the `paths///` prefix stripped; EVERY verdict is rendered on its own line; a gray ` at ::` line closes each change. + +Example output shape: + +``` +GET /pets + ✖ breaking changed parameters/{query:limit} · required + Parameter became required. (parameter-became-required) + at rev.yaml:11:21 + ✔ non-breaking changed responses/200 · description + at rev.yaml:14:16 + +components + ✔ non-breaking added components/schemas/Pet + at rev.yaml:16:3 + +1 breaking, 2 non-breaking. +``` + +- [ ] **Step 1: Write the failing test** + +In `packages/cli/src/commands/diff/__tests__/serializers.test.ts`, rebuild the shared fixture so every side includes `file`/`line`/`col` and classification uses `verdicts`: + +```ts +const RESULT: DiffResult = { + version: '1', + specVersions: { base: 'oas3_1', revision: 'oas3_1' }, + summary: { breaking: 3, nonBreaking: 1 }, + changes: [ + { + pointer: '#/paths/~1pets/get/parameters/{query:limit}', + property: 'required', + kind: 'changed', + typeName: 'Parameter', + base: { + pointer: '#/paths/~1pets/get/parameters/0/required', + file: '/abs/base.yaml', + line: 9, + col: 21, + value: false, + }, + revision: { + pointer: '#/paths/~1pets/get/parameters/1/required', + file: '/abs/rev.yaml', + line: 11, + col: 21, + value: true, + }, + compat: 'breaking', + verdicts: [ + { + ruleId: 'parameter-became-required', + compat: 'breaking', + message: 'Parameter became required.', + }, + ], + }, + { + pointer: '#/paths/~1pets/get/requestBody', + property: 'schema', + kind: 'changed', + typeName: 'RequestBody', + base: { + pointer: '#/paths/~1pets/get/requestBody/schema', + file: '/abs/base.yaml', + line: 14, + col: 9, + value: '#/components/schemas/A', + }, + revision: { + pointer: '#/paths/~1pets/get/requestBody/schema', + file: '/abs/rev.yaml', + line: 14, + col: 9, + value: '#/components/schemas/B', + }, + compat: 'breaking', + verdicts: [ + { ruleId: 'ref-target-changed', compat: 'breaking', message: 'Reference target changed.' }, + ], + }, + { + pointer: '#/paths/~1pets/delete', + kind: 'removed', + typeName: 'Operation', + base: { + pointer: '#/paths/~1pets/delete', + file: '/abs/base.yaml', + line: 30, + col: 3, + value: {}, + }, + compat: 'breaking', + verdicts: [ + { ruleId: 'operation-removed', compat: 'breaking', message: 'Operation was removed.' }, + ], + }, + { + pointer: '#/components/schemas/Pet', + kind: 'added', + typeName: 'Schema', + revision: { + pointer: '#/components/schemas/Pet', + file: '/abs/rev.yaml', + line: 20, + col: 5, + value: { type: 'object' }, + }, + compat: 'non-breaking', + }, + ], +}; +``` + +New stylish assertions: + +```ts +it('groups stylish output per operation with locations and all verdicts', () => { + // colorette is auto-disabled under vitest (no TTY), so plain substrings work + const output = stylishDiff(RESULT); + + expect(output).toContain('GET /pets'); + expect(output).toContain('DELETE /pets'); + expect(output).toContain('components'); + expect(output).toContain('Parameter became required. (parameter-became-required)'); + expect(output).toMatch(/at .*rev\.yaml:11:21/); + expect(output).toMatch(/at .*rev\.yaml:20:5/); + // removed changes point at the base file, others at the revision file: + expect(output).toMatch(/at .*base\.yaml:30:3/); + expect(output).toContain('parameters/{query:limit} · required'); + expect(output).toContain('3 breaking, 1 non-breaking.'); +}); +``` + +Update the markdown/html tests in the same file to the new fixture only where they referenced the old change list (their `verdicts` rendering was already covered in Task 2). + +- [ ] **Step 2: Run it to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/__tests__/serializers.test.ts` +Expected: FAIL — old stylish output has no groups or `at …` lines. + +- [ ] **Step 3: Rewrite `serializers/stylish.ts`** + +```ts +import * as path from 'node:path'; + +import { unescapePointerFragment } from '@redocly/openapi-core'; +import { blue, bold, gray, green, red } from 'colorette'; + +import type { Change, ChangeSide, Compat, DiffResult } from '../engine/types.js'; + +const SEVERITY_ORDER: Compat[] = ['breaking', 'non-breaking']; + +const ICONS: Record = { + breaking: red('✖ breaking '), + 'non-breaking': green('✔ non-breaking'), +}; + +const HTTP_METHODS = new Set([ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', + 'query', +]); + +// The side shown to the user: what was removed lives in the base document, +// everything else is best inspected in the revision. +function displaySide(change: Change): ChangeSide | undefined { + return change.kind === 'removed' + ? (change.base ?? change.revision) + : (change.revision ?? change.base); +} + +// Identity keys escape '/' (node-identity.ts), so plain splitting is safe. +function segmentsOf(pointer: string): string[] { + return pointer.replace(/^#\//, '').split('/'); +} + +function groupOf(change: Change): string { + const segments = segmentsOf(displaySide(change)?.pointer ?? change.pointer); + if (segments[0] === 'paths' && segments.length > 1) { + const pathKey = unescapePointerFragment(segments[1]); + const method = segments[2]; + return method && HTTP_METHODS.has(method) ? `${method.toUpperCase()} ${pathKey}` : pathKey; + } + return segments[0] || 'document'; +} + +function labelOf(change: Change): string { + const segments = segmentsOf(change.pointer); + const rest = + segments[0] === 'paths' + ? segments.length > 2 && HTTP_METHODS.has(segments[2]) + ? segments.slice(3) + : segments.slice(2) + : segments; + const label = rest.join('/') || segments.join('/'); + return change.property ? `${label} · ${change.property}` : label; +} + +function locationOf(change: Change, cwd: string): string | undefined { + const side = displaySide(change); + if (!side?.file) return undefined; + const file = /^https?:\/\//.test(side.file) ? side.file : path.relative(cwd, side.file); + return `${file}:${side.line}:${side.col}`; +} + +export function stylishDiff(result: DiffResult): string { + const cwd = process.cwd(); + const groups = new Map(); + for (const change of result.changes) { + const key = groupOf(change); + const group = groups.get(key) ?? []; + group.push(change); + groups.set(key, group); + } + + const lines: string[] = []; + for (const [key, changes] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) { + lines.push(bold(blue(key))); + const sorted = [...changes].sort( + (a, b) => + SEVERITY_ORDER.indexOf(a.compat) - SEVERITY_ORDER.indexOf(b.compat) || + a.pointer.localeCompare(b.pointer) + ); + for (const change of sorted) { + lines.push(` ${ICONS[change.compat]} ${bold(change.kind)} ${labelOf(change)}`); + for (const verdict of change.verdicts ?? []) { + lines.push(gray(` ${verdict.message} (${verdict.ruleId})`)); + } + const location = locationOf(change, cwd); + if (location) lines.push(gray(` at ${location}`)); + } + lines.push(''); + } + + const { breaking, nonBreaking } = result.summary; + lines.push(`${red(`${breaking} breaking`)}, ${green(`${nonBreaking} non-breaking`)}.`); + return lines.join('\n'); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff` +Expected: PASS (fix `serializers-rich.test.ts` expectations if they asserted the old flat layout). + +- [ ] **Step 5: Manual smoke check** + +```bash +npm run compile +node packages/cli/bin/cli.js diff resources/cafe.yaml resources/__cafe-pre-release.yaml --fail-on=none +``` + +Expected: changes grouped under headers like `GET /coffee` (real operations from the cafe spec), every verdict on its own line, and a gray `at resources/….yaml::` line that is cmd-clickable in the terminal. + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/commands/diff +git commit -m "feat(cli): group diff stylish output per operation with locations and verdicts + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 7: Path-parameter rename matching at the compare stage + +Collect stays untouched: each side's stable pointers remain truthful to its own document. A new `alignRenamedPaths` step runs BEFORE `compareMaps`: it finds path templates present on only one side, matches them by parameter-position-normalized form (`/pet/{id}` and `/pet/{petId}` both normalize to `/pet/{0}`), and — only when the match is unambiguous 1:1 on BOTH sides — re-keys the revision entries into the base pointer space. Each match also emits an explicit rename change (`property: 'path'`, non-breaking), so the rename is visible in every output format. Ambiguous matches are left alone and diff as remove+add, so no collisions are possible. + +**Files:** + +- Create: `packages/cli/src/commands/diff/engine/align-paths.ts` +- Modify: `packages/cli/src/commands/diff/engine/node-identity.ts` (export the escape helper) +- Modify: `packages/cli/src/commands/diff/engine/index.ts` +- Test: `packages/cli/src/commands/diff/engine/__tests__/align-paths.test.ts`, extend `diff-documents.test.ts` + +**Interfaces:** + +- Consumes: `NodeEntry` from `types.ts`; core `unescapePointerFragment`. +- Produces: + +```ts +export interface PathRename { + baseTemplate: string; // '/pet/{id}' + revisionTemplate: string; // '/pet/{petId}' + basePointer: string; // stable pointer, '#/paths/~1pet~1{id}' + revisionPointer: string; // original revision stable pointer + baseRealPointer: string; + revisionRealPointer: string; +} + +export function alignRenamedPaths( + base: Map, + revision: Map +): { revision: Map; renames: PathRename[] }; +``` + +`node-identity.ts` renames its private `esc` to an exported `escapeIdentityKeyPart` (same body) so `align-paths.ts` builds `{path:}` segments identically. + +- [ ] **Step 1: Write the failing unit test** + +Create `packages/cli/src/commands/diff/engine/__tests__/align-paths.test.ts`: + +```ts +import { alignRenamedPaths } from '../align-paths.js'; +import type { NodeEntry } from '../types.js'; + +function entry(pointer: string, typeName: string, parentPointer: string | null): NodeEntry { + return { pointer, realPointer: pointer, parentPointer, typeName, scalars: {}, refs: {}, raw: {} }; +} + +function side(template: string, paramName: string): Map { + const escaped = template.replace(/\//g, '~1'); + const p = `#/paths/${escaped}`; + return new Map([ + [p, entry(p, 'PathItem', '#/paths')], + [`${p}/get`, entry(`${p}/get`, 'Operation', p)], + [ + `${p}/get/parameters/{path:${paramName}}`, + entry(`${p}/get/parameters/{path:${paramName}}`, 'Parameter', `${p}/get/parameters`), + ], + ]); +} + +describe('alignRenamedPaths', () => { + it('aliases an unambiguous renamed path into the base pointer space', () => { + const base = side('/pet/{id}', 'id'); + const revision = side('/pet/{petId}', 'petId'); + + const { revision: aligned, renames } = alignRenamedPaths(base, revision); + + // the fixture builds realPointer === pointer, so real pointers keep each + // side's own template + expect(renames).toEqual([ + { + baseTemplate: '/pet/{id}', + revisionTemplate: '/pet/{petId}', + basePointer: '#/paths/~1pet~1{id}', + revisionPointer: '#/paths/~1pet~1{petId}', + baseRealPointer: '#/paths/~1pet~1{id}', + revisionRealPointer: '#/paths/~1pet~1{petId}', + }, + ]); + expect(aligned.has('#/paths/~1pet~1{id}')).toBe(true); + expect(aligned.has('#/paths/~1pet~1{id}/get/parameters/{path:id}')).toBe(true); + // real pointers keep the revision's original template — the rename stays visible + expect(aligned.get('#/paths/~1pet~1{id}')!.realPointer).toBe('#/paths/~1pet~1{petId}'); + }); + + it('leaves ambiguous matches alone', () => { + const base = side('/a/{x}/b', 'x'); + const revision = new Map([...side('/a/{y}/b', 'y'), ...side('/a/{z}/b', 'z')]); + + const { revision: aligned, renames } = alignRenamedPaths(base, revision); + + expect(renames).toEqual([]); + expect(aligned).toBe(revision); // untouched + }); + + it('is a no-op when templates match exactly', () => { + const base = side('/pet/{id}', 'id'); + const revision = side('/pet/{id}', 'id'); + + const { renames } = alignRenamedPaths(base, revision); + expect(renames).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/align-paths.test.ts` +Expected: FAIL — `align-paths.js` does not exist. + +- [ ] **Step 3: Export the identity-escape helper** + +In `packages/cli/src/commands/diff/engine/node-identity.ts`, rename the private `esc` function to `escapeIdentityKeyPart` and export it (update the internal call sites in `IDENTITY_KEYS`). + +- [ ] **Step 4: Implement `align-paths.ts`** + +Create `packages/cli/src/commands/diff/engine/align-paths.ts`: + +```ts +import { unescapePointerFragment } from '@redocly/openapi-core'; + +import { escapeIdentityKeyPart } from './node-identity.js'; +import type { NodeEntry } from './types.js'; + +export interface PathRename { + baseTemplate: string; + revisionTemplate: string; + basePointer: string; + revisionPointer: string; + baseRealPointer: string; + revisionRealPointer: string; +} + +const TEMPLATE_PARAM = /\{([^}]+)\}/g; + +function normalizeTemplate(template: string): string { + let index = 0; + return template.replace(TEMPLATE_PARAM, () => `{${index++}}`); +} + +function paramNames(template: string): string[] { + return [...template.matchAll(TEMPLATE_PARAM)].map((m) => m[1]); +} + +// raw path template → stable PathItem pointer +function pathTemplates(entries: Map): Map { + const result = new Map(); + for (const entry of entries.values()) { + if (entry.typeName === 'PathItem' && entry.parentPointer === '#/paths') { + result.set(unescapePointerFragment(entry.pointer.slice('#/paths/'.length)), entry.pointer); + } + } + return result; +} + +function groupByNormalized(templates: string[]): Map { + const groups = new Map(); + for (const template of templates) { + const normalized = normalizeTemplate(template); + groups.set(normalized, [...(groups.get(normalized) ?? []), template]); + } + return groups; +} + +// Matches path templates that differ only in parameter names and re-keys the +// revision entries into the base pointer space. Only unambiguous 1:1 matches +// are aliased — anything else keeps its own keys and diffs as remove+add. +export function alignRenamedPaths( + base: Map, + revision: Map +): { revision: Map; renames: PathRename[] } { + const baseTemplates = pathTemplates(base); + const revisionTemplates = pathTemplates(revision); + + const baseGroups = groupByNormalized( + [...baseTemplates.keys()].filter((t) => !revisionTemplates.has(t)) + ); + const revisionGroups = groupByNormalized( + [...revisionTemplates.keys()].filter((t) => !baseTemplates.has(t)) + ); + + const renames: PathRename[] = []; + for (const [normalized, baseCandidates] of baseGroups) { + const revisionCandidates = revisionGroups.get(normalized) ?? []; + if (baseCandidates.length !== 1 || revisionCandidates.length !== 1) continue; + const [baseTemplate] = baseCandidates; + const [revisionTemplate] = revisionCandidates; + const basePointer = baseTemplates.get(baseTemplate)!; + const revisionPointer = revisionTemplates.get(revisionTemplate)!; + renames.push({ + baseTemplate, + revisionTemplate, + basePointer, + revisionPointer, + baseRealPointer: base.get(basePointer)!.realPointer, + revisionRealPointer: revision.get(revisionPointer)!.realPointer, + }); + } + + if (!renames.length) return { revision, renames }; + + const rewrites = renames.map((rename) => ({ + fromPrefix: rename.revisionPointer, + toPrefix: rename.basePointer, + // positional mapping of revision param names to base param names, + // pre-escaped the way node-identity builds '{path:}' segments + paramMap: new Map( + paramNames(rename.revisionTemplate).map((name, i) => [ + escapeIdentityKeyPart(name), + escapeIdentityKeyPart(paramNames(rename.baseTemplate)[i]), + ]) + ), + })); + + const rewriteKey = (key: string): string => { + for (const { fromPrefix, toPrefix, paramMap } of rewrites) { + if (key !== fromPrefix && !key.startsWith(fromPrefix + '/')) continue; + const suffix = key + .slice(fromPrefix.length) + .split('/') + .map((segment) => { + const match = segment.match(/^\{path:(.+)\}$/); + const mapped = match && paramMap.get(match[1]); + return mapped ? `{path:${mapped}}` : segment; + }) + .join('/'); + return toPrefix + suffix; + } + return key; + }; + + const aliased = new Map(); + for (const [key, entry] of revision) { + const newKey = rewriteKey(key); + aliased.set(newKey, { + ...entry, + pointer: newKey, + parentPointer: entry.parentPointer === null ? null : rewriteKey(entry.parentPointer), + }); + } + return { revision: aliased, renames }; +} +``` + +(No key collisions are possible: `toPrefix` corresponds to a base-only template, so `#/paths/` cannot already exist as a revision key; ambiguous normalized groups are skipped entirely.) + +- [ ] **Step 5: Run the unit test to verify it passes** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/align-paths.test.ts` +Expected: PASS. + +- [ ] **Step 6: Wire into `diffDocuments` with an explicit rename change** + +In `packages/cli/src/commands/diff/engine/index.ts`: + +```ts +import { alignRenamedPaths, type PathRename } from './align-paths.js'; +import type { DiffResult, DiffSummary, RawChange } from './types.js'; +``` + +Add above `diffDocuments`: + +```ts +// The path template itself is a map key, not a node property, so the rename is +// surfaced as a synthetic 'changed' on the PathItem with property 'path'. +function toRenameChange(rename: PathRename): RawChange { + return { + pointer: rename.basePointer, + property: 'path', + kind: 'changed', + typeName: 'PathItem', + base: { pointer: rename.baseRealPointer, value: rename.baseTemplate }, + revision: { pointer: rename.revisionRealPointer, value: rename.revisionTemplate }, + }; +} +``` + +Inside `diffDocuments`, replace the compare/classify block with: + +```ts +const { revision: alignedRevision, renames } = alignRenamedPaths( + baseCollected.entries, + revisionCollected.entries +); + +const rawChanges = [ + ...renames.map(toRenameChange), + ...compareMaps(baseCollected.entries, alignedRevision), +]; +// usage edges are NOT rewritten: polarity only inspects 'parameters'/'responses' +// segments and component roots, which a path rename never alters +const usage = new UsageIndex([...baseCollected.usageEdges, ...revisionCollected.usageEdges]); + +const changes = locateChanges( + classifyChanges({ + changes: rawChanges, + specVersion: revisionVersion, + base: baseCollected.entries, + revision: alignedRevision, + usage, + }), + base.source, + revision.source +); +``` + +- [ ] **Step 7: Write the failing integration test** + +Add to `engine/__tests__/diff-documents.test.ts`: + +```ts +it('matches renamed path parameters instead of remove+add', async () => { + const config = await createConfig({}); + const makeSpec = (param: string) => outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + paths: + /pet/{${param}}: + get: + parameters: + - name: ${param} + in: path + required: true + schema: { type: string } + responses: + '200': { description: OK } + `; + const result = diffDocuments({ + base: makeDocumentFromString(makeSpec('id'), 'base.yaml'), + revision: makeDocumentFromString(makeSpec('petId'), 'rev.yaml'), + config, + }); + + expect(result.summary.breaking).toBe(0); + + // the rename itself is an explicit, non-breaking change + const renameChange = result.changes.find((c) => c.property === 'path')!; + expect(renameChange).toMatchObject({ + pointer: '#/paths/~1pet~1{id}', + kind: 'changed', + typeName: 'PathItem', + compat: 'non-breaking', + }); + expect(renameChange.base?.value).toBe('/pet/{id}'); + expect(renameChange.revision?.value).toBe('/pet/{petId}'); + + // the parameter matched; only its name changed + const nameChange = result.changes.find((c) => c.property === 'name')!; + expect(nameChange).toMatchObject({ + kind: 'changed', + compat: 'non-breaking', + pointer: '#/paths/~1pet~1{id}/get/parameters/{path:id}', + }); + expect(nameChange.base?.value).toBe('id'); + expect(nameChange.revision?.value).toBe('petId'); +}); + +it('reports ambiguous path renames as remove+add', async () => { + const config = await createConfig({}); + const base = makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + paths: + /a/{x}/b: + get: + responses: + '200': { description: OK } + `, + 'base.yaml' + ); + const revision = makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + paths: + /a/{y}/b: + get: + responses: + '200': { description: OK } + /a/{z}/b: + get: + responses: + '200': { description: OK } + `, + 'rev.yaml' + ); + const result = diffDocuments({ base, revision, config }); + + const kinds = result.changes.map((c) => c.kind).sort(); + expect(kinds).toEqual(['added', 'added', 'removed']); + expect(result.changes.find((c) => c.property === 'path')).toBeUndefined(); +}); +``` + +- [ ] **Step 8: Run to verify, then make green** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts` +Expected: PASS after Step 6's wiring (Steps 6 and 7 may be done in either order; both must be green here). All pre-existing tests must also stay green — paths without renames are untouched by `alignRenamedPaths`. + +- [ ] **Step 9: Verify green + typecheck** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff && npm run typecheck` +Expected: PASS. + +- [ ] **Step 10: Commit** + +```bash +git add packages/cli/src/commands/diff +git commit -m "feat(cli): match renamed path parameters at the diff compare stage + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 8: Documentation update + +**Files:** + +- Modify: `docs/@v2/commands/diff.md` +- Modify: `.changeset/diff-command.md` (only if it mentions `warning` levels or `--fail-on=warning`) + +- [ ] **Step 1: Update the docs page** + +In `docs/@v2/commands/diff.md`: + +- Line 11: "…changes are also classified as breaking, warning, or non-breaking…" → "…changes are also classified as breaking or non-breaking…". +- Line 19 example: `--fail-on=warning` → `--fail-on=breaking`. +- Line 29 `--fail-on` row: possible values `breaking`, `none` (default `breaking`). +- Line 41: rewrite to: "Changes the tool detects but cannot judge automatically (for example, a `$ref` that now points to a different target) are conservatively reported as `breaking`." +- Line 46: replace the trailing "…reported as a `warning` rather than matched to its previous identity." with "…reported as `breaking` (`ref-target-changed`) rather than matched to its previous identity." +- Line 72 (`ref-target-changed` row): drop "(reported as `warning`)"; state it is reported as `breaking`. +- Document that each change carries ALL triggered rule verdicts (`verdicts`: rule id, level, message), with the change's level being the most severe verdict. +- Add a short subsection under the output description: + +```markdown +### Locations + +Each change reports the source file, line, and column of the affected node on both +sides (`base` and `revision`). In the `stylish` format, changes are grouped per +operation (for example, `GET /pets`) and each change includes a clickable +`file:line:col` reference — the base file for removals, the revision file otherwise. +For multi-file API descriptions, nodes pulled in from files referenced via `$ref` +resolve to `1:1` of the root file. + +### Path parameter renaming + +Renaming a path parameter (for example, `/pets/{id}` → `/pets/{petId}`) is treated +as the same endpoint, not a removal plus an addition. The rename is reported as a +non-breaking change of the path template, alongside a non-breaking change of the +parameter's `name`. If the match is ambiguous (several paths differing only in +parameter names), the paths are compared by their literal keys instead. +``` + +- Update the sample `stylish` output block (if present) to the new grouped format shown in Task 6. + +- [ ] **Step 2: Check the changeset** + +Read `.changeset/diff-command.md`; if it mentions `warning` classification or `--fail-on=warning`, align the wording. Do not create a new changeset — the command is unreleased and covered by the existing one. + +- [ ] **Step 3: Full verification** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff && npm run typecheck` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add docs/@v2/commands/diff.md .changeset/diff-command.md +git commit -m "docs: update diff command docs for two-level compat, verdicts, locations, and path-param matching + +Co-Authored-By: Claude Fable 5 " +``` diff --git a/docs/superpowers/plans/2026-07-07-diff-command.md b/docs/superpowers/plans/2026-07-07-diff-command.md new file mode 100644 index 0000000000..d1ee783921 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-diff-command.md @@ -0,0 +1,3077 @@ +# `redocly diff` Command Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A new `redocly diff ` command that compares two API descriptions and reports added/removed/changed parts, with breaking-change classification for OpenAPI 3.x and stylish/json/markdown/html output. + +**Architecture:** Each side is bundled and collected (via the existing `walkDocument` + type trees) into a flat `Map`; the two maps are compared with a dumb two-pass union iteration into flat `Change[]`; a classifier (polarity engine + lint-style rule registry, worst-verdict-wins) assigns `breaking | warning | non-breaking`. **The entire engine lives inside the CLI package (`packages/cli/src/commands/diff/engine/`) and consumes ONLY the public `@redocly/openapi-core` API — nothing is added or changed in `packages/core`. The command is experimental.** Spec: `docs/superpowers/specs/2026-07-07-diff-command-design.md`. + +**Tech Stack:** TypeScript ESM, vitest, existing `@redocly/openapi-core` machinery (`bundle`, `walkDocument`, `normalizeTypes`, `detectSpec`), `colorette`, `@redocly/ajv` (tests only). + +## Global Constraints + +- **Node version:** the default shell node is v16 which cannot run the repo tooling. Before ANY `npm`, `npx`, or `git commit` command run: `export PATH="$HOME/.nvm/versions/node/v22.19.0/bin:$PATH"` (repo requires node >=20.19; pre-commit hooks fail on v16). +- **ESM:** every relative import inside `packages/` ends with `.js` (e.g. `import { compareMaps } from './compare.js'`). +- **Run a single unit test file:** `VITEST_SUITE=unit npx vitest run --coverage.enabled=false` (coverage thresholds are global; disable for single-file runs). +- **Commits:** conventional commits (`feat:`, `test:`, `docs:`). Pre-commit runs oxlint + oxfmt via lint-staged automatically. +- **No new runtime dependencies.** `colorette` and `@redocly/ajv` are already dependencies. +- **Do NOT touch `packages/core`.** The engine imports everything from `@redocly/openapi-core`'s existing public API (`walkDocument`, `normalizeVisitors`, `normalizeTypes`, `detectSpec`, `getMajorSpecVersion`, `getTypes`, `isRef`, `isPlainObject`, `makeDocumentFromString`, `createConfig`, `bundle`, `logger`, and their types). If something seems missing from that API, find a public equivalent — do not add core exports. Reading core sources for debugging is fine; modifying them is not. +- **The command is experimental:** the yargs description carries the `[experimental]` suffix (same convention as `join` in `packages/cli/src/index.ts`), and the docs page states it. +- **Working directory:** repo root (the worktree root). All paths below are relative to it. + +--- + +### Task 1: Core diff types and verdict helpers + +**Files:** + +- Create: `packages/cli/src/commands/diff/engine/types.ts` +- Test: `packages/cli/src/commands/diff/engine/__tests__/types.test.ts` + +**Interfaces:** + +- Consumes: `SpecVersion` type from `@redocly/openapi-core`. +- Produces (used by every later task): `Compat`, `ChangeKind`, `NodeEntry`, `ChangeSide`, `Change`, `RawChange`, `DiffSummary`, `DiffResult`, `Verdict`, `Polarity`, `RuleContext`, `DiffRule`, `DiffRuleRegistry`, `compatRank(c: Compat): number`, `worstOf(a: Compat, b: Compat): Compat`, `breaking(message: string): Verdict`, `warning(message: string): Verdict`. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/cli/src/commands/diff/engine/__tests__/types.test.ts +import { worstOf, compatRank, breaking, warning } from '../types.js'; + +describe('diff types helpers', () => { + it('ranks compat levels', () => { + expect(compatRank('breaking')).toBeGreaterThan(compatRank('warning')); + expect(compatRank('warning')).toBeGreaterThan(compatRank('non-breaking')); + }); + + it('picks the worst compat', () => { + expect(worstOf('non-breaking', 'breaking')).toBe('breaking'); + expect(worstOf('warning', 'non-breaking')).toBe('warning'); + expect(worstOf('warning', 'warning')).toBe('warning'); + }); + + it('builds verdicts', () => { + expect(breaking('boom')).toEqual({ compat: 'breaking', message: 'boom' }); + expect(warning('hmm')).toEqual({ compat: 'warning', message: 'hmm' }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/types.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../types.js`. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/cli/src/commands/diff/engine/types.ts +import type { SpecVersion } from '@redocly/openapi-core'; + +export type Compat = 'breaking' | 'warning' | 'non-breaking'; + +export type ChangeKind = 'added' | 'removed' | 'changed'; + +export interface NodeEntry { + pointer: string; // stable matching key, e.g. '#/paths/~1pets/get/parameters/{query:limit}' + realPointer: string; // actual JSON Pointer in THIS document, e.g. '#/paths/~1pets/get/parameters/1' + parentPointer: string | null; // stable pointer of the parent node + typeName: string; // from this side's type tree + scalars: Record; // shallow primitives and arrays of primitives (enum, required, ...) + refs: Record; // $ref-valued properties, recorded as attributes (not followed) + raw: unknown; // the raw node value — payload for added/removed changes +} + +export interface ChangeSide { + pointer: string; // real JSON Pointer in this document + value?: unknown; +} + +export interface Change { + pointer: string; // stable node pointer — the change's identity + property?: string; // set for property-level changes + kind: ChangeKind; + typeName: string; + base?: ChangeSide; // absent for added + revision?: ChangeSide; // absent for removed + compat: Compat; + ruleIds?: string[]; // all rules that produced a verdict (worst wins) + message?: string; // message of the most severe verdict +} + +// What compare() emits — classification fields are filled later by classify(). +export type RawChange = Omit; + +export interface DiffSummary { + breaking: number; + warning: number; + nonBreaking: number; +} + +export interface DiffResult { + version: '1'; + specVersions: { base: SpecVersion; revision: SpecVersion }; + summary: DiffSummary; + changes: Change[]; +} + +export interface Verdict { + compat: Compat; + message: string; +} + +export type Polarity = 'request' | 'response' | 'both' | 'neutral'; + +export interface RuleContext { + polarity: Polarity; + specVersion: SpecVersion; + base: (pointer: string) => NodeEntry | undefined; + revision: (pointer: string) => NodeEntry | undefined; +} + +export interface DiffRule { + id: string; + description: string; + visit(change: RawChange, ctx: RuleContext): Verdict | undefined; +} + +export type DiffRuleRegistry = Record; + +const COMPAT_RANK: Record = { breaking: 2, warning: 1, 'non-breaking': 0 }; + +export function compatRank(compat: Compat): number { + return COMPAT_RANK[compat]; +} + +export function worstOf(a: Compat, b: Compat): Compat { + return compatRank(a) >= compatRank(b) ? a : b; +} + +export function breaking(message: string): Verdict { + return { compat: 'breaking', message }; +} + +export function warning(message: string): Verdict { + return { compat: 'warning', message }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/types.test.ts --coverage.enabled=false` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/commands/diff/engine/types.ts packages/cli/src/commands/diff/engine/__tests__/types.test.ts +git commit -m "feat(cli): add diff data model and verdict helpers" +``` + +--- + +### Task 2: Predicates library + +**Files:** + +- Create: `packages/cli/src/commands/diff/engine/predicates.ts` +- Test: `packages/cli/src/commands/diff/engine/__tests__/predicates.test.ts` + +**Interfaces:** + +- Produces: `isScalar(v): boolean`, `isScalarArray(v): boolean`, `scalarEquals(a, b): boolean`, `missingItems(before, after): unknown[]`, `addedItems(before, after): unknown[]`, `becameTrue(before, after): boolean`, `isTypeNarrowed(before, after): boolean`, `isTypeWidened(before, after): boolean`. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/cli/src/commands/diff/engine/__tests__/predicates.test.ts +import { + isScalar, + isScalarArray, + scalarEquals, + missingItems, + addedItems, + becameTrue, + isTypeNarrowed, + isTypeWidened, +} from '../predicates.js'; + +describe('diff predicates', () => { + it('detects scalars and scalar arrays', () => { + expect(isScalar('a')).toBe(true); + expect(isScalar(1)).toBe(true); + expect(isScalar(null)).toBe(true); + expect(isScalar({})).toBe(false); + expect(isScalarArray(['a', 1, true])).toBe(true); + expect(isScalarArray([{ a: 1 }])).toBe(false); + expect(isScalarArray('a')).toBe(false); + }); + + it('compares scalars and scalar arrays', () => { + expect(scalarEquals('a', 'a')).toBe(true); + expect(scalarEquals(['a', 'b'], ['a', 'b'])).toBe(true); + expect(scalarEquals(['a', 'b'], ['b', 'a'])).toBe(false); + expect(scalarEquals(undefined, undefined)).toBe(true); + expect(scalarEquals(1, '1')).toBe(false); + }); + + it('computes missing and added items', () => { + expect(missingItems(['a', 'b', 'c'], ['a', 'b'])).toEqual(['c']); + expect(missingItems(['a'], ['a', 'b'])).toEqual([]); + expect(missingItems(undefined, ['a'])).toEqual([]); + expect(addedItems(['a'], ['a', 'b'])).toEqual(['b']); + expect(addedItems(['a'], undefined)).toEqual([]); + }); + + it('detects becameTrue', () => { + expect(becameTrue(undefined, true)).toBe(true); + expect(becameTrue(false, true)).toBe(true); + expect(becameTrue(true, true)).toBe(false); + expect(becameTrue(true, false)).toBe(false); + }); + + it('classifies type narrowing and widening', () => { + // integer → number widens the accepted set + expect(isTypeNarrowed('integer', 'number')).toBe(false); + expect(isTypeWidened('integer', 'number')).toBe(true); + // number → integer narrows it + expect(isTypeNarrowed('number', 'integer')).toBe(true); + expect(isTypeWidened('number', 'integer')).toBe(false); + // string → number is incompatible both ways + expect(isTypeNarrowed('string', 'number')).toBe(true); + expect(isTypeWidened('string', 'number')).toBe(true); + // same type — neither + expect(isTypeNarrowed('string', 'string')).toBe(false); + expect(isTypeWidened('string', 'string')).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/predicates.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../predicates.js`. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/cli/src/commands/diff/engine/predicates.ts + +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); +} + +export function scalarEquals(a: unknown, b: unknown): boolean { + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, i) => scalarEquals(item, b[i])); + } + return a === b; +} + +export function missingItems(before: unknown, after: unknown): unknown[] { + if (!Array.isArray(before)) return []; + const afterItems = Array.isArray(after) ? after : []; + return before.filter((item) => !afterItems.includes(item)); +} + +export function addedItems(before: unknown, after: unknown): unknown[] { + return missingItems(after, before); +} + +export function becameTrue(before: unknown, after: unknown): boolean { + return before !== true && after === true; +} + +// integer → number is the only widening pair among JSON Schema primitive types. +const WIDENING_PAIRS: Record = { integer: ['number'] }; + +export function isTypeNarrowed(before: unknown, after: unknown): boolean { + if (before === after) return false; + if (typeof before !== 'string' || typeof after !== 'string') return true; // conservative + return !(WIDENING_PAIRS[before] ?? []).includes(after); +} + +export function isTypeWidened(before: unknown, after: unknown): boolean { + if (before === after) return false; + if (typeof before !== 'string' || typeof after !== 'string') return true; // conservative + return !(WIDENING_PAIRS[after] ?? []).includes(before); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/predicates.test.ts --coverage.enabled=false` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/commands/diff/engine/predicates.ts packages/cli/src/commands/diff/engine/__tests__/predicates.test.ts +git commit -m "feat(cli): add diff predicate helpers" +``` + +--- + +### Task 3: Identity registry + +**Files:** + +- Create: `packages/cli/src/commands/diff/engine/node-identity.ts` +- Test: `packages/cli/src/commands/diff/engine/__tests__/node-identity.test.ts` + +**Interfaces:** + +- Consumes: `isPlainObject` from `@redocly/openapi-core`. +- Produces: `getIdentityKey(typeName: string, value: unknown): string | undefined` — returns a stable list-item segment like `{query:limit}`, or `undefined` for positional fallback. Pointer-special characters inside keys are escaped (`~` → `~0`, `/` → `~1`). + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/cli/src/commands/diff/engine/__tests__/node-identity.test.ts +import { getIdentityKey } from '../node-identity.js'; + +describe('getIdentityKey', () => { + it('keys Parameter by in+name', () => { + expect(getIdentityKey('Parameter', { in: 'query', name: 'limit' })).toBe('{query:limit}'); + }); + + it('keys Server by url with pointer escaping', () => { + expect(getIdentityKey('Server', { url: 'https://api.example.com/v1' })).toBe( + '{https:~1~1api.example.com~1v1}' + ); + }); + + it('keys Tag by name', () => { + expect(getIdentityKey('Tag', { name: 'pets' })).toBe('{pets}'); + }); + + it('keys SecurityRequirement by sorted scheme names', () => { + expect(getIdentityKey('SecurityRequirement', { oauth: [], apiKey: [] })).toBe('{apiKey+oauth}'); + }); + + it('returns undefined for unknown types and malformed values (positional fallback)', () => { + expect(getIdentityKey('Schema', { type: 'string' })).toBeUndefined(); + expect(getIdentityKey('Parameter', { name: 'limit' })).toBeUndefined(); // no `in` + expect(getIdentityKey('Parameter', 'not-an-object')).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/node-identity.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../node-identity.js`. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/cli/src/commands/diff/engine/node-identity.ts +import { isPlainObject } from '@redocly/openapi-core'; + +// JSON Pointer escaping for identity-key content: keys become pointer segments. +function esc(value: string): string { + return value.replace(/~/g, '~0').replace(/\//g, '~1'); +} + +type IdentityKeyFn = (value: Record) => string | undefined; + +// Identity keys for list items that have a natural identity. +// Everything else falls back to positional matching (see spec §5.2). +const IDENTITY_KEYS: Record = { + Parameter: (v) => + typeof v.in === 'string' && typeof v.name === 'string' + ? `{${esc(v.in)}:${esc(v.name)}}` + : undefined, + Server: (v) => (typeof v.url === 'string' ? `{${esc(v.url)}}` : undefined), + Tag: (v) => (typeof v.name === 'string' ? `{${esc(v.name)}}` : undefined), + SecurityRequirement: (v) => `{${Object.keys(v).sort().map(esc).join('+')}}`, +}; + +export function getIdentityKey(typeName: string, value: unknown): string | undefined { + const keyFn = IDENTITY_KEYS[typeName]; + if (!keyFn || !isPlainObject(value)) return undefined; + return keyFn(value as Record); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/node-identity.test.ts --coverage.enabled=false` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/commands/diff/engine/node-identity.ts packages/cli/src/commands/diff/engine/__tests__/node-identity.test.ts +git commit -m "feat(cli): add diff list-item identity registry" +``` + +--- + +### Task 4: Collection — document → flat map + +**Files:** + +- Create: `packages/cli/src/commands/diff/engine/collect.ts` +- Test: `packages/cli/src/commands/diff/engine/__tests__/collect.test.ts` + +**Interfaces:** + +- Consumes: `walkDocument`, `normalizeVisitors`, `isRef`, `isPlainObject` and types `WalkContext`, `UserContext`, `Document`, `NormalizedNodeType`, `Config`, `SpecVersion` — all from `@redocly/openapi-core`; `getIdentityKey` (Task 3); `isScalar`, `isScalarArray` (Task 2); `NodeEntry` (Task 1). +- Produces: `collectDocumentMap(opts: { document: Document; types: Record; specVersion: SpecVersion; config: Config }): CollectedDocument` where `CollectedDocument = { entries: Map; usageEdges: Array<{ site: string; target: string }> }`. + +**Key behaviors under test:** identity keys replace array indexes in stable pointers; real pointers preserved; `$ref` recorded as attribute and NOT followed (empty `resolvedRefMap`); components collected at canonical paths; scalar arrays (`enum`, `required`) snapshotted; identity collision gets `#2` suffix; `parentPointer` derived from the pointer string. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/cli/src/commands/diff/engine/__tests__/collect.test.ts +import { + createConfig, + detectSpec, + getTypes, + makeDocumentFromString, + normalizeTypes, +} from '@redocly/openapi-core'; +import { outdent } from 'outdent'; + +import { collectDocumentMap } from '../collect.js'; + +async function collect(yaml: string) { + const document = makeDocumentFromString(yaml, ''); + const config = await createConfig({}); + const specVersion = detectSpec(document.parsed); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + return collectDocumentMap({ document, types, specVersion, config }); +} + +describe('collectDocumentMap', () => { + it('collects nodes with identity-keyed stable pointers and real pointers', async () => { + const { entries } = await collect(outdent` + openapi: 3.1.0 + info: { title: Test, version: '1.0' } + paths: + /pets: + get: + parameters: + - name: filter + in: query + schema: { type: string } + - name: limit + in: query + required: true + schema: { type: integer } + responses: + '200': { description: OK } + `); + + const limit = entries.get('#/paths/~1pets/get/parameters/{query:limit}'); + expect(limit).toBeDefined(); + expect(limit!.typeName).toBe('Parameter'); + expect(limit!.realPointer).toBe('#/paths/~1pets/get/parameters/1'); + expect(limit!.parentPointer).toBe('#/paths/~1pets/get/parameters'); + expect(limit!.scalars).toMatchObject({ name: 'limit', in: 'query', required: true }); + + // nested schema is its own entry under the stable parent + const schema = entries.get('#/paths/~1pets/get/parameters/{query:limit}/schema'); + expect(schema).toBeDefined(); + expect(schema!.typeName).toBe('Schema'); + expect(schema!.scalars).toMatchObject({ type: 'integer' }); + }); + + it('records $ref values as attributes and does not follow them', async () => { + const { entries, usageEdges } = await collect(outdent` + openapi: 3.1.0 + info: { title: Test, version: '1.0' } + paths: + /pets: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + components: + schemas: + Pet: + type: object + properties: + name: { type: string } + `); + + const mediaType = entries.get('#/paths/~1pets/get/responses/200/content/application~1json'); + expect(mediaType).toBeDefined(); + expect(mediaType!.refs).toEqual({ schema: '#/components/schemas/Pet' }); + + // the component is collected once, at its canonical path + const pet = entries.get('#/components/schemas/Pet'); + expect(pet).toBeDefined(); + expect(pet!.typeName).toBe('Schema'); + expect(entries.get('#/components/schemas/Pet/properties/name')).toBeDefined(); + + // usage edge recorded + expect(usageEdges).toContainEqual({ + site: '#/paths/~1pets/get/responses/200/content/application~1json/schema', + target: '#/components/schemas/Pet', + }); + }); + + it('snapshots scalar arrays like enum and required', async () => { + const { entries } = await collect(outdent` + openapi: 3.1.0 + info: { title: Test, version: '1.0' } + paths: {} + components: + schemas: + Size: + type: string + enum: [s, m, l] + Pet: + type: object + required: [name] + properties: + name: { type: string } + `); + + expect(entries.get('#/components/schemas/Size')!.scalars.enum).toEqual(['s', 'm', 'l']); + expect(entries.get('#/components/schemas/Pet')!.scalars.required).toEqual(['name']); + }); + + it('suffixes colliding identity keys deterministically', async () => { + const { entries } = await collect(outdent` + openapi: 3.1.0 + info: { title: Test, version: '1.0' } + paths: + /pets: + get: + parameters: + - name: dup + in: query + - name: dup + in: query + responses: + '200': { description: OK } + `); + + expect(entries.has('#/paths/~1pets/get/parameters/{query:dup}')).toBe(true); + expect(entries.has('#/paths/~1pets/get/parameters/{query:dup}#2')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/collect.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../collect.js`. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/cli/src/commands/diff/engine/collect.ts +import { isPlainObject, isRef, normalizeVisitors, walkDocument } from '@redocly/openapi-core'; + +import { getIdentityKey } from './node-identity.js'; +import { isScalar, isScalarArray } from './predicates.js'; + +import type { + Config, + Document, + NormalizedNodeType, + SpecVersion, + UserContext, + WalkContext, +} from '@redocly/openapi-core'; +import type { NodeEntry } from './types.js'; + +export interface CollectedDocument { + entries: Map; + usageEdges: Array<{ site: string; target: string }>; +} + +export function collectDocumentMap(opts: { + document: Document; + types: Record; + specVersion: SpecVersion; + config: Config; +}): CollectedDocument { + const { document, types, specVersion, config } = opts; + const entries = new Map(); + const usageEdges: Array<{ site: string; target: string }> = []; + // realPointer → stablePointer, filled top-down (walk is pre-order) + const stableByReal = new Map(); + const collisionCounts = new Map(); + + const visitor = { + any: { + enter(node: unknown, ctx: UserContext) { + if (!isPlainObject(node) && !Array.isArray(node)) return; + + const realPointer = ctx.location.pointer; + const { parentReal, segment } = splitPointer(realPointer); + const stableParent = + parentReal === null ? null : (stableByReal.get(parentReal) ?? parentReal); + + let stableSegment = segment; + if (Array.isArray(ctx.parent)) { + const identity = getIdentityKey(ctx.type.name, node); + if (identity !== undefined) stableSegment = identity; + } + + let pointer = + stableParent === null + ? realPointer + : stableParent === '#/' + ? `#/${stableSegment}` + : `${stableParent}/${stableSegment}`; + + if (entries.has(pointer)) { + const next = (collisionCounts.get(pointer) ?? 1) + 1; + collisionCounts.set(pointer, next); + pointer = `${pointer}#${next}`; + } + stableByReal.set(realPointer, pointer); + + const scalars: Record = {}; + const refs: Record = {}; + if (isPlainObject(node)) { + for (const [prop, value] of Object.entries(node)) { + if (isRef(value)) { + refs[prop] = value.$ref; + usageEdges.push({ site: `${pointer}/${prop}`, target: value.$ref }); + } else if (isScalar(value) || isScalarArray(value)) { + scalars[prop] = value; + } + } + } + + entries.set(pointer, { + pointer, + realPointer, + parentPointer: stableParent, + typeName: ctx.type.name, + scalars, + refs, + raw: node, + }); + }, + }, + }; + + const normalizedVisitors = normalizeVisitors( + [{ severity: 'warn', ruleId: 'diff-collect', visitor }], + types + ); + const ctx: WalkContext = { problems: [], specVersion, config, visitorsData: {} }; + + walkDocument({ + document, + rootType: types.Root, + normalizedVisitors, + // Empty map: $ref nodes fail to resolve and are NOT traversed — + // refs are recorded as node attributes above ($ref-as-scalar, spec §5.3). + resolvedRefMap: new Map(), + ctx, + }); + + return { entries, usageEdges }; +} + +function splitPointer(pointer: string): { parentReal: string | null; segment: string } { + if (pointer === '#/' || pointer === '#') { + return { parentReal: null, segment: pointer }; + } + const idx = pointer.lastIndexOf('/'); + const parentReal = idx <= 1 ? '#/' : pointer.slice(0, idx); + return { parentReal, segment: pointer.slice(idx + 1) }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/collect.test.ts --coverage.enabled=false` +Expected: PASS (4 tests). + +**If the walk throws or skips on the empty `resolvedRefMap`:** inspect `packages/core/src/walk.ts` `resolve()` closure — it must return `{ node: undefined, location: undefined }` for unresolved refs. If it throws instead, wrap the ref lookup result handling in `collect.ts` is NOT the fix; instead pass a `ResolvedRefMap` from `resolveDocument` and add a guard in the visitor: skip any entry whose `realPointer` was already recorded (dedupe by first visit). Re-run the test — the ref must still appear in `refs` and `#/components/schemas/Pet` must exist exactly once. + +- [ ] **Step 5: Run the full diff test suite and typecheck** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff --coverage.enabled=false && npm run typecheck` +Expected: all tests PASS; no type errors. + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/commands/diff/engine/collect.ts packages/cli/src/commands/diff/engine/__tests__/collect.test.ts +git commit -m "feat(cli): collect documents into flat stable-pointer maps for diff" +``` + +--- + +### Task 5: Compare — two maps → RawChange[] + +**Files:** + +- Create: `packages/cli/src/commands/diff/engine/compare.ts` +- Test: `packages/cli/src/commands/diff/engine/__tests__/compare.test.ts` + +**Interfaces:** + +- Consumes: `NodeEntry`, `RawChange` (Task 1); `scalarEquals` (Task 2). +- Produces: `compareMaps(base: Map, revision: Map): RawChange[]`. + +**Key behaviors under test:** matched nodes → property-level `changed`; added/removed subtree collapses to one change at its root; descendants of a boundary stay silent; `replaced` (typeName differs) emits a removed+added pair and suppresses descendants; unchanged emits nothing; output ordered by pointer. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/cli/src/commands/diff/engine/__tests__/compare.test.ts +import { compareMaps } from '../compare.js'; + +import type { NodeEntry } from '../types.js'; + +function entry(partial: Partial & { pointer: string }): NodeEntry { + return { + realPointer: partial.pointer, + parentPointer: null, + typeName: 'Schema', + scalars: {}, + refs: {}, + raw: {}, + ...partial, + }; +} + +function toMap(entries: NodeEntry[]): Map { + return new Map(entries.map((e) => [e.pointer, e])); +} + +describe('compareMaps', () => { + it('emits property-level changes for matched nodes', () => { + const base = toMap([entry({ pointer: '#/a', scalars: { type: 'integer', description: 'x' } })]); + const revision = toMap([ + entry({ pointer: '#/a', scalars: { type: 'number', description: 'x', format: 'float' } }), + ]); + + const changes = compareMaps(base, revision); + expect(changes).toEqual([ + { + pointer: '#/a', + property: 'format', + kind: 'changed', + typeName: 'Schema', + base: { pointer: '#/a/format', value: undefined }, + revision: { pointer: '#/a/format', value: 'float' }, + }, + { + pointer: '#/a', + property: 'type', + kind: 'changed', + typeName: 'Schema', + base: { pointer: '#/a/type', value: 'integer' }, + revision: { pointer: '#/a/type', value: 'number' }, + }, + ]); + }); + + it('collapses a removed subtree into one change at its root', () => { + const shared = entry({ pointer: '#/paths', typeName: 'PathsMap' }); + const base = toMap([ + shared, + entry({ + pointer: '#/paths/~1pets', + parentPointer: '#/paths', + typeName: 'PathItem', + raw: { get: {} }, + }), + entry({ + pointer: '#/paths/~1pets/get', + parentPointer: '#/paths/~1pets', + typeName: 'Operation', + }), + ]); + const revision = toMap([shared]); + + const changes = compareMaps(base, revision); + expect(changes).toEqual([ + { + pointer: '#/paths/~1pets', + kind: 'removed', + typeName: 'PathItem', + base: { pointer: '#/paths/~1pets', value: { get: {} } }, + }, + ]); + }); + + it('treats typeName mismatch as a removed+added pair and suppresses descendants', () => { + const base = toMap([ + entry({ pointer: '#/x', typeName: 'Schema', raw: { type: 'object' } }), + entry({ + pointer: '#/x/properties/a', + parentPointer: '#/x', + scalars: { type: 'string' }, + }), + ]); + const revision = toMap([ + entry({ pointer: '#/x', typeName: 'Example', raw: { value: 1 } }), + entry({ + pointer: '#/x/properties/a', + parentPointer: '#/x', + scalars: { type: 'number' }, + }), + ]); + + const changes = compareMaps(base, revision); + expect(changes).toEqual([ + { + pointer: '#/x', + kind: 'removed', + typeName: 'Schema', + base: { pointer: '#/x', value: { type: 'object' } }, + }, + { + pointer: '#/x', + kind: 'added', + typeName: 'Example', + revision: { pointer: '#/x', value: { value: 1 } }, + }, + ]); + }); + + it('emits nothing when maps are identical', () => { + const entries = [entry({ pointer: '#/a', scalars: { type: 'string' } })]; + expect(compareMaps(toMap(entries), toMap(entries))).toEqual([]); + }); + + it('compares ref attributes like scalars', () => { + const base = toMap([entry({ pointer: '#/m', refs: { schema: '#/components/schemas/A' } })]); + const revision = toMap([entry({ pointer: '#/m', refs: { schema: '#/components/schemas/B' } })]); + + const changes = compareMaps(base, revision); + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchObject({ + pointer: '#/m', + property: 'schema', + kind: 'changed', + base: { value: '#/components/schemas/A' }, + revision: { value: '#/components/schemas/B' }, + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/compare.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../compare.js`. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/cli/src/commands/diff/engine/compare.ts +import { scalarEquals } from './predicates.js'; + +import type { NodeEntry, RawChange } from './types.js'; + +export function compareMaps( + base: Map, + revision: Map +): RawChange[] { + const changes: RawChange[] = []; + const keys = new Set([...base.keys(), ...revision.keys()]); + + // Pass 1: boundary nodes — added roots, removed roots, replaced (typeName differs). + const boundaries = new Set(); + for (const key of keys) { + const a = base.get(key); + const b = revision.get(key); + if (!a || !b || a.typeName !== b.typeName) { + boundaries.add(key); + } + } + + const getEntry = (key: string) => base.get(key) ?? revision.get(key); + + const hasBoundaryAncestor = (key: string): boolean => { + let parent = getEntry(key)?.parentPointer ?? null; + while (parent !== null) { + if (boundaries.has(parent)) return true; + parent = getEntry(parent)?.parentPointer ?? null; + } + return false; + }; + + // Pass 2: emission, in deterministic pointer order. + for (const key of [...keys].sort()) { + if (hasBoundaryAncestor(key)) continue; // implied by a reported ancestor + const a = base.get(key); + const b = revision.get(key); + + if (a && !b) { + changes.push({ + pointer: key, + kind: 'removed', + typeName: a.typeName, + base: { pointer: a.realPointer, value: a.raw }, + }); + } else if (!a && b) { + changes.push({ + pointer: key, + kind: 'added', + typeName: b.typeName, + revision: { pointer: b.realPointer, value: b.raw }, + }); + } else if (a && b && a.typeName !== b.typeName) { + // replaced → a removed+added pair at the same pointer + changes.push({ + pointer: key, + kind: 'removed', + typeName: a.typeName, + base: { pointer: a.realPointer, value: a.raw }, + }); + changes.push({ + pointer: key, + kind: 'added', + typeName: b.typeName, + revision: { pointer: b.realPointer, value: b.raw }, + }); + } else if (a && b) { + const props = new Set([ + ...Object.keys(a.scalars), + ...Object.keys(a.refs), + ...Object.keys(b.scalars), + ...Object.keys(b.refs), + ]); + for (const property of [...props].sort()) { + const before = property in a.refs ? a.refs[property] : a.scalars[property]; + const after = property in b.refs ? b.refs[property] : b.scalars[property]; + if (!scalarEquals(before, after)) { + changes.push({ + pointer: key, + property, + kind: 'changed', + typeName: a.typeName, + base: { pointer: `${a.realPointer}/${property}`, value: before }, + revision: { pointer: `${b.realPointer}/${property}`, value: after }, + }); + } + } + } + } + + return changes; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/compare.test.ts --coverage.enabled=false` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/commands/diff/engine/compare.ts packages/cli/src/commands/diff/engine/__tests__/compare.test.ts +git commit -m "feat(cli): add two-pass flat map comparison for diff" +``` + +--- + +### Task 6: Usage index and polarity + +**Files:** + +- Create: `packages/cli/src/commands/diff/engine/classify/usage.ts` +- Create: `packages/cli/src/commands/diff/engine/classify/polarity.ts` +- Test: `packages/cli/src/commands/diff/engine/__tests__/polarity.test.ts` + +**Interfaces:** + +- Consumes: `Polarity` (Task 1). +- Produces: + - `usage.ts`: `class UsageIndex { constructor(edges: Array<{ site: string; target: string }>); polarityOf(componentPointer: string, resolveSitePolarity: (site: string) => Polarity): Polarity }`, `getComponentRoot(pointer: string): string | undefined`, `mergePolarity(a: Polarity, b: Polarity): Polarity`. + - `polarity.ts`: `getPolarity(pointer: string, usage: UsageIndex): Polarity`. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/cli/src/commands/diff/engine/__tests__/polarity.test.ts +import { getPolarity } from '../classify/polarity.js'; +import { UsageIndex, getComponentRoot, mergePolarity } from '../classify/usage.js'; + +describe('getComponentRoot', () => { + it('extracts the component root', () => { + expect(getComponentRoot('#/components/schemas/Pet/properties/name')).toBe( + '#/components/schemas/Pet' + ); + expect(getComponentRoot('#/paths/~1pets/get')).toBeUndefined(); + }); +}); + +describe('mergePolarity', () => { + it('merges polarities', () => { + expect(mergePolarity('neutral', 'request')).toBe('request'); + expect(mergePolarity('request', 'request')).toBe('request'); + expect(mergePolarity('request', 'response')).toBe('both'); + expect(mergePolarity('both', 'response')).toBe('both'); + }); +}); + +describe('getPolarity', () => { + const emptyUsage = new UsageIndex([]); + + it('derives polarity from pointer segments', () => { + expect(getPolarity('#/paths/~1p/get/responses/200/description', emptyUsage)).toBe('response'); + expect(getPolarity('#/paths/~1p/get/parameters/{query:limit}/schema', emptyUsage)).toBe( + 'request' + ); + expect(getPolarity('#/paths/~1p/post/requestBody/content/application~1json', emptyUsage)).toBe( + 'request' + ); + expect(getPolarity('#/info/title', emptyUsage)).toBe('neutral'); + expect(getPolarity('#/tags/{pets}', emptyUsage)).toBe('neutral'); + }); + + it('treats callbacks and webhooks as neutral (inverted direction, not judged in v1)', () => { + expect( + getPolarity('#/paths/~1p/post/callbacks/onEvent/~1cb/post/requestBody', emptyUsage) + ).toBe('neutral'); + expect(getPolarity('#/webhooks/newPet/post/parameters/{query:x}', emptyUsage)).toBe('neutral'); + }); + + it('derives component polarity from usage sites', () => { + const usage = new UsageIndex([ + { + site: '#/paths/~1pets/get/responses/200/content/application~1json/schema', + target: '#/components/schemas/Pet', + }, + ]); + expect(getPolarity('#/components/schemas/Pet/properties/name', usage)).toBe('response'); + }); + + it('derives both when a component is used on both sides', () => { + const usage = new UsageIndex([ + { + site: '#/paths/~1pets/get/responses/200/content/a~1b/schema', + target: '#/components/schemas/Pet', + }, + { + site: '#/paths/~1pets/post/requestBody/content/a~1b/schema', + target: '#/components/schemas/Pet', + }, + ]); + expect(getPolarity('#/components/schemas/Pet', usage)).toBe('both'); + }); + + it('resolves transitive usage through other components, cycle-safe', () => { + const usage = new UsageIndex([ + { + site: '#/paths/~1pets/get/responses/200/content/a~1b/schema', + target: '#/components/schemas/Pet', + }, + { + site: '#/components/schemas/Pet/properties/address', + target: '#/components/schemas/Address', + }, + // cycle: + { site: '#/components/schemas/Address/properties/pet', target: '#/components/schemas/Pet' }, + ]); + expect(getPolarity('#/components/schemas/Address', usage)).toBe('response'); + }); + + it('returns neutral for unused components', () => { + expect(getPolarity('#/components/schemas/Orphan', emptyUsage)).toBe('neutral'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/polarity.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../classify/polarity.js`. + +- [ ] **Step 3: Write the implementation (both files)** + +```ts +// packages/cli/src/commands/diff/engine/classify/usage.ts +import type { Polarity } from '../types.js'; + +export function getComponentRoot(pointer: string): string | undefined { + const match = pointer.match(/^(#\/components\/[^/]+\/[^/]+)/); + return match?.[1]; +} + +export function mergePolarity(a: Polarity, b: Polarity): Polarity { + if (a === b) return a; + if (a === 'neutral') return b; + if (b === 'neutral') return a; + return 'both'; +} + +export class UsageIndex { + private sitesByTarget = new Map>(); + + constructor(edges: Array<{ site: string; target: string }>) { + for (const { site, target } of edges) { + const root = getComponentRoot(target) ?? target; + if (!this.sitesByTarget.has(root)) this.sitesByTarget.set(root, new Set()); + this.sitesByTarget.get(root)!.add(site); + } + } + + polarityOf(componentPointer: string, resolveSitePolarity: (site: string) => Polarity): Polarity { + const seen = new Set(); + const visit = (pointer: string): Polarity => { + if (seen.has(pointer)) return 'neutral'; // cycle guard + seen.add(pointer); + let result: Polarity = 'neutral'; + for (const site of this.sitesByTarget.get(pointer) ?? []) { + // a ref site inside another component chains to that component's own usage + const siteComponentRoot = getComponentRoot(site); + const sitePolarity = siteComponentRoot + ? visit(siteComponentRoot) + : resolveSitePolarity(site); + result = mergePolarity(result, sitePolarity); + if (result === 'both') return 'both'; + } + return result; + }; + return visit(getComponentRoot(componentPointer) ?? componentPointer); + } +} +``` + +```ts +// packages/cli/src/commands/diff/engine/classify/polarity.ts +import { getComponentRoot, type UsageIndex } from './usage.js'; + +import type { Polarity } from '../types.js'; + +// Stable pointers are '#/'-prefixed with '/'-separated segments; identity keys never +// contain a raw '/' (escaped in node-identity.ts), so plain splitting is safe. +function segmentsOf(pointer: string): string[] { + return pointer.replace(/^#\//, '').split('/'); +} + +export function getPolarity(pointer: string, usage: UsageIndex): Polarity { + const segments = segmentsOf(pointer); + if (segments.includes('callbacks') || segments.includes('webhooks')) return 'neutral'; + if (segments[0] === 'components') { + const root = getComponentRoot(pointer); + if (!root) return 'neutral'; + return usage.polarityOf(root, getSitePolarity); + } + return getSitePolarity(pointer); +} + +function getSitePolarity(pointer: string): Polarity { + const segments = segmentsOf(pointer); + if (segments.includes('callbacks') || segments.includes('webhooks')) return 'neutral'; + if (segments.includes('responses')) return 'response'; + if (segments.includes('parameters') || segments.includes('requestBody')) return 'request'; + return 'neutral'; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/polarity.test.ts --coverage.enabled=false` +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/commands/diff/engine/classify/usage.ts packages/cli/src/commands/diff/engine/classify/polarity.ts packages/cli/src/commands/diff/engine/__tests__/polarity.test.ts +git commit -m "feat(cli): add diff polarity engine with component usage index" +``` + +--- + +### Task 7: Classification engine + first rules (operation, path) + +**Files:** + +- Create: `packages/cli/src/commands/diff/engine/classify/index.ts` +- Create: `packages/cli/src/commands/diff/engine/classify/rules/operation-rules.ts` +- Create: `packages/cli/src/commands/diff/engine/classify/oas3.ts` +- Create: `packages/cli/src/commands/diff/engine/classify/oas3_1.ts` +- Test: `packages/cli/src/commands/diff/engine/__tests__/classify.test.ts` + +**Interfaces:** + +- Consumes: Tasks 1, 6 outputs. +- Produces: + - `classify/index.ts`: `classifyChanges(opts: { changes: RawChange[]; specVersion: SpecVersion; base: Map; revision: Map; usage: UsageIndex }): Change[]`. + - `operation-rules.ts`: `operationRemoved: DiffRule`, `pathRemoved: DiffRule`. + - `oas3.ts`: `oas3Rules: DiffRuleRegistry`; `oas3_1.ts`: `oas3_1Rules: DiffRuleRegistry`. + +**Engine policy (spec §7.2):** evaluate ALL rules registered for the change's type; polarity `both` expands to `request` and `response` passes; the most severe verdict wins; all firing rule ids attached (deduped, sorted); default `non-breaking`; unknown spec version → structural only. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/cli/src/commands/diff/engine/__tests__/classify.test.ts +import { classifyChanges } from '../classify/index.js'; +import { UsageIndex } from '../classify/usage.js'; + +import type { NodeEntry, RawChange } from '../types.js'; + +const emptyMaps = { + base: new Map(), + revision: new Map(), + usage: new UsageIndex([]), +}; + +describe('classifyChanges', () => { + it('classifies operation removal as breaking', () => { + const changes: RawChange[] = [ + { + pointer: '#/paths/~1pets/get', + kind: 'removed', + typeName: 'Operation', + base: { pointer: '#/paths/~1pets/get', value: {} }, + }, + ]; + const [change] = classifyChanges({ changes, specVersion: 'oas3_1', ...emptyMaps }); + expect(change.compat).toBe('breaking'); + expect(change.ruleIds).toEqual(['operation-removed']); + expect(change.message).toBeDefined(); + }); + + it('classifies path removal as breaking', () => { + const changes: RawChange[] = [ + { + pointer: '#/paths/~1pets', + kind: 'removed', + typeName: 'PathItem', + base: { pointer: '#/paths/~1pets', value: {} }, + }, + ]; + const [change] = classifyChanges({ changes, specVersion: 'oas3_0', ...emptyMaps }); + expect(change.compat).toBe('breaking'); + expect(change.ruleIds).toEqual(['path-removed']); + }); + + it('defaults to non-breaking when no rule judges the change', () => { + const changes: RawChange[] = [ + { + pointer: '#/info', + property: 'title', + kind: 'changed', + typeName: 'Info', + base: { pointer: '#/info/title', value: 'a' }, + revision: { pointer: '#/info/title', value: 'b' }, + }, + ]; + const [change] = classifyChanges({ changes, specVersion: 'oas3_1', ...emptyMaps }); + expect(change.compat).toBe('non-breaking'); + expect(change.ruleIds).toBeUndefined(); + }); + + it('returns structural-only (non-breaking) for specs without a registry', () => { + const changes: RawChange[] = [ + { + pointer: '#/x', + kind: 'removed', + typeName: 'Operation', + base: { pointer: '#/x', value: {} }, + }, + ]; + const [change] = classifyChanges({ changes, specVersion: 'async2', ...emptyMaps }); + expect(change.compat).toBe('non-breaking'); + }); + + it('added operations are non-breaking', () => { + const changes: RawChange[] = [ + { + pointer: '#/paths/~1pets/post', + kind: 'added', + typeName: 'Operation', + revision: { pointer: '#/paths/~1pets/post', value: {} }, + }, + ]; + const [change] = classifyChanges({ changes, specVersion: 'oas3_1', ...emptyMaps }); + expect(change.compat).toBe('non-breaking'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/classify.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../classify/index.js`. + +- [ ] **Step 3: Write the implementation (four files)** + +```ts +// packages/cli/src/commands/diff/engine/classify/rules/operation-rules.ts +import { breaking } from '../../types.js'; + +import type { DiffRule } from '../../types.js'; + +export const operationRemoved: DiffRule = { + id: 'operation-removed', + description: 'Removing an operation breaks all of its consumers.', + visit(change) { + if (change.kind !== 'removed') return; + return breaking('Operation was removed.'); + }, +}; + +export const pathRemoved: DiffRule = { + id: 'path-removed', + description: 'Removing a path breaks all consumers of its operations.', + visit(change) { + if (change.kind !== 'removed') return; + return breaking('Path was removed.'); + }, +}; +``` + +```ts +// packages/cli/src/commands/diff/engine/classify/oas3.ts +import { operationRemoved, pathRemoved } from './rules/operation-rules.js'; + +import type { DiffRuleRegistry } from '../types.js'; + +export const oas3Rules: DiffRuleRegistry = { + Operation: [operationRemoved], + PathItem: [pathRemoved], +}; +``` + +```ts +// packages/cli/src/commands/diff/engine/classify/oas3_1.ts +import { oas3Rules } from './oas3.js'; + +import type { DiffRuleRegistry } from '../types.js'; + +// Inherits oas3 rules; override or extend pointwise when 3.1-specific rules appear. +export const oas3_1Rules: DiffRuleRegistry = { ...oas3Rules }; +``` + +```ts +// packages/cli/src/commands/diff/engine/classify/index.ts +import { compatRank } from '../types.js'; +import { getPolarity } from './polarity.js'; +import { oas3Rules } from './oas3.js'; +import { oas3_1Rules } from './oas3_1.js'; + +import type { SpecVersion } from '@redocly/openapi-core'; +import type { + Change, + DiffRuleRegistry, + NodeEntry, + Polarity, + RawChange, + Verdict, +} from '../types.js'; +import type { UsageIndex } from './usage.js'; + +const REGISTRIES: Partial> = { + oas3_0: oas3Rules, + oas3_1: oas3_1Rules, + oas3_2: oas3_1Rules, +}; + +function expandPolarity(polarity: Polarity): Polarity[] { + return polarity === 'both' ? ['request', 'response'] : [polarity]; +} + +export function classifyChanges(opts: { + changes: RawChange[]; + specVersion: SpecVersion; + base: Map; + revision: Map; + usage: UsageIndex; +}): Change[] { + const { changes, specVersion, base, revision, usage } = opts; + const registry = REGISTRIES[specVersion] ?? {}; + + return changes.map((change) => { + const rules = registry[change.typeName] ?? []; + const ruleIds: string[] = []; + let winner: Verdict | undefined; + + for (const polarity of expandPolarity(getPolarity(change.pointer, usage))) { + const ctx = { + polarity, + specVersion, + base: (pointer: string) => base.get(pointer), + revision: (pointer: string) => revision.get(pointer), + }; + for (const rule of rules) { + const verdict = rule.visit(change, ctx); + if (!verdict) continue; + if (!ruleIds.includes(rule.id)) ruleIds.push(rule.id); + if (!winner || compatRank(verdict.compat) > compatRank(winner.compat)) { + winner = verdict; // worst verdict wins; registration order carries no semantics + } + } + } + + return { + ...change, + compat: winner?.compat ?? 'non-breaking', + ...(ruleIds.length ? { ruleIds: ruleIds.sort() } : {}), + ...(winner ? { message: winner.message } : {}), + }; + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/classify.test.ts --coverage.enabled=false` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/commands/diff/engine/classify packages/cli/src/commands/diff/engine/__tests__/classify.test.ts +git commit -m "feat(cli): add diff classification engine with worst-verdict policy" +``` + +--- + +### Task 8: Parameter, response, and media-type rules + +**Files:** + +- Create: `packages/cli/src/commands/diff/engine/classify/rules/parameter-rules.ts` +- Create: `packages/cli/src/commands/diff/engine/classify/rules/response-rules.ts` +- Modify: `packages/cli/src/commands/diff/engine/classify/oas3.ts` +- Test: `packages/cli/src/commands/diff/engine/__tests__/rules-parameter-response.test.ts` + +**Interfaces:** + +- Produces: `parameterRemoved`, `parameterAddedRequired`, `parameterBecameRequired` (in `parameter-rules.ts`); `responseRemoved`, `mediaTypeRemoved` (in `response-rules.ts`) — all `DiffRule`. + +**Note (spec §7.3 deviation):** `parameter-in-changed` is intentionally NOT implemented: the identity key is `in+name`, so a changed `in` produces a removed+added pair, already judged breaking by `parameter-removed`. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/cli/src/commands/diff/engine/__tests__/rules-parameter-response.test.ts +import { + parameterAddedRequired, + parameterBecameRequired, + parameterRemoved, +} from '../classify/rules/parameter-rules.js'; +import { mediaTypeRemoved, responseRemoved } from '../classify/rules/response-rules.js'; + +import type { RawChange, RuleContext } from '../types.js'; + +function ctx(polarity: RuleContext['polarity']): RuleContext { + return { polarity, specVersion: 'oas3_1', base: () => undefined, revision: () => undefined }; +} + +describe('parameter rules', () => { + it('parameter-removed: breaking in request, silent in response context', () => { + const change: RawChange = { + pointer: '#/paths/~1p/get/parameters/{query:limit}', + kind: 'removed', + typeName: 'Parameter', + base: { pointer: '#/paths/~1p/get/parameters/0', value: { name: 'limit', in: 'query' } }, + }; + expect(parameterRemoved.visit(change, ctx('request'))?.compat).toBe('breaking'); + expect(parameterRemoved.visit(change, ctx('response'))).toBeUndefined(); + }); + + it('parameter-added-required: breaking only when the new parameter is required', () => { + const added = (required?: boolean): RawChange => ({ + pointer: '#/paths/~1p/get/parameters/{query:limit}', + kind: 'added', + typeName: 'Parameter', + revision: { + pointer: '#/paths/~1p/get/parameters/0', + value: { name: 'limit', in: 'query', ...(required === undefined ? {} : { required }) }, + }, + }); + expect(parameterAddedRequired.visit(added(true), ctx('request'))?.compat).toBe('breaking'); + expect(parameterAddedRequired.visit(added(false), ctx('request'))).toBeUndefined(); + expect(parameterAddedRequired.visit(added(), ctx('request'))).toBeUndefined(); + }); + + it('parameter-became-required: breaking when required flips to true in request', () => { + const change: RawChange = { + pointer: '#/paths/~1p/get/parameters/{query:limit}', + property: 'required', + kind: 'changed', + typeName: 'Parameter', + base: { pointer: '#/paths/~1p/get/parameters/0/required', value: undefined }, + revision: { pointer: '#/paths/~1p/get/parameters/0/required', value: true }, + }; + expect(parameterBecameRequired.visit(change, ctx('request'))?.compat).toBe('breaking'); + expect(parameterBecameRequired.visit(change, ctx('response'))).toBeUndefined(); + + const relaxed: RawChange = { + ...change, + base: { pointer: change.base!.pointer, value: true }, + revision: { pointer: change.revision!.pointer, value: false }, + }; + expect(parameterBecameRequired.visit(relaxed, ctx('request'))).toBeUndefined(); + }); +}); + +describe('response rules', () => { + it('response-removed is breaking', () => { + const change: RawChange = { + pointer: '#/paths/~1p/get/responses/200', + kind: 'removed', + typeName: 'Response', + base: { pointer: '#/paths/~1p/get/responses/200', value: { description: 'OK' } }, + }; + expect(responseRemoved.visit(change, ctx('response'))?.compat).toBe('breaking'); + }); + + it('media-type-removed is breaking in any polarity', () => { + const change: RawChange = { + pointer: '#/paths/~1p/get/responses/200/content/application~1json', + kind: 'removed', + typeName: 'MediaType', + base: { pointer: '#/paths/~1p/get/responses/200/content/application~1json', value: {} }, + }; + expect(mediaTypeRemoved.visit(change, ctx('response'))?.compat).toBe('breaking'); + expect(mediaTypeRemoved.visit(change, ctx('request'))?.compat).toBe('breaking'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/rules-parameter-response.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve rule modules. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/cli/src/commands/diff/engine/classify/rules/parameter-rules.ts +import { isPlainObject } from '@redocly/openapi-core'; + +import { becameTrue } from '../../predicates.js'; +import { breaking } from '../../types.js'; + +import type { DiffRule } from '../../types.js'; + +export const parameterRemoved: DiffRule = { + id: 'parameter-removed', + description: 'Removing a request parameter breaks clients that send it.', + visit(change, ctx) { + if (change.kind !== 'removed' || ctx.polarity !== 'request') return; + return breaking('Parameter was removed.'); + }, +}; + +export const parameterAddedRequired: DiffRule = { + id: 'parameter-added-required', + description: 'Adding a new required parameter breaks clients that do not send it.', + visit(change, ctx) { + if (change.kind !== 'added' || ctx.polarity !== 'request') return; + const value = change.revision?.value; + if (isPlainObject(value) && value.required === true) { + return breaking('A new required parameter was added.'); + } + }, +}; + +export const parameterBecameRequired: DiffRule = { + id: 'parameter-became-required', + description: 'Marking an existing request parameter as required breaks clients that omit it.', + visit(change, ctx) { + if (change.property !== 'required' || ctx.polarity !== 'request') return; + if (becameTrue(change.base?.value, change.revision?.value)) { + return breaking('Parameter became required.'); + } + }, +}; +``` + +```ts +// packages/cli/src/commands/diff/engine/classify/rules/response-rules.ts +import { breaking } from '../../types.js'; + +import type { DiffRule } from '../../types.js'; + +export const responseRemoved: DiffRule = { + id: 'response-removed', + description: 'Removing a response breaks clients that handle it.', + visit(change) { + if (change.kind !== 'removed') return; + return breaking('Response was removed.'); + }, +}; + +export const mediaTypeRemoved: DiffRule = { + id: 'media-type-removed', + description: 'Removing a media type breaks clients that produce or consume it.', + visit(change) { + if (change.kind !== 'removed') return; + return breaking('Media type was removed.'); + }, +}; +``` + +Update the registry: + +```ts +// packages/cli/src/commands/diff/engine/classify/oas3.ts +import { operationRemoved, pathRemoved } from './rules/operation-rules.js'; +import { + parameterAddedRequired, + parameterBecameRequired, + parameterRemoved, +} from './rules/parameter-rules.js'; +import { mediaTypeRemoved, responseRemoved } from './rules/response-rules.js'; + +import type { DiffRuleRegistry } from '../types.js'; + +export const oas3Rules: DiffRuleRegistry = { + Operation: [operationRemoved], + PathItem: [pathRemoved], + Parameter: [parameterRemoved, parameterAddedRequired, parameterBecameRequired], + Response: [responseRemoved], + MediaType: [mediaTypeRemoved], +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/rules-parameter-response.test.ts packages/cli/src/commands/diff/engine/__tests__/classify.test.ts --coverage.enabled=false` +Expected: PASS (both files — the classify engine tests must still pass). + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/commands/diff/engine/classify packages/cli/src/commands/diff/engine/__tests__/rules-parameter-response.test.ts +git commit -m "feat(cli): add diff parameter and response breaking rules" +``` + +--- + +### Task 9: Schema rules and ref-target rule + +**Files:** + +- Create: `packages/cli/src/commands/diff/engine/classify/rules/schema-rules.ts` +- Create: `packages/cli/src/commands/diff/engine/classify/rules/ref-rules.ts` +- Modify: `packages/cli/src/commands/diff/engine/classify/oas3.ts` +- Test: `packages/cli/src/commands/diff/engine/__tests__/rules-schema.test.ts` + +**Interfaces:** + +- Produces: `schemaTypeChanged`, `enumValuesRemoved`, `enumValuesAdded`, `requiredPropertiesAdded`, `requiredPropertiesRemoved`, `propertyRemovedFromResponse` (in `schema-rules.ts`); `refTargetChanged` (in `ref-rules.ts`) — all `DiffRule`. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/cli/src/commands/diff/engine/__tests__/rules-schema.test.ts +import { refTargetChanged } from '../classify/rules/ref-rules.js'; +import { + enumValuesAdded, + enumValuesRemoved, + propertyRemovedFromResponse, + requiredPropertiesAdded, + requiredPropertiesRemoved, + schemaTypeChanged, +} from '../classify/rules/schema-rules.js'; + +import type { NodeEntry, RawChange, RuleContext } from '../types.js'; + +function ctx( + polarity: RuleContext['polarity'], + maps: { base?: Map; revision?: Map } = {} +): RuleContext { + return { + polarity, + specVersion: 'oas3_1', + base: (p) => maps.base?.get(p), + revision: (p) => maps.revision?.get(p), + }; +} + +function propChange(property: string, before: unknown, after: unknown): RawChange { + return { + pointer: '#/components/schemas/Pet', + property, + kind: 'changed', + typeName: 'Schema', + base: { pointer: `#/components/schemas/Pet/${property}`, value: before }, + revision: { pointer: `#/components/schemas/Pet/${property}`, value: after }, + }; +} + +describe('schema rules', () => { + it('schema-type-changed: narrowing breaks requests, widening breaks responses', () => { + const narrowed = propChange('type', 'number', 'integer'); + expect(schemaTypeChanged.visit(narrowed, ctx('request'))?.compat).toBe('breaking'); + expect(schemaTypeChanged.visit(narrowed, ctx('response'))).toBeUndefined(); + + const widened = propChange('type', 'integer', 'number'); + expect(schemaTypeChanged.visit(widened, ctx('request'))).toBeUndefined(); + expect(schemaTypeChanged.visit(widened, ctx('response'))?.compat).toBe('breaking'); + }); + + it('enum rules are polarity-mirrored', () => { + const shrunk = propChange('enum', ['a', 'b'], ['a']); + expect(enumValuesRemoved.visit(shrunk, ctx('request'))?.compat).toBe('breaking'); + expect(enumValuesRemoved.visit(shrunk, ctx('response'))).toBeUndefined(); + + const grew = propChange('enum', ['a'], ['a', 'b']); + expect(enumValuesAdded.visit(grew, ctx('response'))?.compat).toBe('breaking'); + expect(enumValuesAdded.visit(grew, ctx('request'))).toBeUndefined(); + }); + + it('required rules are polarity-mirrored', () => { + const grew = propChange('required', ['a'], ['a', 'b']); + expect(requiredPropertiesAdded.visit(grew, ctx('request'))?.compat).toBe('breaking'); + expect(requiredPropertiesAdded.visit(grew, ctx('response'))).toBeUndefined(); + + const shrunk = propChange('required', ['a', 'b'], ['a']); + expect(requiredPropertiesRemoved.visit(shrunk, ctx('response'))?.compat).toBe('breaking'); + expect(requiredPropertiesRemoved.visit(shrunk, ctx('request'))).toBeUndefined(); + }); + + it('property-removed-from-response fires only for schema-property nodes in response', () => { + const change: RawChange = { + pointer: '#/components/schemas/Pet/properties/name', + kind: 'removed', + typeName: 'Schema', + base: { pointer: '#/components/schemas/Pet/properties/name', value: { type: 'string' } }, + }; + expect(propertyRemovedFromResponse.visit(change, ctx('response'))?.compat).toBe('breaking'); + expect(propertyRemovedFromResponse.visit(change, ctx('request'))).toBeUndefined(); + + const notAProperty: RawChange = { + pointer: '#/components/schemas/Pet/oneOf/0', + kind: 'removed', + typeName: 'Schema', + base: { pointer: '#/components/schemas/Pet/oneOf/0', value: {} }, + }; + expect(propertyRemovedFromResponse.visit(notAProperty, ctx('response'))).toBeUndefined(); + }); +}); + +describe('ref-target-changed', () => { + it('warns when a ref-valued property is retargeted', () => { + const pointer = '#/paths/~1p/get/responses/200/content/application~1json'; + const base = new Map([ + [ + pointer, + { + pointer, + realPointer: pointer, + parentPointer: null, + typeName: 'MediaType', + scalars: {}, + refs: { schema: '#/components/schemas/Pet' }, + raw: {}, + }, + ], + ]); + const change: RawChange = { + pointer, + property: 'schema', + kind: 'changed', + typeName: 'MediaType', + base: { pointer: `${pointer}/schema`, value: '#/components/schemas/Pet' }, + revision: { pointer: `${pointer}/schema`, value: '#/components/schemas/PetV2' }, + }; + expect(refTargetChanged.visit(change, ctx('response', { base }))?.compat).toBe('warning'); + }); + + it('stays silent for ordinary string property changes', () => { + const change: RawChange = { + pointer: '#/info', + property: 'title', + kind: 'changed', + typeName: 'Info', + base: { pointer: '#/info/title', value: 'a' }, + revision: { pointer: '#/info/title', value: 'b' }, + }; + expect(refTargetChanged.visit(change, ctx('neutral'))).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/rules-schema.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve rule modules. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/cli/src/commands/diff/engine/classify/rules/schema-rules.ts +import { addedItems, isTypeNarrowed, isTypeWidened, missingItems } from '../../predicates.js'; +import { breaking } from '../../types.js'; + +import type { DiffRule } from '../../types.js'; + +export const schemaTypeChanged: DiffRule = { + id: 'schema-type-changed', + description: + 'Narrowing a type restricts what clients may send; widening restricts what they can rely on receiving.', + visit(change, ctx) { + if (change.property !== 'type') return; + const before = change.base?.value; + const after = change.revision?.value; + if (ctx.polarity === 'request' && isTypeNarrowed(before, after)) { + return breaking(`Schema type changed from '${before}' to '${after}'.`); + } + if (ctx.polarity === 'response' && isTypeWidened(before, after)) { + return breaking(`Schema type changed from '${before}' to '${after}'.`); + } + }, +}; + +export const enumValuesRemoved: DiffRule = { + id: 'enum-values-removed', + description: 'Removing enum values restricts what clients may send.', + visit(change, ctx) { + if (change.property !== 'enum' || ctx.polarity !== 'request') return; + const removed = missingItems(change.base?.value, change.revision?.value); + if (removed.length) { + return breaking(`Enum values removed: ${removed.join(', ')}.`); + } + }, +}; + +export const enumValuesAdded: DiffRule = { + id: 'enum-values-added', + description: 'Adding enum values to response data may send clients values they never handled.', + visit(change, ctx) { + if (change.property !== 'enum' || ctx.polarity !== 'response') return; + const added = addedItems(change.base?.value, change.revision?.value); + if (added.length) { + return breaking(`Enum values added: ${added.join(', ')}.`); + } + }, +}; + +export const requiredPropertiesAdded: DiffRule = { + id: 'required-properties-added', + description: 'Requiring new request properties breaks clients that do not send them.', + visit(change, ctx) { + if (change.property !== 'required' || ctx.polarity !== 'request') return; + const added = addedItems(change.base?.value, change.revision?.value); + if (added.length) { + return breaking(`Properties became required: ${added.join(', ')}.`); + } + }, +}; + +export const requiredPropertiesRemoved: DiffRule = { + id: 'required-properties-removed', + description: 'Un-requiring response properties breaks clients that rely on their presence.', + visit(change, ctx) { + if (change.property !== 'required' || ctx.polarity !== 'response') return; + const removed = missingItems(change.base?.value, change.revision?.value); + if (removed.length) { + return breaking(`Properties are no longer required: ${removed.join(', ')}.`); + } + }, +}; + +export const propertyRemovedFromResponse: DiffRule = { + id: 'property-removed-from-response', + description: 'Removing a response property breaks clients that read it.', + visit(change, ctx) { + if (change.kind !== 'removed' || ctx.polarity !== 'response') return; + const segments = change.pointer.split('/'); + if (segments[segments.length - 2] !== 'properties') return; + return breaking('Schema property was removed.'); + }, +}; +``` + +```ts +// packages/cli/src/commands/diff/engine/classify/rules/ref-rules.ts +import { warning } from '../../types.js'; + +import type { DiffRule } from '../../types.js'; + +// Pointer-aligned comparison cannot verify whether two different targets are +// content-equivalent (spec §7.3, §13) — honest verdict is a warning. +export const refTargetChanged: DiffRule = { + id: 'ref-target-changed', + description: + 'A $ref now points to a different target; content equivalence cannot be verified automatically.', + visit(change, ctx) { + if (change.kind !== 'changed' || !change.property) return; + const wasRef = change.property in (ctx.base(change.pointer)?.refs ?? {}); + const isRefNow = change.property in (ctx.revision(change.pointer)?.refs ?? {}); + if (!wasRef && !isRefNow) return; + return warning( + `Reference target changed from '${change.base?.value}' to '${change.revision?.value}' — review manually.` + ); + }, +}; +``` + +Update the registry (`refTargetChanged` is registered for every type that commonly owns refs): + +```ts +// packages/cli/src/commands/diff/engine/classify/oas3.ts +import { operationRemoved, pathRemoved } from './rules/operation-rules.js'; +import { + parameterAddedRequired, + parameterBecameRequired, + parameterRemoved, +} from './rules/parameter-rules.js'; +import { refTargetChanged } from './rules/ref-rules.js'; +import { mediaTypeRemoved, responseRemoved } from './rules/response-rules.js'; +import { + enumValuesAdded, + enumValuesRemoved, + propertyRemovedFromResponse, + requiredPropertiesAdded, + requiredPropertiesRemoved, + schemaTypeChanged, +} from './rules/schema-rules.js'; + +import type { DiffRuleRegistry } from '../types.js'; + +export const oas3Rules: DiffRuleRegistry = { + Operation: [operationRemoved], + PathItem: [pathRemoved, refTargetChanged], + Parameter: [parameterRemoved, parameterAddedRequired, parameterBecameRequired, refTargetChanged], + Response: [responseRemoved, refTargetChanged], + MediaType: [mediaTypeRemoved, refTargetChanged], + RequestBody: [refTargetChanged], + Schema: [ + schemaTypeChanged, + enumValuesRemoved, + enumValuesAdded, + requiredPropertiesAdded, + requiredPropertiesRemoved, + propertyRemovedFromResponse, + refTargetChanged, + ], +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff --coverage.enabled=false` +Expected: PASS — all diff tests, including earlier tasks'. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/commands/diff/engine/classify packages/cli/src/commands/diff/engine/__tests__/rules-schema.test.ts +git commit -m "feat(cli): add diff schema and ref-target breaking rules" +``` + +--- + +### Task 10: Orchestrator `diffDocuments` + output schema + +**Files:** + +- Create: `packages/cli/src/commands/diff/engine/index.ts` +- Create: `packages/cli/src/commands/diff/engine/output-schema.ts` +- Test: `packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts` + +**Interfaces:** + +- Consumes: everything above; `detectSpec`, `getMajorSpecVersion`, `getTypes`, `normalizeTypes` from `@redocly/openapi-core`. The `@redocly/ajv` import in the test resolves via workspace hoisting (it is a dependency of `@redocly/openapi-core`) — test-only, not a new dependency. +- Produces (all exported from the engine, imported by the command via relative paths — nothing is added to `@redocly/openapi-core`): + - `diffDocuments(opts: { base: Document; revision: Document; config: Config }): DiffResult` (synchronous — bundling is the caller's job). + - `class DiffError extends Error` — thrown on major-family mismatch. + - `DIFF_OUTPUT_SCHEMA` — JSON Schema for `DiffResult`. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts +import Ajv from '@redocly/ajv/dist/2020.js'; +import { createConfig, makeDocumentFromString } from '@redocly/openapi-core'; +import { outdent } from 'outdent'; + +import { DiffError, diffDocuments } from '../index.js'; +import { DIFF_OUTPUT_SCHEMA } from '../output-schema.js'; + +const BASE = outdent` + openapi: 3.1.0 + info: { title: Test, version: '1.0' } + paths: + /pets: + get: + parameters: + - name: limit + in: query + schema: { type: integer } + - name: filter + in: query + schema: { type: string } + responses: + '200': { description: OK } +`; + +const REVISION = outdent` + openapi: 3.1.0 + info: { title: Test, version: '1.0' } + paths: + /pets: + get: + parameters: + - name: filter + in: query + schema: { type: string } + - name: limit + in: query + required: true + schema: { type: number } + responses: + '200': { description: List of pets } +`; + +describe('diffDocuments', () => { + it('produces the running example from the design spec', async () => { + const config = await createConfig({}); + const result = diffDocuments({ + base: makeDocumentFromString(BASE, ''), + revision: makeDocumentFromString(REVISION, ''), + config, + }); + + expect(result.version).toBe('1'); + expect(result.specVersions).toEqual({ base: 'oas3_1', revision: 'oas3_1' }); + + // reordering parameters produces NO changes; three real changes remain + const byKey = (c: { pointer: string; property?: string }) => + `${c.pointer}${c.property ? '::' + c.property : ''}`; + const keys = result.changes.map(byKey).sort(); + expect(keys).toEqual([ + '#/paths/~1pets/get/parameters/{query:limit}::required', + '#/paths/~1pets/get/parameters/{query:limit}/schema::type', + '#/paths/~1pets/get/responses/200::description', + ]); + + const becameRequired = result.changes.find((c) => c.property === 'required')!; + expect(becameRequired.compat).toBe('breaking'); + expect(becameRequired.ruleIds).toEqual(['parameter-became-required']); + expect(becameRequired.base?.pointer).toBe('#/paths/~1pets/get/parameters/0/required'); + expect(becameRequired.revision?.pointer).toBe('#/paths/~1pets/get/parameters/1/required'); + + // integer → number in request is a widening — non-breaking + const typeChanged = result.changes.find((c) => c.property === 'type')!; + expect(typeChanged.compat).toBe('non-breaking'); + + const description = result.changes.find((c) => c.property === 'description')!; + expect(description.compat).toBe('non-breaking'); + + expect(result.summary).toEqual({ breaking: 1, warning: 0, nonBreaking: 2 }); + }); + + it('validates against the published output schema', async () => { + const config = await createConfig({}); + const result = diffDocuments({ + base: makeDocumentFromString(BASE, ''), + revision: makeDocumentFromString(REVISION, ''), + config, + }); + + const ajv = new Ajv({ strict: false }); + const validate = ajv.compile(DIFF_OUTPUT_SCHEMA); + expect(validate(result)).toBe(true); + }); + + it('throws DiffError for different spec families', async () => { + const config = await createConfig({}); + const oas2 = makeDocumentFromString( + outdent` + swagger: '2.0' + info: { title: Test, version: '1.0' } + paths: {} + `, + '' + ); + expect(() => + diffDocuments({ base: oas2, revision: makeDocumentFromString(REVISION, ''), config }) + ).toThrow(DiffError); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../index.js` / `../output-schema.js`. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/cli/src/commands/diff/engine/index.ts +import { detectSpec, getMajorSpecVersion, getTypes, normalizeTypes } from '@redocly/openapi-core'; + +import { classifyChanges } from './classify/index.js'; +import { UsageIndex } from './classify/usage.js'; +import { collectDocumentMap } from './collect.js'; +import { compareMaps } from './compare.js'; + +import type { Config, Document, SpecVersion } from '@redocly/openapi-core'; +import type { DiffResult, DiffSummary } from './types.js'; + +export class DiffError extends Error {} + +export function diffDocuments(opts: { + base: Document; + revision: Document; + config: Config; +}): DiffResult { + const { base, revision, config } = opts; + + const baseVersion = detectSpec(base.parsed); + const revisionVersion = detectSpec(revision.parsed); + if (getMajorSpecVersion(baseVersion) !== getMajorSpecVersion(revisionVersion)) { + throw new DiffError( + `Cannot compare different specification families: '${baseVersion}' vs '${revisionVersion}'.` + ); + } + + // Each side is collected with ITS OWN type tree (spec §5.6). + const collect = (document: Document, specVersion: SpecVersion) => + collectDocumentMap({ + document, + types: normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config), + specVersion, + config, + }); + + const baseCollected = collect(base, baseVersion); + const revisionCollected = collect(revision, revisionVersion); + + const rawChanges = compareMaps(baseCollected.entries, revisionCollected.entries); + const usage = new UsageIndex([...baseCollected.usageEdges, ...revisionCollected.usageEdges]); + + const changes = classifyChanges({ + changes: rawChanges, + specVersion: revisionVersion, + base: baseCollected.entries, + revision: revisionCollected.entries, + usage, + }); + + const summary = changes.reduce( + (acc, change) => { + if (change.compat === 'breaking') acc.breaking++; + else if (change.compat === 'warning') acc.warning++; + else acc.nonBreaking++; + return acc; + }, + { breaking: 0, warning: 0, nonBreaking: 0 } + ); + + return { + version: '1', + specVersions: { base: baseVersion, revision: revisionVersion }, + summary, + changes, + }; +} +``` + +```ts +// packages/cli/src/commands/diff/engine/output-schema.ts + +const changeSideSchema = { + type: 'object', + properties: { + pointer: { type: 'string' }, + value: {}, // any JSON value + }, + required: ['pointer'], + additionalProperties: false, +} as const; + +// JSON Schema for the versioned `json` output format (spec §8). +export const DIFF_OUTPUT_SCHEMA = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + version: { const: '1' }, + specVersions: { + type: 'object', + properties: { base: { type: 'string' }, revision: { type: 'string' } }, + required: ['base', 'revision'], + additionalProperties: false, + }, + summary: { + type: 'object', + properties: { + breaking: { type: 'integer', minimum: 0 }, + warning: { type: 'integer', minimum: 0 }, + nonBreaking: { type: 'integer', minimum: 0 }, + }, + required: ['breaking', 'warning', 'nonBreaking'], + additionalProperties: false, + }, + changes: { + type: 'array', + items: { + type: 'object', + properties: { + pointer: { type: 'string' }, + property: { type: 'string' }, + kind: { enum: ['added', 'removed', 'changed'] }, + typeName: { type: 'string' }, + base: changeSideSchema, + revision: changeSideSchema, + compat: { enum: ['breaking', 'warning', 'non-breaking'] }, + ruleIds: { type: 'array', items: { type: 'string' } }, + message: { type: 'string' }, + }, + required: ['pointer', 'kind', 'typeName', 'compat'], + additionalProperties: false, + }, + }, + }, + required: ['version', 'specVersions', 'summary', 'changes'], + additionalProperties: false, +} as const; +``` + +- [ ] **Step 4: Run test to verify it passes, then typecheck** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff --coverage.enabled=false && npm run typecheck` +Expected: all diff tests PASS; no type errors. + +**If the parameter reorder produces phantom changes:** debug `collect.ts` stable pointers first (`entries.keys()`), not `compare.ts` — the comparison is deliberately dumb. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/commands/diff +git commit -m "feat(cli): add diffDocuments orchestrator with versioned output schema" +``` + +--- + +### Task 11: CLI command with stylish and json serializers + +**Files:** + +- Create: `packages/cli/src/commands/diff/index.ts` +- Create: `packages/cli/src/commands/diff/serializers/stylish.ts` +- Create: `packages/cli/src/commands/diff/serializers/json.ts` +- Modify: `packages/cli/src/index.ts` (register the command next to the `stats` registration) +- Test: `packages/cli/src/commands/diff/__tests__/serializers.test.ts` + +**Interfaces:** + +- Consumes: `bundle`, `logger` from `@redocly/openapi-core`; `diffDocuments`, `DiffError` from `./engine/index.js` (Task 10); `DiffResult` type from `./engine/types.js`; `getFallbackApisOrExit` from `../../utils/miscellaneous.js`; `AbortFlowError`, `exitWithError` from `../../utils/error.js`; `CommandArgs` from `../../wrapper.js`; `VerifyConfigOptions` from `../../types.js`. +- Produces: `handleDiff(args: CommandArgs): Promise`, `DiffArgv`; serializers `stylishDiff(result: DiffResult): string`, `jsonDiff(result: DiffResult): string`. + +- [ ] **Step 1: Write the failing serializer test** + +```ts +// packages/cli/src/commands/diff/__tests__/serializers.test.ts +import { jsonDiff } from '../serializers/json.js'; +import { stylishDiff } from '../serializers/stylish.js'; + +import type { DiffResult } from '../engine/types.js'; + +const RESULT: DiffResult = { + version: '1', + specVersions: { base: 'oas3_1', revision: 'oas3_1' }, + summary: { breaking: 1, warning: 1, nonBreaking: 1 }, + changes: [ + { + pointer: '#/paths/~1pets/get/responses/200', + property: 'description', + kind: 'changed', + typeName: 'Response', + base: { pointer: '#/paths/~1pets/get/responses/200/description', value: 'OK' }, + revision: { pointer: '#/paths/~1pets/get/responses/200/description', value: 'Pets' }, + compat: 'non-breaking', + }, + { + pointer: '#/paths/~1pets/get/parameters/{query:limit}', + property: 'required', + kind: 'changed', + typeName: 'Parameter', + base: { pointer: '#/paths/~1pets/get/parameters/0/required', value: undefined }, + revision: { pointer: '#/paths/~1pets/get/parameters/0/required', value: true }, + compat: 'breaking', + ruleIds: ['parameter-became-required'], + message: 'Parameter became required.', + }, + { + pointer: '#/paths/~1pets/get/requestBody/content/application~1json', + property: 'schema', + kind: 'changed', + typeName: 'MediaType', + base: { + pointer: '#/paths/~1pets/get/requestBody/content/application~1json/schema', + value: '#/components/schemas/A', + }, + revision: { + pointer: '#/paths/~1pets/get/requestBody/content/application~1json/schema', + value: '#/components/schemas/B', + }, + compat: 'warning', + ruleIds: ['ref-target-changed'], + message: 'Reference target changed.', + }, + ], +}; + +describe('stylishDiff', () => { + it('orders by severity and renders a summary', () => { + const output = stylishDiff(RESULT); + const breakingIndex = output.indexOf('parameter-became-required'); + const warningIndex = output.indexOf('ref-target-changed'); + const nonBreakingIndex = output.indexOf('description'); + expect(breakingIndex).toBeGreaterThan(-1); + expect(breakingIndex).toBeLessThan(warningIndex); + expect(warningIndex).toBeLessThan(nonBreakingIndex); + expect(output).toContain('1 breaking'); + expect(output).toContain('1 warning'); + expect(output).toContain('1 non-breaking'); + }); +}); + +describe('jsonDiff', () => { + it('round-trips the DiffResult', () => { + expect(JSON.parse(jsonDiff(RESULT))).toEqual(JSON.parse(JSON.stringify(RESULT))); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/__tests__/serializers.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve serializer modules. + +- [ ] **Step 3: Write the serializers** + +```ts +// packages/cli/src/commands/diff/serializers/json.ts +import type { DiffResult } from '../engine/types.js'; + +export function jsonDiff(result: DiffResult): string { + return JSON.stringify(result, null, 2); +} +``` + +```ts +// packages/cli/src/commands/diff/serializers/stylish.ts +import { bold, gray, green, red, yellow } from 'colorette'; + +import type { Change, Compat, DiffResult } from '../engine/types.js'; + +const SEVERITY_ORDER: Compat[] = ['breaking', 'warning', 'non-breaking']; + +const ICONS: Record = { + breaking: red('✖ breaking '), + warning: yellow('⚠ warning '), + 'non-breaking': green('✔ non-breaking'), +}; + +function label(change: Change): string { + return change.property ? `${change.pointer} · ${change.property}` : change.pointer; +} + +export function stylishDiff(result: DiffResult): string { + const lines: string[] = []; + const sorted = [...result.changes].sort( + (a, b) => + SEVERITY_ORDER.indexOf(a.compat) - SEVERITY_ORDER.indexOf(b.compat) || + a.pointer.localeCompare(b.pointer) + ); + + for (const change of sorted) { + const rule = change.ruleIds?.length ? gray(` (${change.ruleIds.join(', ')})`) : ''; + const message = change.message ? gray(` — ${change.message}`) : ''; + lines.push(`${ICONS[change.compat]} ${bold(change.kind)} ${label(change)}${message}${rule}`); + } + + const { breaking, warning, nonBreaking } = result.summary; + lines.push( + '', + `${red(`${breaking} breaking`)}, ${yellow(`${warning} warning`)}, ${green( + `${nonBreaking} non-breaking` + )}.` + ); + return lines.join('\n'); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff/__tests__/serializers.test.ts --coverage.enabled=false` +Expected: PASS (2 tests). + +- [ ] **Step 5: Write the command handler** + +```ts +// packages/cli/src/commands/diff/index.ts +import { writeFileSync } from 'node:fs'; + +import { bundle, logger } from '@redocly/openapi-core'; + +import { AbortFlowError, exitWithError } from '../../utils/error.js'; +import { getFallbackApisOrExit } from '../../utils/miscellaneous.js'; +import { DiffError, diffDocuments } from './engine/index.js'; +import { jsonDiff } from './serializers/json.js'; +import { stylishDiff } from './serializers/stylish.js'; + +import type { VerifyConfigOptions } from '../../types.js'; +import type { CommandArgs } from '../../wrapper.js'; +import type { DiffResult } from './engine/types.js'; + +export type DiffOutputFormat = 'stylish' | 'json' | 'markdown' | 'html'; +export type DiffFailOn = 'breaking' | 'warning' | 'none'; + +export type DiffArgv = { + base: string; + revision: string; + format: DiffOutputFormat; + output?: string; + 'fail-on': DiffFailOn; +} & VerifyConfigOptions; + +const SERIALIZERS: Record string> = { + stylish: stylishDiff, + json: jsonDiff, + // markdown and html are added in the next task: + markdown: jsonDiff, + html: jsonDiff, +}; + +export async function handleDiff({ argv, config, collectSpecData }: CommandArgs) { + const [{ path: basePath }] = await getFallbackApisOrExit([argv.base], config); + const [{ path: revisionPath }] = await getFallbackApisOrExit([argv.revision], config); + + const { bundle: baseDocument } = await bundle({ config, ref: basePath }); + const { bundle: revisionDocument } = await bundle({ config, ref: revisionPath }); + collectSpecData?.(revisionDocument.parsed); + + let result: DiffResult; + try { + result = diffDocuments({ base: baseDocument, revision: revisionDocument, config }); + } catch (error) { + if (error instanceof DiffError) { + return exitWithError(error.message); + } + throw error; + } + + const output = SERIALIZERS[argv.format](result); + if (argv.output) { + writeFileSync(argv.output, output); + } else { + logger.output(output + '\n'); + } + + const failOn = argv['fail-on']; + const failed = + failOn === 'breaking' + ? result.summary.breaking > 0 + : failOn === 'warning' + ? result.summary.breaking + result.summary.warning > 0 + : false; + if (failed) { + throw new AbortFlowError( + `Diff failed: ${result.summary.breaking} breaking, ${result.summary.warning} warning change(s) found.` + ); + } +} +``` + +**Note:** `logger` is exported from `@redocly/openapi-core` (`packages/core/src/index.ts:129`) and `logger.output(...)` is the stdout channel the `bundle` command uses to print bundled documents (`packages/cli/src/commands/bundle.ts:105`) — verified. + +- [ ] **Step 6: Register the command** + +In `packages/cli/src/index.ts`, add next to the other imports: + +```ts +import { handleDiff, type DiffArgv } from './commands/diff/index.js'; +``` + +Add this `.command(...)` block adjacent to the `stats` registration (same level of the yargs chain): + +```ts + .command( + 'diff ', + 'Compare two API descriptions and detect breaking changes [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'] as ReadonlyArray< + 'stylish' | 'json' | 'markdown' | 'html' + >, + 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 level are found.', + choices: ['breaking', 'warning', 'none'] as ReadonlyArray< + 'breaking' | 'warning' | 'none' + >, + default: 'breaking' as const, + }, + }), + (argv) => { + commandWrapper(handleDiff)(argv); + } + ) +``` + +This mirrors exactly how the `stats` registration invokes `commandWrapper(handleStats)(argv)`. If TypeScript complains about the argv type, compare with the `stats` block in the same file and align the option typings (`as ReadonlyArray<...>` / `as const` casts) the same way. + +- [ ] **Step 7: Typecheck and run CLI tests** + +Run: `npm run typecheck && VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff --coverage.enabled=false` +Expected: no type errors; serializer tests PASS. + +- [ ] **Step 8: Smoke-run the command manually** + +```bash +cat > /tmp/diff-base.yaml <<'EOF' +openapi: 3.1.0 +info: { title: T, version: '1.0' } +paths: + /pets: + get: + responses: + '200': { description: OK } +EOF +cat > /tmp/diff-rev.yaml <<'EOF' +openapi: 3.1.0 +info: { title: T, version: '1.0' } +paths: + /pets: + get: + responses: + '200': { description: Pets } +EOF +npm run cli -- diff /tmp/diff-base.yaml /tmp/diff-rev.yaml; echo "exit=$?" +npm run cli -- diff /tmp/diff-base.yaml /tmp/diff-base.yaml --format json; echo "exit=$?" +``` + +Expected: first run prints one non-breaking change + summary, `exit=0`; second prints `"changes": []` JSON, `exit=0`. + +- [ ] **Step 9: Commit** + +```bash +git add packages/cli/src/commands/diff packages/cli/src/index.ts +git commit -m "feat(cli): add diff command with stylish and json formats" +``` + +--- + +### Task 12: Markdown and HTML serializers + +**Files:** + +- Create: `packages/cli/src/commands/diff/serializers/markdown.ts` +- Create: `packages/cli/src/commands/diff/serializers/html.ts` +- Modify: `packages/cli/src/commands/diff/index.ts` (wire real serializers into `SERIALIZERS`) +- Test: `packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts` + +**Interfaces:** + +- Produces: `markdownDiff(result: DiffResult): string`, `htmlDiff(result: DiffResult): string`. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts +import { htmlDiff } from '../serializers/html.js'; +import { markdownDiff } from '../serializers/markdown.js'; + +import type { DiffResult } from '../engine/types.js'; + +const RESULT: DiffResult = { + version: '1', + specVersions: { base: 'oas3_1', revision: 'oas3_1' }, + summary: { breaking: 1, warning: 0, nonBreaking: 0 }, + changes: [ + { + pointer: '#/paths/~1pets/get', + kind: 'removed', + typeName: 'Operation', + base: { pointer: '#/paths/~1pets/get', value: { summary: '' } }, + compat: 'breaking', + ruleIds: ['operation-removed'], + message: 'Operation was removed.', + }, + ], +}; + +describe('markdownDiff', () => { + it('renders a summary and a table row per change', () => { + const output = markdownDiff(RESULT); + expect(output).toContain('| Impact | Change | Location | Details |'); + expect(output).toContain('operation-removed'); + expect(output).toContain('`#/paths/~1pets/get`'); + expect(output).toContain('**1** breaking'); + }); +}); + +describe('htmlDiff', () => { + it('renders a self-contained page with escaped values', () => { + const output = htmlDiff(RESULT); + expect(output).toContain(' + + +

API diff

+

+ ${breaking} breaking + ${warning} warning + ${nonBreaking} non-breaking + ${escapeHtml(result.specVersions.base)} → ${escapeHtml( + result.specVersions.revision + )} +

+${result.changes.map(renderChange).join('\n')} + +`; +} +``` + +Wire them in `packages/cli/src/commands/diff/index.ts` — replace the `SERIALIZERS` constant and add imports: + +```ts +import { htmlDiff } from './serializers/html.js'; +import { markdownDiff } from './serializers/markdown.js'; + +const SERIALIZERS: Record string> = { + stylish: stylishDiff, + json: jsonDiff, + markdown: markdownDiff, + html: htmlDiff, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff --coverage.enabled=false && npm run typecheck` +Expected: all serializer tests PASS; no type errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/commands/diff +git commit -m "feat(cli): add markdown and html diff formats" +``` + +--- + +### Task 13: E2E snapshot tests, docs page, changeset + +**Files:** + +- Create: `tests/e2e/diff/breaking-changes/base.yaml` +- Create: `tests/e2e/diff/breaking-changes/revision.yaml` +- Create: `tests/e2e/diff/diff.test.ts` +- Create: `docs/@v2/commands/diff.md` +- Create: `.changeset/diff-command.md` + +- [ ] **Step 1: Create the e2e fixtures** + +```yaml +# tests/e2e/diff/breaking-changes/base.yaml +openapi: 3.1.0 +info: + title: Diff E2E + version: '1.0' +paths: + /pets: + get: + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + delete: + responses: + '204': + description: Deleted +components: + schemas: + Pet: + type: object + required: [name] + properties: + name: + type: string + tag: + type: string +``` + +```yaml +# tests/e2e/diff/breaking-changes/revision.yaml +openapi: 3.1.0 +info: + title: Diff E2E + version: '2.0' +paths: + /pets: + get: + parameters: + - name: limit + in: query + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + type: object + required: [name] + properties: + name: + type: string +``` + +- [ ] **Step 2: Write the e2e test** + +```ts +// tests/e2e/diff/diff.test.ts +import { spawnSync } from 'node:child_process'; +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', () => { + const testPath = join(__dirname, 'breaking-changes'); + + test('stylish output', async () => { + const args = getParams(indexEntryPoint, ['diff', 'base.yaml', 'revision.yaml']); + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'stylish-snapshot.txt')); + }); + + test('json output', async () => { + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + '--format=json', + ]); + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'json-snapshot.txt')); + }); + + test('exits 1 on breaking changes with default fail-on', () => { + const result = spawnSync('node', [indexEntryPoint, 'diff', 'base.yaml', 'revision.yaml'], { + encoding: 'utf-8', + cwd: testPath, + env: { ...process.env, NO_COLOR: 'TRUE' }, + }); + expect(result.status).toBe(1); + }); + + test('exits 0 with --fail-on=none', () => { + const result = spawnSync( + 'node', + [indexEntryPoint, 'diff', 'base.yaml', 'revision.yaml', '--fail-on=none'], + { encoding: 'utf-8', cwd: testPath, env: { ...process.env, NO_COLOR: 'TRUE' } } + ); + expect(result.status).toBe(0); + }); + + test('exits 0 when comparing a file to itself', () => { + const result = spawnSync('node', [indexEntryPoint, 'diff', 'base.yaml', 'base.yaml'], { + encoding: 'utf-8', + cwd: testPath, + env: { ...process.env, NO_COLOR: 'TRUE' }, + }); + expect(result.status).toBe(0); + }); +}); +``` + +- [ ] **Step 3: Compile the CLI and run the e2e tests (snapshots are created on first run)** + +Run: `npm run compile && VITEST_SUITE=e2e npx vitest run tests/e2e/diff` +Expected: 5 tests PASS; `stylish-snapshot.txt` and `json-snapshot.txt` created in `tests/e2e/diff/breaking-changes/`. + +**Review the created snapshots before committing.** The stylish snapshot must show (a) the removed `delete` operation as breaking, (b) `limit` became required as breaking, (c) removal of the `tag` property of the response-only `Pet` schema as breaking (`property-removed-from-response` — this exercises the usage index), (d) the `info.version` change as non-breaking. If any expectation is off, debug the corresponding engine layer first (collect → compare → classify), not the snapshot. + +- [ ] **Step 4: Write the docs page** + +Open `docs/@v2/commands/stats.md` first and mirror its front-matter/heading conventions exactly (including how admonitions/notes are written in this docs set). Content for `docs/@v2/commands/diff.md`: + +````markdown +# diff + +The `diff` command is **experimental**: its output formats and rule ids may change in future releases. + +Compares two API descriptions and reports what was added, removed, and changed. For OpenAPI 3.x, changes are classified as breaking, warning, or non-breaking. + +## Usage + +```bash +redocly diff +redocly diff v1/openapi.yaml v2/openapi.yaml +redocly diff https://example.com/openapi.yaml openapi.yaml --format=json +redocly diff main@v1 main@v2 --fail-on=warning +``` +```` + +## Options + +| Option | Type | Description | +| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| base | string | **REQUIRED.** Path, URL, or config alias of the base (older) API description. | +| revision | string | **REQUIRED.** Path, URL, or config alias of the revision (newer) API description. | +| --config | string | Specify path to the [configuration file](../configuration/index.md). | +| --fail-on | string | Exit with code `1` when changes at this level are found. Possible values: `breaking`, `warning`, `none`. Default value is `breaking`. | +| --format | string | Format for the output. Possible values: `stylish`, `json`, `markdown`, `html`. Default value is `stylish`. | +| --help | boolean | Show help. | +| --lint-config | string | Severity level for config file linting. Possible values: `warn`, `error`, `off`. Default value is `warn`. | +| --output, -o | string | Write the report to a file instead of stdout. | +| --version | boolean | Show version number. | + +## How it works + +- Both descriptions are bundled, so external `$ref`s are resolved before comparison. +- List items with a natural identity (for example, parameters keyed by `in` + `name`) are matched by identity, so reordering them is not reported as a change. +- Changes to shared components are reported once, at the component location; whether a component change is breaking is derived from where the component is used (requests, responses, or both). +- Changes the tool detects but cannot judge automatically (for example, a `$ref` that now points to a different target) are reported as `warning`. +- Structural comparison works for all supported specification types; breaking-change classification applies to OpenAPI 3.x. + +The `diff` command detects common breaking changes; it is not an exhaustive detector. Comparing documents of different specification families (for example, OpenAPI 2.0 vs OpenAPI 3.1) is not supported. + +## Breaking change rules + +| Rule id | Description | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `operation-removed` | Removing an operation breaks all of its consumers. | +| `path-removed` | Removing a path breaks all consumers of its operations. | +| `parameter-removed` | Removing a request parameter breaks clients that send it. | +| `parameter-added-required` | Adding a new required parameter breaks clients that do not send it. | +| `parameter-became-required` | Marking an existing request parameter as required breaks clients that omit it. | +| `schema-type-changed` | Narrowing a type restricts what clients may send; widening restricts what they can rely on receiving. | +| `enum-values-removed` | Removing enum values restricts what clients may send. | +| `enum-values-added` | Adding enum values to response data may send clients values they never handled. | +| `required-properties-added` | Requiring new request properties breaks clients that do not send them. | +| `required-properties-removed` | Un-requiring response properties breaks clients that rely on their presence. | +| `property-removed-from-response` | Removing a response property breaks clients that read it. | +| `response-removed` | Removing a response breaks clients that handle it. | +| `media-type-removed` | Removing a media type breaks clients that produce or consume it. | +| `ref-target-changed` | A `$ref` now points to a different target; content equivalence cannot be verified automatically (reported as `warning`). | + +## Examples + +### Fail a CI pipeline on breaking changes + +```bash +redocly diff main-openapi.yaml pr-openapi.yaml +# exit code 1 when breaking changes are found +``` + +### Generate an HTML report + +```bash +redocly diff v1.yaml v2.yaml --format=html -o diff-report.html +``` + +```` + +- [ ] **Step 5: Add the docs page to the sidebar if commands are listed there** + +Run: `grep -n "stats" docs/sidebars.yaml` +If command pages are listed in `docs/sidebars.yaml`, add a `diff` entry next to the `stats` entry, mirroring its format exactly. If `stats` is not listed there, skip this step. + +- [ ] **Step 6: Create the changeset** + +Only `@redocly/cli` is bumped — `packages/core` is untouched. Content of `.changeset/diff-command.md` (the file starts directly with the `---` front matter): + +```markdown +--- +'@redocly/cli': minor +--- + +Added the experimental `diff` command that compares two API descriptions and reports added, removed, and changed parts, with breaking-change classification for OpenAPI 3.x. Supports `stylish`, `json`, `markdown`, and `html` output formats and a `--fail-on` CI gate. +```` + +- [ ] **Step 7: Run the full verification** + +Run: `npm run typecheck && VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff --coverage.enabled=false && VITEST_SUITE=e2e npx vitest run tests/e2e/diff` +Expected: everything PASSES. + +- [ ] **Step 8: Commit** + +```bash +git add tests/e2e/diff docs/@v2/commands/diff.md .changeset/diff-command.md +git commit -m "test(cli): add diff e2e snapshots, docs page, and changeset" +``` + +--- + +## Final verification (after all tasks) + +1. `npm run typecheck` — clean. +2. `VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff --coverage.enabled=false` — all green. +3. `npm run compile && VITEST_SUITE=e2e npx vitest run tests/e2e/diff` — all green. +4. Manual sanity: `npm run cli -- diff tests/e2e/diff/breaking-changes/base.yaml tests/e2e/diff/breaking-changes/revision.yaml --format html -o /tmp/report.html` and open `/tmp/report.html`. +5. Confirm the spec's §12 limitations are documented in `docs/@v2/commands/diff.md` (rename blindness, coverage positioning, experimental status). +6. **Isolation check:** `git diff main --name-only | grep '^packages/core'` must print NOTHING — `packages/core` is untouched. diff --git a/docs/superpowers/specs/2026-07-07-diff-command-design.md b/docs/superpowers/specs/2026-07-07-diff-command-design.md new file mode 100644 index 0000000000..613c06512f --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-diff-command-design.md @@ -0,0 +1,306 @@ +# `redocly diff` — Design + +- **Date:** 2026-07-07 +- **Status:** Approved for implementation planning +- **Scope:** New **experimental** CLI command that compares two API descriptions and reports structural changes, with breaking-change classification for OpenAPI 3.x. The entire engine lives inside the CLI package; nothing is added to `@redocly/openapi-core`. + +## 1. Goals + +1. Compare two versions of an API description and report what was added, removed, and changed. +2. Structural diff works for **every** spec the CLI supports (OpenAPI 2/3.0/3.1/3.2, AsyncAPI 2/3, Arazzo, Overlay, OpenRPC) by reusing the existing type trees. +3. Classify changes as `breaking` / `warning` / `non-breaking` for OpenAPI 3.x. +4. Output formats: `stylish` (terminal, default), `json` (stable, versioned), `markdown` (PR comments), `html` (self-contained page). +5. CI gate: non-zero exit code via `--fail-on`. +6. Fit the repo's architecture: reuse `bundle`, `detectSpec`, type trees, `walkDocument`; rules follow the lint-rule idiom; no second rule framework. +7. **Isolation:** the whole diff engine ships inside `packages/cli` and consumes ONLY the existing public `@redocly/openapi-core` API — no new core exports, no core changes. +8. **Experimental status:** the command is marked `[experimental]` (same convention as `join`); output formats and rule ids may change while it stabilizes. + +### Non-goals (v1) + +- Git revisions as inputs (`HEAD~1:openapi.yaml`) — users run `git show` themselves. +- Rename/move detection (component renamed with identical content reads as removed+added). +- Breaking rules for non-OpenAPI specs (structural diff still works; everything is `non-breaking`). +- Rule severity configuration via `redocly.yaml` (rule ids are stable so this can be added later on the existing rules/severity model). +- ajv witness escalation (validating base examples against revision schemas to upgrade `warning` verdicts with evidence). +- Semantic normalization across minor versions (`nullable: true` vs `type: [..., 'null']`). + +## 2. CLI + +``` +redocly diff [experimental] + --format stylish | json | markdown | html (default: stylish) + --output -o + --fail-on breaking | warning | none (default: breaking) + --config +``` + +- Each side is a `ref` string resolved by the existing infrastructure (`getFallbackApisOrExit`, `BaseResolver`): local file path, `http(s)://` URL, or an alias from the `apis:` block of `redocly.yaml`. Sides resolve independently (file vs URL is fine). +- **Version guard:** different major spec families (e.g. `oas2` vs `oas3`, `oas3` vs `async2`) → clear error. Same family, different minor (3.0 vs 3.1) → allowed, with a warning about known syntactic noise. +- Exit code: `--fail-on breaking` → 1 when `summary.breaking > 0`; `--fail-on warning` → 1 when `breaking + warning > 0`; `none` → always 0 (unless the command itself fails). + +## 3. Pipeline + +``` + [1] input ×2 BaseResolver: file / URL / alias + [2] bundle ×2 existing bundle() + detectSpec(); EACH side uses ITS OWN type tree + [3] collect ×2 walkDocument → Map + usage edges + [4] compare two passes over the union of keys → Change[] + [5] classify polarity engine + per-type rule registry → compat + [6] report 4 serializers from one DiffResult; exit code +``` + +The whole engine (stages 3–5) lives in `packages/cli/src/commands/diff/engine/`; the command and serializers live in `packages/cli/src/commands/diff/`. `packages/core` is not modified — the engine consumes only the existing public `@redocly/openapi-core` API (`walkDocument`, `normalizeVisitors`, `normalizeTypes`, `detectSpec`, `getMajorSpecVersion`, `getTypes`, `isRef`, `isPlainObject`, `bundle`, `logger`, and their types). + +## 4. Data contracts + +```ts +interface NodeEntry { + pointer: string; // stable matching key: …/parameters/{query:limit} + realPointer: string; // actual JSON Pointer in THIS document: …/parameters/1 + parentPointer: string | null; // derived from the pointer STRING (not the walk stack) + typeName: string; // from this side's type tree + scalars: Record; // shallow primitives (+ scalar arrays: enum, required) + refs: Record; // $ref-valued properties, recorded as attributes + raw: unknown; // the raw node value — subtree payload for added/removed changes +} + +type Compat = 'breaking' | 'warning' | 'non-breaking'; +// warning = "potentially breaking; cannot be verified automatically" + +interface ChangeSide { + pointer: string; // real JSON Pointer in this document + value?: unknown; // value / subtree on this side +} + +interface Change { + pointer: string; // ONE stable node pointer — the change's identity + property?: string; // set for property-level changes + kind: 'added' | 'removed' | 'changed'; + typeName: string; + base?: ChangeSide; // absent for added + revision?: ChangeSide; // absent for removed + compat: Compat; // filled by the classifier + ruleIds?: string[]; // all rules that produced a verdict (worst wins) + message?: string; // message of the most severe verdict +} + +interface DiffResult { + version: '1'; // output schema version; stability is promised once the command leaves experimental + specVersions: { base: string; revision: string }; + summary: { breaking: number; warning: number; nonBreaking: number }; + changes: Change[]; +} +``` + +## 5. Collection (stage 3) + +One generic visitor (`any.enter`) on the existing `walkDocument`, run once per side. All the "intelligence" of the system lives here: + +1. **Stable pointers.** Array indexes are replaced by keys from a small **identity registry**: `Parameter → in+name`, `Server → url`, `Tag → name`, `SecurityRequirement → scheme names`. Key collision → deterministic `#2` suffix. +2. **Positional fallback.** Combinator lists (`allOf`/`oneOf`/`anyOf`) and unknown lists match by index. Rationale: an edit to a subschema (common) yields a clean nested diff; a reorder (rare) yields noise. No content-hash strategy in v1 — predictability over cleverness. The matching strategy is a per-list-type property of the registry, so a two-phase strategy can be added later without touching `compare`. +3. **`$ref` is a scalar.** External refs are inlined by `bundle`; internal refs are recorded in `refs` as node attributes and are **not** followed. Component content is diffed once, at its canonical `#/components/...` path. This also sidesteps `walkDocument`'s per-type deduplication (`seenNodesPerType`). +4. **Usage index.** While collecting, record edges "ref site → target" from both sides (union). Used by the classifier to derive polarity for components (transitively, cycle-safe). +5. **Dual pointers.** `realPointer` (this side's actual JSON Pointer) is stored next to the stable `pointer`. +6. **Own type tree per side.** A 3.0 document is collected with the 3.0 tree. Type names align across 3.x trees, so matching works; where trees genuinely diverge, removed+added is the honest answer. +7. `parentPointer` is derived by trimming the last segment of the stable pointer string. (The walk stack is wrong for the first visit of a component reached via a ref site.) + +## 6. Compare (stage 4) + +Dumb and mechanical — two passes over the union of keys, O(N+M): + +``` +Pass 1 (boundaries): find removed-roots, added-roots, + and replaced nodes (present in both, typeName differs) +Pass 2 (emission): + • removed/added root (parent present in BOTH maps) → one Change carrying the + whole subtree as payload; descendants stay silent + • any key with a boundary ancestor (walk up parentPointer, O(depth)) → silent + • replaced → a removed+added pair at the same pointer; descendants suppressed + • matched node → shallow diff of scalars ∪ refs → property-level 'changed' +``` + +No node statuses, no tree structure, no `modified` propagation: unchanged nodes emit nothing; serializers group by sorting on `pointer`. + +For property-level changes (`property` set, kind `'changed'`) both `base` and `revision` sides are present — the node exists on both sides; `value` is `undefined` on the side where the property is absent (e.g. a property that first appears in the revision). + +## 7. Classification (stage 5) + +### 7.1 Polarity — computed once by the engine + +The axis every compatibility judgment depends on: is the change on the **request** side (client → server) or **response** side (server → client)? Rules are mirrored (contravariance/covariance): + +| Schema change | in request | in response | +| ------------------------ | ---------- | ----------- | +| property became required | breaking | safe | +| property removed | safe | breaking | +| type narrowed | breaking | safe | +| enum shrunk | breaking | safe | + +``` +segments contain 'responses' → response +segments contain 'parameters'|'requestBody' → request +under callbacks / webhooks → neutral (direction is inverted there; + v1 honestly does not judge; the future fix + is polarity inversion in this same function) +path under components → derived from the usage index (transitively): + request | response | both | neutral (unused) +everything else (info, tags, servers…) → neutral +``` + +`both` polarity: the change is judged under each polarity; the **worst verdict wins**. + +### 7.2 Rules — lint-visitor idiom (Decision: variant B, see §13) + +```ts +interface DiffRule { + id: string; // stable — future config severity, docs catalog + description: string; + visit(change: Change, ctx: RuleContext): Verdict | undefined; +} + +interface RuleContext { + polarity: Polarity; + specVersion: SpecVersion; + base(pointer: string): NodeEntry | undefined; // look up neighboring nodes + revision(pointer: string): NodeEntry | undefined; +} + +// registry keyed by typeName — same mental model as lint visitors +export const oas3Rules: Record = { + Operation: [operationRemoved], + Parameter: [parameterRemoved, parameterAddedRequired, parameterBecameRequired], + Schema: [schemaTypeChanged, enumChanged, requiredChanged, propertyRemoved], + Response: [responseRemoved], + MediaType: [mediaTypeRemoved], +}; +``` + +Example rule — guards are 1–2 lines because the registry already filtered by type: + +```ts +export const parameterBecameRequired: DiffRule = { + id: 'parameter-became-required', + description: 'Marking an existing request parameter as required breaks clients that omit it', + visit(change, ctx) { + if (change.property !== 'required' || ctx.polarity !== 'request') return; + if (change.revision?.value === true) return breaking('Parameter became required'); + }, +}; +``` + +**Verdict policy: no first-match-wins.** The engine evaluates **all** rules registered for the change's type, collects every verdict, and keeps the most severe (`breaking > warning > non-breaking`). All firing `ruleIds` are attached. Registration order carries no semantics. + +Anything no rule judges → `non-breaking` (safe default). No registry for a spec (AsyncAPI, Arazzo, …) → structural diff only. + +Registries are per spec version; `oas3_1Rules` extends `oas3Rules` and overrides pointwise — same pattern as the type trees. + +Shared **predicate helpers** (`narrowed`, `missingItems`, `becameTrue`, …) live in `predicates.ts` as pure, unit-tested functions reused by rules. + +**Model scope:** rules judge **one change at a time** (with document context via `ctx.base()/revision()`). Correlation judgments across changes (rename detection, "removed here + added there") do not fit this model and are deliberately deferred to a future post-pass hook. + +### 7.3 Starter rule set (initial, not exhaustive) + +`operation-removed`, `path-removed`, `parameter-removed`, `parameter-added-required`, `parameter-became-required`, `schema-type-changed` (narrowed in request / widened in response), `enum-values-removed` (request), `enum-values-added` (response), `required-properties-added` (request), `required-properties-removed` (response), `property-removed` (response), `response-removed`, `media-type-removed`, `ref-target-changed` (→ `warning`: content equivalence cannot be verified by pointer-aligned comparison). + +Note: there is deliberately no `parameter-in-changed` rule — the identity key is `in+name`, so a changed `in` surfaces as a removed+added pair, already judged breaking by `parameter-removed`. + +The rule catalog (id + description) is generated into the docs — same honesty format as coverage tables. + +## 8. Reporting (stage 6) + +All four serializers consume one `DiffResult`: + +- **stylish** (default): groups by shared pointer prefix, `colorette` colors, ordered breaking → warning → non-breaking, summary line. +- **json**: `DiffResult` as-is; the schema is versioned and validated in tests with the repo's existing ajv. +- **markdown**: a table suitable for PR comments. +- **html**: self-contained page (inline CSS/JS, no external requests), collapsible groups, filter by compat. + +Large `before/after` payloads (e.g. `example` objects, whole subtrees) are truncated in human-oriented formats; `json` carries them in full. + +## 9. Errors + +- Different major spec families → immediate, explicit error. +- Invalid/missing inputs → existing `getFallbackApisOrExit` behavior. +- Unresolvable external refs → existing bundle/resolver errors, reported per side. + +## 10. File layout + +``` +packages/cli/src/commands/diff/ + index.ts # handleDiff: resolve sides, call the engine, serialize, exit code + engine/ # self-contained; imports ONLY public @redocly/openapi-core API + index.ts # diffDocuments() orchestrator, DiffError + types.ts # NodeEntry, Change, DiffResult, Compat, DiffRule + output-schema.ts # versioned JSON Schema for the json format + predicates.ts # narrowed, missingItems, becameTrue, … + node-identity.ts # identity registry + positional fallback + collect.ts # generic visitor → Map + usage edges + compare.ts # two-pass comparison + classify/ + index.ts # engine: polarity + registry dispatch + worst-wins + polarity.ts + usage.ts # usage index, transitive polarity for components + oas3.ts # rule registry + oas3_1.ts # extends oas3 + rules/ # rule modules (lint-rule idiom) + __tests__/ + serializers/ + stylish.ts json.ts markdown.ts html.ts + __tests__/ + +docs/@v2/commands/diff.md +``` + +`packages/core` is not touched. Promotion of the engine into `@redocly/openapi-core` is a future step, taken only once the command leaves experimental status — the module is self-contained over core's public API, so the move is mechanical. + +## 11. Implementation order (each step is a testable layer) + +1. `types.ts` + `predicates.ts` + `node-identity.ts` — pure units. +2. `collect.ts` — fixtures: identity keys, collisions, refs-as-scalars, dual pointers, usage edges. +3. `compare.ts` — reorders, subtree collapse, **replaced fixture with a polymorphic node**. +4. `classify/` — polarity (incl. components via usage, `both`, callbacks→neutral), engine policy, starter rules. +5. CLI command + `stylish` + `json` → first end-to-end snapshot test. +6. `markdown` + `html` + `--fail-on` + docs page + changeset. + +Testing: unit tests per layer and per rule (a rule test feeds a hand-built `Change`); e2e snapshot tests on fixture pairs for every format (repo's vitest snapshot pattern); ajv validation of `json` output against the published schema. + +## 12. Known limitations (v1) + +1. **Positioning:** "detects common breaking changes" with a documented rule catalog — not an exhaustive detector (oasdiff has hundreds of checks refined over years). +2. **Move blindness:** renaming a component = removed + added + `warning` on ref changes. +3. Reordering combinator subschemas produces noise (positional matching). +4. `readOnly`/`writeOnly` do not refine polarity; `both` is coarse (worst-of-both). +5. Reorder of identity-keyed lists is invisible; `servers` order is semantic → backlog: `orderSensitive` flag in the registry. +6. Cross-minor comparisons carry syntactic noise (`nullable` vs `type: [null]`). +7. Callbacks/webhooks are `neutral`: structural diff only, no polarity judgments. + +## 13. Alternatives considered (decision log) + +### Rule form (the classification layer) + +- **(1) Flat matcher array** — functions parsing paths themselves (`segments.includes('parameters')`). Rejected: context logic duplicated and drifting across every matcher. +- **(2) Declarative matrix / DSL** — nested data table (`props.enum.shrunk.request`) + generic interpreter. Rejected: ~30% of real rules need escape-hatch functions anyway (leaky DSL), debugging goes through a meta-level, deep literals type poorly. Its valuable halves survive: predicates as a helper library; declarative _user-facing_ configuration returns later as YAML over stable rule ids (same pattern as lint's configurable rules/assertions). +- **(3) Selector + verdict** — `on: {type, kind, property, polarity}` + judgment function; engine pre-filters. Viable, best at 100+ rules; rejected _for v1_: introduces matching semantics new to this repo (conflict policy, selector freeze discipline) — overhead paid before the scale exists. **Migration from B is mechanical** (guards → selector); the trigger is recorded: "rules > ~50 or guard boilerplate hurts". +- **(4) B: lint-visitor idiom — CHOSEN.** `{id, description, visit}` objects in `Record`. Matches how every lint rule in this repo is already written; structural ids/descriptions; per-rule unit tests; worst-wins policy removes ordering semantics. +- **(5) One classifier module per spec (switch)** — simplest possible; rejected: ids become scattered string literals, docs catalog must be maintained by hand, single file becomes a merge-conflict magnet as rules grow. +- **(6) Set-theoretic schema comparison** (Atlassian `json-schema-diff` / `openapi-diff`) — theoretically ideal (schemas as sets of accepted documents; breaking = removed set in requests / added set in responses — which independently validates our polarity model). Rejected with evidence: keyword coverage collapses under the set algebra (their own tables: no `enum`, no `pattern`, no `format`; `servers`/`security`/`callbacks` not compared at all) and both projects are dormant. Their three-way classification and two-sided entity details (source/destination location+value) independently validate our `Compat` and `ChangeSide` designs. +- **(7) Variance annotations inside the type trees** — would couple diff semantics into the shared core that lint/bundle/docs depend on. Rejected. + +### Comparison engine + +- **Diff tree with node statuses** (added/removed/modified/replaced/unchanged + bottom-up propagation) — rejected as over-engineering: `unchanged` exists only to be pruned, `modified` propagation is recomputable by sorting pointers, `replaced` reduces to a removed+added pair. Flat maps + two-pass iteration deliver the same output with one data structure. +- **"Collect only breaking-relevant data, mismatch = error"** — rejected: breaking-ness is directional (an added endpoint is a mismatch but safe), the full diff report is a product requirement, and the "what is breaking-relevant" filter is the same domain knowledge relocated into a worse place. +- **Generic diff libraries** (`fast-json-patch`, `deep-diff`, `microdiff`) — rejected: positional on arrays (kills identity matching), no `typeName` attribution (kills rule dispatch), no `$ref` policy. They would replace the easy 50 lines and none of the hard parts. +- **walkDocument reuse vs custom lockstep traversal** — walkDocument CHOSEN: it already handles ref resolution, `ResolveTypeFn` polymorphism, `directResolveAs`, extensions; a custom traversal would duplicate and drift. + +### Other decisions + +- **Bundle then compare effective contract** (vs keeping files separate): chosen for reliability and infra reuse; internal refs are still compared component-wise via refs-as-scalars. +- **`warning` as a third compat level**: honest bucket for "cannot verify automatically" (ref retargets). Mirrors industry practice (oasdiff WARN, Atlassian `unclassified`). +- **ajv**: not usable for the core compare (validates instances against schemas, not schemas against schemas). Used in v1 only to validate our own JSON output schema in tests. Witness escalation (validating base examples against revision schemas to upgrade warnings with evidence) → future work. +- **Git refs input** → deferred; **rule severity via redocly.yaml** → deferred (ids are stable, lands on the existing rules/severity model — one rule system in the product). +- **Engine location: CLI package vs openapi-core — CLI CHOSEN.** The command is experimental; keeping the engine inside `packages/cli` avoids expanding core's public API surface before the model stabilizes. The engine consumes only core's public API, so promoting it into core later is a mechanical move, not a rewrite. + +## 14. Future work + +Deprecation/sunset semantics (removal of a deprecated-past-sunset operation is not breaking) · ignore/approval mechanism for legalizing known changes · endpoint-attribution view derived from the usage index ("affected operations") · extensible-enum semantics for response enums · rename detection via content matching · recursive subtree comparison for ref retargets (re-key both prefixes, reuse `compare()`) · ajv witness escalation with counterexample messages · polarity inversion for callbacks/webhooks · `orderSensitive` lists · cross-minor semantic normalization · git revision inputs · selector-form rules refactor at scale · YAML-configurable severities over stable rule ids · promotion of the engine into `@redocly/openapi-core` once the command leaves experimental status. diff --git a/packages/cli/src/commands/diff/__tests__/__snapshots__/html-report.html b/packages/cli/src/commands/diff/__tests__/__snapshots__/html-report.html new file mode 100644 index 0000000000..6c0226eab3 --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/__snapshots__/html-report.html @@ -0,0 +1,136 @@ + + + + +API diff + + + +

API diff

+

+ 3 breaking + 2 non-breaking + oas3_1 → oas3_1 +

+ +
+ + breaking + removed + #/paths/~1pets/delete + Operation was removed. operation-removed + +
{
+  "base": {
+    "pointer": "#/paths/~1pets/delete",
+    "file": "base.yaml",
+    "line": 30,
+    "col": 3,
+    "value": {
+      "summary": "<script>alert(1)</script>"
+    }
+  }
+}
+
+ +
+ + breaking + changed + #/paths/~1pets/get/parameters/{query:limit} · required + Parameter became required. parameter-became-required + +
{
+  "base": {
+    "pointer": "#/paths/~1pets/get/parameters/0/required",
+    "file": "base.yaml",
+    "line": 9,
+    "col": 21
+  },
+  "revision": {
+    "pointer": "#/paths/~1pets/get/parameters/1/required",
+    "file": "revision.yaml",
+    "line": 11,
+    "col": 21,
+    "value": true
+  }
+}
+
+ +
+ + breaking + changed + #/paths/~1pets/post/requestBody/content/application~1json/schema · pattern + `pattern` changed from 'a' to 'a|b'. string-length-changed + +
{
+  "revision": {
+    "pointer": "#/paths/~1pets/post/requestBody/content/application~1json/schema/pattern",
+    "file": "revision.yaml",
+    "line": 18,
+    "col": 22,
+    "value": "a|b"
+  }
+}
+
+ +
+ + non-breaking + added + #/components/schemas/Pet + + +
{
+  "revision": {
+    "pointer": "#/components/schemas/Pet",
+    "file": "revision.yaml",
+    "line": 20,
+    "col": 5,
+    "value": {
+      "type": "object"
+    }
+  }
+}
+
+ +
+ + non-breaking + changed + #/paths/~1pet~1{id} · path + + +
{
+  "base": {
+    "pointer": "#/paths/~1pet~1{id}",
+    "file": "base.yaml",
+    "line": 4,
+    "col": 3,
+    "value": "/pet/{id}"
+  },
+  "revision": {
+    "pointer": "#/paths/~1pet~1{petId}",
+    "file": "revision.yaml",
+    "line": 4,
+    "col": 3,
+    "value": "/pet/{petId}"
+  }
+}
+
+ + \ No newline at end of file diff --git a/packages/cli/src/commands/diff/__tests__/classify.test.ts b/packages/cli/src/commands/diff/__tests__/classify.test.ts new file mode 100644 index 0000000000..db561dd0af --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/classify.test.ts @@ -0,0 +1,113 @@ +import { classifyChanges } from '../engine/classify/index.js'; +import { UsageIndex } from '../engine/classify/usage.js'; +import type { NodeEntry, RawChange } from '../engine/types.js'; +import { treeOf } from './tree.js'; + +const emptyMaps = { + base: new Map(), + revision: new Map(), + usage: new UsageIndex([], () => undefined), +}; + +// What every rule verdict passes through: which rules run, how many verdicts survive, +// and which one decides the change. The rules themselves are covered by tests/e2e/diff. +describe('classifyChanges', () => { + it('defaults to non-breaking when no rule judges the change', () => { + const changes: RawChange[] = [ + { + pointer: '#/info', + property: 'title', + kind: 'changed', + typeName: 'Info', + base: { pointer: '#/info/title', value: 'a' }, + revision: { pointer: '#/info/title', value: 'b' }, + }, + ]; + const [change] = classifyChanges({ changes, specVersion: 'oas3_1', ...emptyMaps }); + expect(change.compat).toBe('non-breaking'); + expect(change.verdicts).toBeUndefined(); + }); + + it('returns structural-only (non-breaking) for specs without a registry', () => { + const changes: RawChange[] = [ + { + pointer: '#/x', + kind: 'removed', + typeName: 'Operation', + base: { pointer: '#/x', value: {} }, + }, + ]; + const [change] = classifyChanges({ changes, specVersion: 'async2', ...emptyMaps }); + expect(change.compat).toBe('non-breaking'); + }); + + it('keeps every verdict when multiple rules fire, worst-first', () => { + // The component is referenced from a request and from a response, so it is + // judged under both polarities; that needs real node types on the way down. + const entries = treeOf(` + #/ Root + #/paths PathsMap + #/paths/~1x PathItem + #/paths/~1x/get Operation + #/paths/~1x/get/parameters ParameterList + #/paths/~1x/get/parameters/{query:q} Parameter + #/paths/~1x/get/parameters/{query:q}/schema Schema + #/paths/~1x/get/responses Responses + #/paths/~1x/get/responses/200 Response + #/paths/~1x/get/responses/200/content MediaTypesMap + #/paths/~1x/get/responses/200/content/application~1json MediaType + #/paths/~1x/get/responses/200/content/application~1json/schema Schema + #/components Components + #/components/schemas NamedSchemas + #/components/schemas/S Schema + `); + const tree = (pointer: string) => entries.get(pointer); + const usage = new UsageIndex( + [ + { + site: '#/paths/~1x/get/parameters/{query:q}/schema', + target: '#/components/schemas/S', + }, + { + site: '#/paths/~1x/get/responses/200/content/application~1json/schema', + target: '#/components/schemas/S', + }, + ], + tree + ); + const changes: RawChange[] = [ + { + pointer: '#/components/schemas/S', + property: 'enum', + kind: 'changed', + typeName: 'Schema', + base: { pointer: '#/components/schemas/S/enum', value: ['a', 'b'] }, + revision: { pointer: '#/components/schemas/S/enum', value: ['a', 'c'] }, + }, + ]; + const [change] = classifyChanges({ + changes, + specVersion: 'oas3_1', + base: entries, + revision: entries, + usage, + }); + // Swapping one enum value both removes an accepted request value and returns a + // response value no client handled, so both verdicts are kept. + expect(change.compat).toBe('breaking'); + expect(change.verdicts).toMatchInlineSnapshot(` + [ + { + "compat": "breaking", + "message": "Enum values added: c.", + "ruleId": "enum-values-added", + }, + { + "compat": "breaking", + "message": "Enum values removed: b.", + "ruleId": "enum-values-removed", + }, + ] + `); + }); +}); diff --git a/packages/cli/src/commands/diff/__tests__/collect.test.ts b/packages/cli/src/commands/diff/__tests__/collect.test.ts new file mode 100644 index 0000000000..505e52e272 --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/collect.test.ts @@ -0,0 +1,173 @@ +import { + createConfig, + detectSpec, + getTypes, + makeDocumentFromString, + normalizeTypes, +} from '@redocly/openapi-core'; +import { outdent } from 'outdent'; + +import { collectDocumentMap } from '../engine/collect.js'; +import type { NodeEntry } from '../engine/types.js'; + +async function collect(yaml: string) { + const document = makeDocumentFromString(yaml, ''); + const config = await createConfig({}); + const specVersion = detectSpec(document.parsed); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + return collectDocumentMap({ document, types, specVersion, config }); +} + +/** `stable pointer (real pointer) TypeName` per node — the map the comparison runs on. */ +function tree(entries: Map): string { + return [...entries.values()] + .map((entry) => `${entry.pointer} (${entry.realPointer}) ${entry.typeName}`) + .join('\n'); +} + +describe('collectDocumentMap', () => { + it('keys every node by a stable pointer while remembering the real one', async () => { + const { entries } = await collect(outdent` + openapi: 3.1.0 + info: { title: Test, version: '1.0' } + paths: + /pets: + get: + parameters: + - name: filter + in: query + schema: { type: string } + - name: limit + in: query + required: true + schema: { type: integer } + responses: + '200': { description: OK } + `); + + // The parameters are keyed by `in` + `name`, so reordering them cannot read as a + // change, while the real pointer still points at the index they sit at today. + expect(tree(entries)).toMatchInlineSnapshot(` + "#/ (#/) Root + #/info (#/info) Info + #/paths (#/paths) Paths + #/paths/~1pets (#/paths/~1pets) PathItem + #/paths/~1pets/get (#/paths/~1pets/get) Operation + #/paths/~1pets/get/parameters (#/paths/~1pets/get/parameters) ParameterList + #/paths/~1pets/get/parameters/{query:filter} (#/paths/~1pets/get/parameters/0) Parameter + #/paths/~1pets/get/parameters/{query:filter}/schema (#/paths/~1pets/get/parameters/0/schema) Schema + #/paths/~1pets/get/parameters/{query:limit} (#/paths/~1pets/get/parameters/1) Parameter + #/paths/~1pets/get/parameters/{query:limit}/schema (#/paths/~1pets/get/parameters/1/schema) Schema + #/paths/~1pets/get/responses (#/paths/~1pets/get/responses) Responses + #/paths/~1pets/get/responses/200 (#/paths/~1pets/get/responses/200) Response" + `); + expect(entries.get('#/paths/~1pets/get/parameters/{query:limit}')!.scalars).toMatchObject({ + name: 'limit', + in: 'query', + required: true, + }); + }); + + it('records $ref values as attributes and does not follow them', async () => { + const { entries, usageEdges } = await collect(outdent` + openapi: 3.1.0 + info: { title: Test, version: '1.0' } + paths: + /pets: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + components: + schemas: + Pet: + type: object + properties: + name: { type: string } + `); + + const mediaType = entries.get('#/paths/~1pets/get/responses/200/content/application~1json'); + expect(mediaType).toBeDefined(); + expect(mediaType!.refs).toEqual({ schema: '#/components/schemas/Pet' }); + + // the component is collected once, at its canonical path + const pet = entries.get('#/components/schemas/Pet'); + expect(pet).toBeDefined(); + expect(pet!.typeName).toBe('Schema'); + expect(entries.get('#/components/schemas/Pet/properties/name')).toBeDefined(); + + // usage edge recorded + // The site is the media type node that holds the `$ref`, since the reference + // itself is not a node and could not be looked up later. + expect(usageEdges).toContainEqual({ + site: '#/paths/~1pets/get/responses/200/content/application~1json', + target: '#/components/schemas/Pet', + }); + }); + + it('snapshots scalar arrays like enum and required', async () => { + const { entries } = await collect(outdent` + openapi: 3.1.0 + info: { title: Test, version: '1.0' } + paths: {} + components: + schemas: + Size: + type: string + enum: [s, m, l] + Pet: + type: object + required: [name] + properties: + name: { type: string } + `); + + expect(entries.get('#/components/schemas/Size')!.scalars.enum).toEqual(['s', 'm', 'l']); + expect(entries.get('#/components/schemas/Pet')!.scalars.required).toEqual(['name']); + }); + + it('keys list items by their identity, with pointer escaping inside the key', async () => { + const { entries } = await collect(outdent` + openapi: 3.1.0 + info: { title: Test, version: '1.0' } + servers: + - url: https://api.example.com/v1 + - url: https://staging.example.com/v1 + tags: + - name: pets + paths: {} + `); + + // Reordering these must not read as a change, so the key is the url, not the index. + // The slashes in it are escaped, or they would split the pointer into more segments. + expect(entries.has('#/servers/{https:~1~1api.example.com~1v1}')).toBe(true); + expect(entries.get('#/servers/{https:~1~1api.example.com~1v1}')!.realPointer).toBe( + '#/servers/0' + ); + expect(entries.has('#/tags/{pets}')).toBe(true); + }); + + it('suffixes colliding identity keys deterministically', async () => { + const { entries } = await collect(outdent` + openapi: 3.1.0 + info: { title: Test, version: '1.0' } + paths: + /pets: + get: + parameters: + - name: dup + in: query + - name: dup + in: query + responses: + '200': { description: OK } + `); + + expect(entries.has('#/paths/~1pets/get/parameters/{query:dup}')).toBe(true); + expect(entries.has('#/paths/~1pets/get/parameters/{query:dup}#2')).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/diff/__tests__/compare.test.ts b/packages/cli/src/commands/diff/__tests__/compare.test.ts new file mode 100644 index 0000000000..cebe63f611 --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/compare.test.ts @@ -0,0 +1,163 @@ +import { compareMaps } from '../engine/compare.js'; +import type { NodeEntry } from '../engine/types.js'; + +function entry(partial: Partial & { pointer: string }): NodeEntry { + return { + realPointer: partial.pointer, + parentPointer: null, + keyInParent: '', + typeName: 'Schema', + scalars: {}, + refs: {}, + raw: {}, + ...partial, + }; +} + +function toMap(entries: NodeEntry[]): Map { + return new Map(entries.map((entry) => [entry.pointer, entry])); +} + +describe('compareMaps', () => { + it('emits one change per differing property, in pointer order', () => { + const base = toMap([entry({ pointer: '#/a', scalars: { type: 'integer', description: 'x' } })]); + const revision = toMap([ + entry({ pointer: '#/a', scalars: { type: 'number', description: 'x', format: 'float' } }), + ]); + + expect(compareMaps(base, revision)).toMatchInlineSnapshot(` + [ + { + "base": { + "pointer": "#/a/format", + "value": undefined, + }, + "kind": "changed", + "pointer": "#/a", + "property": "format", + "revision": { + "pointer": "#/a/format", + "value": "float", + }, + "typeName": "Schema", + }, + { + "base": { + "pointer": "#/a/type", + "value": "integer", + }, + "kind": "changed", + "pointer": "#/a", + "property": "type", + "revision": { + "pointer": "#/a/type", + "value": "number", + }, + "typeName": "Schema", + }, + ] + `); + }); + + it('collapses a removed subtree into one change at its root', () => { + const shared = entry({ pointer: '#/paths', typeName: 'PathsMap' }); + const base = toMap([ + shared, + entry({ + pointer: '#/paths/~1pets', + parentPointer: '#/paths', + typeName: 'PathItem', + raw: { get: {} }, + }), + entry({ + pointer: '#/paths/~1pets/get', + parentPointer: '#/paths/~1pets', + typeName: 'Operation', + }), + ]); + + expect(compareMaps(base, toMap([shared]))).toMatchInlineSnapshot(` + [ + { + "base": { + "pointer": "#/paths/~1pets", + "value": { + "get": {}, + }, + }, + "kind": "removed", + "pointer": "#/paths/~1pets", + "typeName": "PathItem", + }, + ] + `); + }); + + it('treats a node whose type changed as a removed+added pair and suppresses its subtree', () => { + const base = toMap([ + entry({ pointer: '#/x', typeName: 'Schema', raw: { type: 'object' } }), + entry({ pointer: '#/x/properties/a', parentPointer: '#/x', scalars: { type: 'string' } }), + ]); + const revision = toMap([ + entry({ pointer: '#/x', typeName: 'Example', raw: { value: 1 } }), + entry({ pointer: '#/x/properties/a', parentPointer: '#/x', scalars: { type: 'number' } }), + ]); + + expect(compareMaps(base, revision)).toMatchInlineSnapshot(` + [ + { + "base": { + "pointer": "#/x", + "value": { + "type": "object", + }, + }, + "kind": "removed", + "pointer": "#/x", + "typeName": "Schema", + }, + { + "kind": "added", + "pointer": "#/x", + "revision": { + "pointer": "#/x", + "value": { + "value": 1, + }, + }, + "typeName": "Example", + }, + ] + `); + }); + + it('compares a $ref the way it compares a scalar', () => { + const base = toMap([entry({ pointer: '#/m', refs: { schema: '#/components/schemas/A' } })]); + const revision = toMap([entry({ pointer: '#/m', refs: { schema: '#/components/schemas/B' } })]); + + expect(compareMaps(base, revision)).toMatchInlineSnapshot(` + [ + { + "base": { + "pointer": "#/m/schema", + "value": "#/components/schemas/A", + }, + "kind": "changed", + "pointer": "#/m", + "property": "schema", + "revision": { + "pointer": "#/m/schema", + "value": "#/components/schemas/B", + }, + "typeName": "Schema", + }, + ] + `); + }); + + it('emits nothing when the two maps are identical', () => { + const entries = [entry({ pointer: '#/a', scalars: { type: 'string' } })]; + + expect(compareMaps(toMap(entries), toMap(entries))).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/diff/__tests__/diff-documents.test.ts b/packages/cli/src/commands/diff/__tests__/diff-documents.test.ts new file mode 100644 index 0000000000..f268759e52 --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/diff-documents.test.ts @@ -0,0 +1,178 @@ +import { createConfig, makeDocumentFromString } from '@redocly/openapi-core'; +import { outdent } from 'outdent'; + +import { DiffError, diffDocuments } from '../engine/index.js'; +import type { DiffResult } from '../engine/types.js'; + +const BASE = outdent` + openapi: 3.1.0 + info: { title: Test, version: '1.0' } + paths: + /pets: + get: + parameters: + - name: limit + in: query + schema: { type: integer } + - name: filter + in: query + schema: { type: string } + responses: + '200': { description: OK } +`; + +const REVISION = outdent` + openapi: 3.1.0 + info: { title: Test, version: '1.0' } + paths: + /pets: + get: + parameters: + - name: filter + in: query + schema: { type: string } + - name: limit + in: query + required: true + schema: { type: number } + responses: + '200': { description: List of pets } +`; + +/** One line per change: verdict, what moved, and where it sits on each side. */ +function report(result: DiffResult): string { + return result.changes + .map((change) => { + const at = [change.base, change.revision] + .filter(Boolean) + .map((side) => `${side!.file}:${side!.line} ${side!.pointer}`) + .join(' → '); + const name = `${change.pointer}${change.property ? ` · ${change.property}` : ''}`; + return [ + `${change.compat} ${change.kind} ${name}`, + ...(change.verdicts ?? []).map((verdict) => ` ${verdict.ruleId}: ${verdict.message}`), + ` at ${at}`, + ].join('\n'); + }) + .join('\n'); +} + +describe('diffDocuments', () => { + it('matches reordered parameters by identity and judges what actually changed', async () => { + const config = await createConfig({}); + const result = diffDocuments({ + base: makeDocumentFromString(BASE, 'base.yaml'), + revision: makeDocumentFromString(REVISION, 'rev.yaml'), + config, + }); + + // The two parameters swapped places, which is not a change. What remains: the + // parameter became required (breaking), its type widened from integer to number + // (accepts more, so a request tolerates it), and a description was reworded. + // The real pointers differ per side, which is how the swap stays visible. + expect(report(result)).toMatchInlineSnapshot(` + "breaking changed #/paths/~1pets/get/parameters/{query:limit} · required + parameter-became-required: Parameter became required. + at base.yaml:7 #/paths/~1pets/get/parameters/0/required → rev.yaml:12 #/paths/~1pets/get/parameters/1/required + non-breaking changed #/paths/~1pets/get/parameters/{query:limit}/schema · type + at base.yaml:9 #/paths/~1pets/get/parameters/0/schema/type → rev.yaml:13 #/paths/~1pets/get/parameters/1/schema/type + non-breaking changed #/paths/~1pets/get/responses/200 · description + at base.yaml:14 #/paths/~1pets/get/responses/200/description → rev.yaml:15 #/paths/~1pets/get/responses/200/description" + `); + expect(result.summary).toEqual({ breaking: 1, nonBreaking: 2 }); + }); + + it('throws DiffError for different spec families', async () => { + const config = await createConfig({}); + const oas2 = makeDocumentFromString( + outdent` + swagger: '2.0' + info: { title: Test, version: '1.0' } + paths: {} + `, + '' + ); + expect(() => + diffDocuments({ base: oas2, revision: makeDocumentFromString(REVISION, ''), config }) + ).toThrow(DiffError); + }); + + it('matches renamed path parameters instead of remove+add', async () => { + const config = await createConfig({}); + const makeSpec = (param: string) => outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + paths: + /pet/{${param}}: + get: + parameters: + - name: ${param} + in: path + required: true + schema: { type: string } + responses: + '200': { description: OK } + `; + const result = diffDocuments({ + base: makeDocumentFromString(makeSpec('id'), 'base.yaml'), + revision: makeDocumentFromString(makeSpec('petId'), 'rev.yaml'), + config, + }); + + // The endpoint is the same one under a new parameter name, so nothing is removed: + // the path template and the parameter name are reported as changes of their own, + // both keyed on the base pointer. + expect(report(result)).toMatchInlineSnapshot(` + "non-breaking changed #/paths/~1pet~1{id} · path + at base.yaml:5 #/paths/~1pet~1{id} → rev.yaml:5 #/paths/~1pet~1{petId} + non-breaking changed #/paths/~1pet~1{id}/get/parameters/{path:id} · name + at base.yaml:7 #/paths/~1pet~1{id}/get/parameters/0/name → rev.yaml:7 #/paths/~1pet~1{petId}/get/parameters/0/name" + `); + expect(result.summary.breaking).toBe(0); + }); + + it('reports ambiguous path renames as remove+add', async () => { + const config = await createConfig({}); + const base = makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + paths: + /a/{x}/b: + get: + responses: + '200': { description: OK } + `, + 'base.yaml' + ); + const revision = makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + paths: + /a/{y}/b: + get: + responses: + '200': { description: OK } + /a/{z}/b: + get: + responses: + '200': { description: OK } + `, + 'rev.yaml' + ); + const result = diffDocuments({ base, revision, config }); + + // Two candidates differ from `/a/{x}/b` only in the parameter name, so there is no + // way to tell which one it became. The paths are compared by their literal keys. + expect(report(result)).toMatchInlineSnapshot(` + "breaking removed #/paths/~1a~1{x}~1b + path-removed: Path was removed. + at base.yaml:5 #/paths/~1a~1{x}~1b + non-breaking added #/paths/~1a~1{y}~1b + at rev.yaml:5 #/paths/~1a~1{y}~1b + non-breaking added #/paths/~1a~1{z}~1b + at rev.yaml:9 #/paths/~1a~1{z}~1b" + `); + }); +}); diff --git a/packages/cli/src/commands/diff/__tests__/documented-rules.test.ts b/packages/cli/src/commands/diff/__tests__/documented-rules.test.ts new file mode 100644 index 0000000000..fdfb8b6b9a --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/documented-rules.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { async3Rules } from '../engine/classify/async3.js'; +import { oas3Rules } from '../engine/classify/oas3.js'; +import type { DiffRule } from '../engine/types.js'; + +const docs = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '../../../../../../docs/@v2/commands/diff.md'), + 'utf8' +); + +// A rule reaches a registry under every node type it applies to, and a rule shared by +// two specifications appears in both, so the same rule is listed more than once. +const rulesById = new Map(); +for (const rule of [...Object.values(oas3Rules), ...Object.values(async3Rules)].flat()) { + rulesById.set(rule.id, rule); +} + +/** A row of the rule table: `| \`rule-id\` | description |`, however the formatter pads it. */ +const RULE_TABLE_ROW = /^\| `([a-z0-9-]+)`\s*\|\s*(.+?)\s*\|$/gm; + +const documented = new Map( + [...docs.matchAll(RULE_TABLE_ROW)].map(([, ruleId, description]) => [ruleId, description]) +); + +// The catalog is what users decide to trust the command on, so a rule that ships +// without a row — or a row left behind by a renamed rule — is a documentation bug. +describe('the documented rule catalog', () => { + it('lists every rule the command runs', () => { + const undocumented = [...rulesById.keys()].filter((ruleId) => !documented.has(ruleId)); + + expect(undocumented.sort()).toEqual([]); + }); + + it('lists no rule the command no longer has', () => { + const stale = [...documented.keys()].filter((ruleId) => !rulesById.has(ruleId)); + + expect(stale.sort()).toEqual([]); + }); + + it.each([...rulesById.values()].map((rule) => [rule.id, rule.description]))( + 'describes `%s` with the description it carries in code', + (ruleId, description) => { + expect(documented.get(ruleId)).toBe(description); + } + ); +}); diff --git a/packages/cli/src/commands/diff/__tests__/polarity.test.ts b/packages/cli/src/commands/diff/__tests__/polarity.test.ts new file mode 100644 index 0000000000..901dd5a807 --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/polarity.test.ts @@ -0,0 +1,244 @@ +import type { NodeLookup } from '../engine/classify/chain.js'; +import { getAsync3Polarity, getOas3Polarity } from '../engine/classify/polarity.js'; +import { getComponentRoot, mergePolarity, UsageIndex } from '../engine/classify/usage.js'; +import { treeOf } from './tree.js'; + +// One document covering every direction-bearing shape at once. +const entries = treeOf(` + #/ Root + #/info Info + #/info/title scalar + #/tags TagList + #/tags/{pets} Tag + #/paths PathsMap + #/paths/~1p PathItem + #/paths/~1p/get Operation + #/paths/~1p/get/parameters ParameterList + #/paths/~1p/get/parameters/{query:limit} Parameter + #/paths/~1p/get/parameters/{query:limit}/schema Schema + #/paths/~1p/get/responses Responses + #/paths/~1p/get/responses/200 Response + #/paths/~1p/get/responses/200/content MediaTypesMap + #/paths/~1p/get/responses/200/content/application~1json MediaType + #/paths/~1p/get/responses/200/content/application~1json/schema Schema + #/paths/~1p/get/responses/200/content/application~1json/schema/properties SchemaProperties + #/paths/~1p/get/responses/200/content/application~1json/schema/properties/callbacks Schema + #/paths/~1p/post Operation + #/paths/~1p/post/requestBody RequestBody + #/paths/~1p/post/requestBody/content MediaTypesMap + #/paths/~1p/post/requestBody/content/application~1json MediaType + #/paths/~1p/post/requestBody/content/application~1json/schema Schema + #/paths/~1p/post/requestBody/content/application~1json/schema/properties SchemaProperties + #/paths/~1p/post/requestBody/content/application~1json/schema/properties/responses Schema + #/paths/~1p/post/callbacks CallbacksMap + #/paths/~1p/post/callbacks/onEvent Callback + #/paths/~1p/post/callbacks/onEvent/~1cb PathItem + #/paths/~1p/post/callbacks/onEvent/~1cb/post Operation + #/paths/~1p/post/callbacks/onEvent/~1cb/post/requestBody RequestBody + #/paths/~1p/post/callbacks/onEvent/~1cb/post/responses Responses + #/webhooks WebhooksMap + #/webhooks/newPet PathItem + #/webhooks/newPet/post Operation + #/webhooks/newPet/post/requestBody RequestBody + #/webhooks/newPet/post/responses Responses + #/components Components + #/components/schemas NamedSchemas + #/components/schemas/Pet Schema + #/components/schemas/Pet/properties SchemaProperties + #/components/schemas/Pet/properties/name Schema + #/components/schemas/Address NamedSchemas + #/components/schemas/Orphan Schema +`); +const tree: NodeLookup = (pointer) => entries.get(pointer); + +const emptyUsage = new UsageIndex([], tree); + +describe('getComponentRoot', () => { + it('finds the component a node belongs to', () => { + expect(getComponentRoot('#/components/schemas/Pet/properties/name', tree)).toBe( + '#/components/schemas/Pet' + ); + expect(getComponentRoot('#/components/schemas/Pet', tree)).toBe('#/components/schemas/Pet'); + }); + + it('returns undefined outside components', () => { + expect(getComponentRoot('#/paths/~1p/get', tree)).toBeUndefined(); + }); +}); + +describe('mergePolarity', () => { + it('merges polarities', () => { + expect(mergePolarity('neutral', 'request')).toBe('request'); + expect(mergePolarity('request', 'request')).toBe('request'); + expect(mergePolarity('request', 'response')).toBe('both'); + expect(mergePolarity('both', 'response')).toBe('both'); + }); +}); + +describe('getOas3Polarity', () => { + it('reads the direction off the node types on the way down', () => { + expect(getOas3Polarity('#/paths/~1p/get/responses/200', emptyUsage, tree)).toBe('response'); + expect( + getOas3Polarity('#/paths/~1p/get/parameters/{query:limit}/schema', emptyUsage, tree) + ).toBe('request'); + expect( + getOas3Polarity('#/paths/~1p/post/requestBody/content/application~1json', emptyUsage, tree) + ).toBe('request'); + expect(getOas3Polarity('#/info/title', emptyUsage, tree)).toBe('neutral'); + expect(getOas3Polarity('#/tags/{pets}', emptyUsage, tree)).toBe('neutral'); + }); + + it('flips the direction under callbacks and webhooks', () => { + // The API sends these, so their request body reaches the consumer like a response. + expect( + getOas3Polarity('#/paths/~1p/post/callbacks/onEvent/~1cb/post/requestBody', emptyUsage, tree) + ).toBe('response'); + expect(getOas3Polarity('#/webhooks/newPet/post/requestBody', emptyUsage, tree)).toBe( + 'response' + ); + // ...and what the consumer answers with is a request. + expect(getOas3Polarity('#/webhooks/newPet/post/responses', emptyUsage, tree)).toBe('request'); + }); + + it('is not fooled by properties named after a direction-bearing node', () => { + // Both of these are `Schema` nodes; only their key looks like a context. + expect( + getOas3Polarity( + '#/paths/~1p/post/requestBody/content/application~1json/schema/properties/responses', + emptyUsage, + tree + ) + ).toBe('request'); + expect( + getOas3Polarity( + '#/paths/~1p/get/responses/200/content/application~1json/schema/properties/callbacks', + emptyUsage, + tree + ) + ).toBe('response'); + }); + + it('derives component polarity from usage sites', () => { + const usage = new UsageIndex( + [ + { + site: '#/paths/~1p/get/responses/200/content/application~1json/schema', + target: '#/components/schemas/Pet', + }, + ], + tree + ); + expect(getOas3Polarity('#/components/schemas/Pet/properties/name', usage, tree)).toBe( + 'response' + ); + }); + + it('derives both when a component is used on both sides', () => { + const usage = new UsageIndex( + [ + { + site: '#/paths/~1p/get/responses/200/content/application~1json/schema', + target: '#/components/schemas/Pet', + }, + { + site: '#/paths/~1p/post/requestBody/content/application~1json/schema', + target: '#/components/schemas/Pet', + }, + ], + tree + ); + expect(getOas3Polarity('#/components/schemas/Pet', usage, tree)).toBe('both'); + }); + + it('resolves transitive usage through other components, cycle-safe', () => { + const usage = new UsageIndex( + [ + { + site: '#/paths/~1p/get/responses/200/content/application~1json/schema', + target: '#/components/schemas/Pet', + }, + { + site: '#/components/schemas/Pet/properties/name', + target: '#/components/schemas/Address', + }, + // cycle back + { site: '#/components/schemas/Address', target: '#/components/schemas/Pet' }, + ], + tree + ); + expect(getOas3Polarity('#/components/schemas/Address', usage, tree)).toBe('response'); + }); + + it('returns neutral for unused components', () => { + expect(getOas3Polarity('#/components/schemas/Orphan', emptyUsage, tree)).toBe('neutral'); + }); +}); + +// AsyncAPI states the direction on the operation, and channels live outside the +// operations, so a payload is reached through the channel that holds it. +const asyncEntries = treeOf(` + #/ Root + #/channels NamedChannels + #/channels/signups Channel + #/channels/signups/messages NamedMessages + #/channels/signups/messages/signup Message + #/channels/signups/messages/signup/payload Schema + #/channels/receipts Channel + #/channels/receipts/messages NamedMessages + #/channels/receipts/messages/receipt Message + #/channels/orders Channel + #/operations NamedOperations + #/operations/onSignup Operation action=receive + #/operations/sendReceipt Operation action=send + #/operations/onOrder Operation action=receive + #/operations/onOrder/reply OperationReply +`); +const asyncTree: NodeLookup = (pointer) => asyncEntries.get(pointer); + +describe('getAsync3Polarity', () => { + const usage = new UsageIndex( + [ + { site: '#/operations/onSignup', target: '#/channels/signups' }, + { site: '#/operations/sendReceipt', target: '#/channels/receipts' }, + { site: '#/operations/onOrder/reply', target: '#/channels/orders' }, + ], + asyncTree + ); + + it('judges a received payload as a request and a sent one as a response', () => { + // Another application produces what this one receives, so its payload is input. + expect(getAsync3Polarity('#/channels/signups/messages/signup/payload', usage, asyncTree)).toBe( + 'request' + ); + expect(getAsync3Polarity('#/channels/receipts/messages/receipt', usage, asyncTree)).toBe( + 'response' + ); + }); + + it('flips the direction for a reply channel', () => { + expect(getAsync3Polarity('#/channels/orders', usage, asyncTree)).toBe('response'); + }); + + it('reads the direction off the operation the change sits in', () => { + expect(getAsync3Polarity('#/operations/sendReceipt', usage, asyncTree)).toBe('response'); + }); + + it('returns neutral for a channel no operation references', () => { + expect(getAsync3Polarity('#/channels/signups', new UsageIndex([], asyncTree), asyncTree)).toBe( + 'neutral' + ); + }); + + it('does not hang on a payload that refers back into its own channel', () => { + const recursive = new UsageIndex( + [ + { site: '#/channels/signups/messages/signup/payload', target: '#/channels/signups' }, + { site: '#/channels/signups', target: '#/channels/signups/messages/signup/payload' }, + ], + asyncTree + ); + expect(getAsync3Polarity('#/channels/signups/messages/signup', recursive, asyncTree)).toBe( + 'neutral' + ); + }); +}); diff --git a/packages/cli/src/commands/diff/__tests__/predicates.test.ts b/packages/cli/src/commands/diff/__tests__/predicates.test.ts new file mode 100644 index 0000000000..5ddaf2152e --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/predicates.test.ts @@ -0,0 +1,73 @@ +import { + constraintDirection, + effectiveTypes, + isScalarArray, + isTypeSetNarrowed, + isTypeSetWidened, +} from '../engine/predicates.js'; + +describe('diff predicates', () => { + it('does not treat an empty array as a scalar value', () => { + // An empty array is walked as a node of its own, so counting it as a scalar too + // would report the same change twice (`security: []` did). + expect(isScalarArray([])).toBe(false); + expect(isScalarArray(['a', 1, true])).toBe(true); + }); + + it('classifies type narrowing and widening over the whole accepted set', () => { + const narrowed = (before: unknown, after: unknown) => + isTypeSetNarrowed(effectiveTypes(before), effectiveTypes(after)); + const widened = (before: unknown, after: unknown) => + isTypeSetWidened(effectiveTypes(before), effectiveTypes(after)); + + // integer → number widens the accepted set + expect(narrowed('integer', 'number')).toBe(false); + expect(widened('integer', 'number')).toBe(true); + // number → integer narrows it + expect(narrowed('number', 'integer')).toBe(true); + expect(widened('number', 'integer')).toBe(false); + // string → number is incompatible both ways + expect(narrowed('string', 'number')).toBe(true); + expect(widened('string', 'number')).toBe(true); + // same type — neither + expect(narrowed('string', 'string')).toBe(false); + expect(widened('string', 'string')).toBe(false); + + // Accepting one more type is a widening, not a narrowing. + expect(narrowed('string', ['string', 'number'])).toBe(false); + expect(widened('string', ['string', 'number'])).toBe(true); + // ...and dropping one is the narrowing. + expect(narrowed(['string', 'number'], 'string')).toBe(true); + expect(widened(['string', 'number'], 'string')).toBe(false); + }); + + it('reads 3.0 `nullable` as the 3.1 null type, so the two spellings match', () => { + const from30 = effectiveTypes('string', true); + const from31 = effectiveTypes(['string', 'null']); + + expect([...from30].sort()).toEqual(['null', 'string']); + expect(isTypeSetNarrowed(from30, from31)).toBe(false); + expect(isTypeSetWidened(from30, from31)).toBe(false); + + // Dropping nullability still narrows. + expect(isTypeSetNarrowed(from30, effectiveTypes('string'))).toBe(true); + }); + + it('tells a tightened constraint from a loosened one', () => { + // A bound that leaves less room accepts less. + expect(constraintDirection('maxLength', 100, 10)).toBe('tighter'); + expect(constraintDirection('maxLength', 10, 100)).toBe('looser'); + expect(constraintDirection('minimum', 0, 10)).toBe('tighter'); + expect(constraintDirection('minimum', 10, 0)).toBe('looser'); + // Presence alone decides when one side has no constraint. + expect(constraintDirection('maxLength', undefined, 10)).toBe('tighter'); + expect(constraintDirection('maxLength', 10, undefined)).toBe('looser'); + // Equivalence of a pattern or format cannot be computed, so assume the worst. + expect(constraintDirection('pattern', '^a', '^b')).toBe('tighter'); + expect(constraintDirection('format', undefined, 'uuid')).toBe('tighter'); + // Closing an open object accepts less; opening it accepts more. + expect(constraintDirection('additionalProperties', true, false)).toBe('tighter'); + expect(constraintDirection('additionalProperties', false, true)).toBe('looser'); + expect(constraintDirection('maxLength', 10, 10)).toBe('same'); + }); +}); diff --git a/packages/cli/src/commands/diff/__tests__/problems.test.ts b/packages/cli/src/commands/diff/__tests__/problems.test.ts new file mode 100644 index 0000000000..46ca466eac --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/problems.test.ts @@ -0,0 +1,86 @@ +import { Source } from '@redocly/openapi-core'; + +import type { DiffResult } from '../engine/types.js'; +import { breakingChangesToProblems } from '../serializers/problems.js'; + +const baseSource = new Source('base.yaml', 'openapi: 3.1.0\n'); +const revisionSource = new Source('revision.yaml', 'openapi: 3.1.0\n'); + +const result: DiffResult = { + version: '1', + specVersions: { base: 'oas3_1', revision: 'oas3_1' }, + summary: { breaking: 2, nonBreaking: 1 }, + changes: [ + { + pointer: '#/paths/~1pets/delete', + kind: 'removed', + typeName: 'Operation', + base: { pointer: '#/paths/~1pets/delete', file: 'base.yaml', line: 21, col: 7 }, + compat: 'breaking', + verdicts: [ + { compat: 'breaking', ruleId: 'operation-removed', message: 'Operation was removed.' }, + ], + }, + { + pointer: '#/paths/~1pets/get/parameters/{query:limit}', + property: 'required', + kind: 'changed', + typeName: 'Parameter', + base: { pointer: '#/paths/~1pets/get/parameters/0/required' }, + revision: { pointer: '#/paths/~1pets/get/parameters/0/required', value: true }, + compat: 'breaking', + verdicts: [ + { + compat: 'breaking', + ruleId: 'parameter-became-required', + message: 'Parameter became required.', + }, + ], + }, + { + pointer: '#/info', + property: 'version', + kind: 'changed', + typeName: 'Info', + base: { pointer: '#/info/version' }, + revision: { pointer: '#/info/version' }, + compat: 'non-breaking', + }, + ], +}; + +describe('breakingChangesToProblems', () => { + it('describes each breaking change the way a lint problem is described', () => { + const problems = breakingChangesToProblems(result, baseSource, revisionSource); + + // Only the breaking changes map onto a lint problem, because a problem always + // carries a severity. A removal is shown in the base document, everything else in + // the revision, with the other side attached as `from` so both are reachable. + expect( + problems.map((problem) => ({ + severity: problem.severity, + ruleId: problem.ruleId, + message: problem.message, + at: `${problem.location[0].source.absoluteRef}${problem.location[0].pointer}`, + from: problem.from && `${problem.from.source.absoluteRef}${problem.from.pointer}`, + })) + ).toMatchInlineSnapshot(` + [ + { + "at": "base.yaml#/paths/~1pets/delete", + "from": undefined, + "message": "Operation was removed.", + "ruleId": "operation-removed", + "severity": "error", + }, + { + "at": "revision.yaml#/paths/~1pets/get/parameters/0/required", + "from": "base.yaml#/paths/~1pets/get/parameters/0/required", + "message": "Parameter became required.", + "ruleId": "parameter-became-required", + "severity": "error", + }, + ] + `); + }); +}); diff --git a/packages/cli/src/commands/diff/__tests__/serializers.test.ts b/packages/cli/src/commands/diff/__tests__/serializers.test.ts new file mode 100644 index 0000000000..c5a54d4eb2 --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/serializers.test.ts @@ -0,0 +1,185 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { cleanColors } from '../../../utils/miscellaneous.js'; +import type { DiffResult } from '../engine/types.js'; +import { htmlDiff } from '../serializers/html.js'; +import { markdownDiff } from '../serializers/markdown.js'; +import { stylishDiff } from '../serializers/stylish.js'; + +/** + * One result carrying every shape the reports have to survive: a removal located in the + * base document, a property change located in both, an added component, the synthetic + * path-rename change, a change no rule judged — and content that fights the output + * format, so the escaping shows up in the snapshots below. + */ +const RESULT: DiffResult = { + version: '1', + specVersions: { base: 'oas3_1', revision: 'oas3_1' }, + summary: { breaking: 3, nonBreaking: 2 }, + changes: [ + { + pointer: '#/paths/~1pets/delete', + kind: 'removed', + typeName: 'Operation', + base: { + pointer: '#/paths/~1pets/delete', + file: 'base.yaml', + line: 30, + col: 3, + value: { summary: '' }, + }, + compat: 'breaking', + verdicts: [ + { ruleId: 'operation-removed', compat: 'breaking', message: 'Operation was removed.' }, + ], + }, + { + pointer: '#/paths/~1pets/get/parameters/{query:limit}', + property: 'required', + kind: 'changed', + typeName: 'Parameter', + base: { + pointer: '#/paths/~1pets/get/parameters/0/required', + file: 'base.yaml', + line: 9, + col: 21, + }, + revision: { + pointer: '#/paths/~1pets/get/parameters/1/required', + file: 'revision.yaml', + line: 11, + col: 21, + value: true, + }, + compat: 'breaking', + verdicts: [ + { + ruleId: 'parameter-became-required', + compat: 'breaking', + message: 'Parameter became required.', + }, + ], + }, + { + pointer: '#/paths/~1pets/post/requestBody/content/application~1json/schema', + property: 'pattern', + kind: 'changed', + typeName: 'Schema', + revision: { + pointer: '#/paths/~1pets/post/requestBody/content/application~1json/schema/pattern', + file: 'revision.yaml', + line: 18, + col: 22, + value: 'a|b', + }, + compat: 'breaking', + verdicts: [ + { + ruleId: 'string-length-changed', + compat: 'breaking', + // A pattern is free text, so a message about it can hold the markdown cell + // separator and the code-span marker. + message: "`pattern` changed from 'a' to 'a|b'.", + }, + ], + }, + { + pointer: '#/components/schemas/Pet', + kind: 'added', + typeName: 'Schema', + revision: { + pointer: '#/components/schemas/Pet', + file: 'revision.yaml', + line: 20, + col: 5, + value: { type: 'object' }, + }, + compat: 'non-breaking', + }, + { + pointer: '#/paths/~1pet~1{id}', + property: 'path', + kind: 'changed', + typeName: 'PathItem', + base: { + pointer: '#/paths/~1pet~1{id}', + file: 'base.yaml', + line: 4, + col: 3, + value: '/pet/{id}', + }, + revision: { + pointer: '#/paths/~1pet~1{petId}', + file: 'revision.yaml', + line: 4, + col: 3, + value: '/pet/{petId}', + }, + compat: 'non-breaking', + }, + ], +}; + +describe('stylishDiff', () => { + it('groups changes per operation, worst first, each with its verdicts and location', () => { + // vitest.config.ts forces FORCE_COLOR=1, so the ANSI codes are stripped here. + expect(cleanColors(stylishDiff(RESULT))).toMatchInlineSnapshot(` + "/pet/{petId} + ✔ non-breaking changed paths · /pet/{id} · path + at revision.yaml:4:3 + + components + ✔ non-breaking added components/schemas/Pet + at revision.yaml:20:5 + + DELETE /pets + ✖ breaking removed paths · /pets · delete + Operation was removed. (operation-removed) + at base.yaml:30:3 + + GET /pets + ✖ breaking changed parameters/{query:limit} · required + Parameter became required. (parameter-became-required) + at revision.yaml:11:21 + + POST /pets + ✖ breaking changed requestBody/content/application~1json/schema · pattern + \`pattern\` changed from 'a' to 'a|b'. (string-length-changed) + at revision.yaml:18:22 + + 3 breaking, 2 non-breaking." + `); + }); +}); + +describe('markdownDiff', () => { + it('renders one table row per change', () => { + expect(markdownDiff(RESULT)).toMatchInlineSnapshot(` + "## API diff + + **3** breaking · **2** non-breaking + + | Impact | Change | Location | Details | + | --- | --- | --- | --- | + | 🔴 breaking | removed | \`#/paths/~1pets/delete\` | Operation was removed. \`operation-removed\` | + | 🔴 breaking | changed | \`#/paths/~1pets/get/parameters/{query:limit} · required\` | Parameter became required. \`parameter-became-required\` | + | 🔴 breaking | changed | \`#/paths/~1pets/post/requestBody/content/application~1json/schema · pattern\` | \\\`pattern\\\` changed from 'a' to 'a\\|b'. \`string-length-changed\` | + | 🟢 non-breaking | added | \`#/components/schemas/Pet\` | | + | 🟢 non-breaking | changed | \`#/paths/~1pet~1{id} · path\` | |" + `); + }); +}); + +describe('htmlDiff', () => { + it('renders a self-contained page', async () => { + const output = htmlDiff(RESULT); + + // Kept as a real .html file: the snapshot can be opened in a browser to review it. + await expect(output).toMatchFileSnapshot( + join(dirname(fileURLToPath(import.meta.url)), '__snapshots__', 'html-report.html') + ); + // The report is opened straight from disk, so it must pull in nothing. + expect(output).not.toMatch(/src="http|href="http/); + }); +}); diff --git a/packages/cli/src/commands/diff/__tests__/tree.ts b/packages/cli/src/commands/diff/__tests__/tree.ts new file mode 100644 index 0000000000..0a4f643db4 --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/tree.ts @@ -0,0 +1,32 @@ +import type { NodeEntry } from '../engine/types.js'; + +/** + * Builds a lookup over a spelled-out node tree for tests that need real node + * types rather than bare pointers. Each line is `pointer typeName`, optionally + * followed by `key=value` scalars, and a node's parent is the closest preceding line + * whose pointer is a prefix of it — which is what `collect` records when it walks a + * document. + */ +export function treeOf(nodes: string): Map { + const entries = new Map(); + const pointers: string[] = []; + + for (const line of nodes.trim().split('\n')) { + const [pointer, typeName, ...assignments] = line.trim().split(/\s+/); + const parentPointer = + [...pointers].reverse().find((candidate) => pointer.startsWith(`${candidate}/`)) ?? null; + pointers.push(pointer); + entries.set(pointer, { + pointer, + realPointer: pointer, + parentPointer, + keyInParent: pointer.slice(pointer.lastIndexOf('/') + 1), + typeName, + scalars: Object.fromEntries(assignments.map((pair) => pair.split('='))), + refs: {}, + raw: {}, + }); + } + + return entries; +} diff --git a/packages/cli/src/commands/diff/engine/align-paths.ts b/packages/cli/src/commands/diff/engine/align-paths.ts new file mode 100644 index 0000000000..b39f3128a2 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/align-paths.ts @@ -0,0 +1,130 @@ +import { unescapePointerFragment } from '@redocly/openapi-core'; + +import { escapeIdentityKeyPart } from './node-identity.js'; +import type { NodeEntry } from './types.js'; + +export interface PathRename { + baseTemplate: string; + revisionTemplate: string; + basePointer: string; + revisionPointer: string; + baseRealPointer: string; + revisionRealPointer: string; +} + +/** A path template parameter: the `id` in `/pets/{id}`. */ +const TEMPLATE_PARAM = /\{([^}]+)\}/g; + +/** A parameter segment of a stable pointer, as node-identity writes it: `{path:id}`. */ +const PATH_PARAM_SEGMENT = /^\{path:(.+)\}$/; + +function normalizeTemplate(template: string): string { + let index = 0; + return template.replace(TEMPLATE_PARAM, () => `{${index++}}`); +} + +function paramNames(template: string): string[] { + return [...template.matchAll(TEMPLATE_PARAM)].map((match) => match[1]); +} + +// raw path template → stable PathItem pointer +function pathTemplates(entries: Map): Map { + const result = new Map(); + for (const entry of entries.values()) { + if (entry.typeName === 'PathItem' && entry.parentPointer === '#/paths') { + result.set(unescapePointerFragment(entry.pointer.slice('#/paths/'.length)), entry.pointer); + } + } + return result; +} + +function groupByNormalized(templates: string[]): Map { + const groups = new Map(); + for (const template of templates) { + const normalized = normalizeTemplate(template); + groups.set(normalized, [...(groups.get(normalized) ?? []), template]); + } + return groups; +} + +// Matches path templates that differ only in parameter names and re-keys the +// revision entries into the base pointer space. Only unambiguous 1:1 matches +// are aliased — anything else keeps its own keys and diffs as remove+add. +export function alignRenamedPaths( + base: Map, + revision: Map +): { revision: Map; renames: PathRename[] } { + const baseTemplates = pathTemplates(base); + const revisionTemplates = pathTemplates(revision); + + const baseGroups = groupByNormalized( + [...baseTemplates.keys()].filter((template) => !revisionTemplates.has(template)) + ); + const revisionGroups = groupByNormalized( + [...revisionTemplates.keys()].filter((template) => !baseTemplates.has(template)) + ); + + const renames: PathRename[] = []; + for (const [normalized, baseCandidates] of baseGroups) { + const revisionCandidates = revisionGroups.get(normalized) ?? []; + if (baseCandidates.length !== 1 || revisionCandidates.length !== 1) continue; + const [baseTemplate] = baseCandidates; + const [revisionTemplate] = revisionCandidates; + const basePointer = baseTemplates.get(baseTemplate)!; + const revisionPointer = revisionTemplates.get(revisionTemplate)!; + renames.push({ + baseTemplate, + revisionTemplate, + basePointer, + revisionPointer, + baseRealPointer: base.get(basePointer)!.realPointer, + revisionRealPointer: revision.get(revisionPointer)!.realPointer, + }); + } + + if (!renames.length) return { revision, renames }; + + const rewrites = renames.map((rename) => { + const baseParams = paramNames(rename.baseTemplate); + return { + fromPrefix: rename.revisionPointer, + toPrefix: rename.basePointer, + // positional mapping of revision param names to base param names, + // pre-escaped the way node-identity builds '{path:}' segments + paramMap: new Map( + paramNames(rename.revisionTemplate).map((name, position) => [ + escapeIdentityKeyPart(name), + escapeIdentityKeyPart(baseParams[position]), + ]) + ), + }; + }); + + const rewriteKey = (key: string): string => { + for (const { fromPrefix, toPrefix, paramMap } of rewrites) { + if (key !== fromPrefix && !key.startsWith(fromPrefix + '/')) continue; + const suffix = key + .slice(fromPrefix.length) + .split('/') + .map((segment) => { + const match = segment.match(PATH_PARAM_SEGMENT); + const mapped = match && paramMap.get(match[1]); + return mapped ? `{path:${mapped}}` : segment; + }) + .join('/'); + return toPrefix + suffix; + } + return key; + }; + + const aliased = new Map(); + for (const [key, entry] of revision) { + const newKey = rewriteKey(key); + aliased.set(newKey, { + ...entry, + pointer: newKey, + parentPointer: entry.parentPointer === null ? null : rewriteKey(entry.parentPointer), + }); + } + return { revision: aliased, renames }; +} diff --git a/packages/cli/src/commands/diff/engine/classify/async3.ts b/packages/cli/src/commands/diff/engine/classify/async3.ts new file mode 100644 index 0000000000..7619e856d1 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/async3.ts @@ -0,0 +1,21 @@ +import type { DiffRuleRegistry } from '../types.js'; +import { channelAddressChanged, channelRemoved } from './rules/channel.js'; +import { messageContentTypeChanged, messageRemoved } from './rules/message.js'; +import { operationActionChanged, operationRemoved } from './rules/operation.js'; +import { refTargetChanged } from './rules/ref.js'; +import { schemaRules } from './rules/schema.js'; +import { serverRemoved } from './rules/server.js'; + +// An AsyncAPI 3 payload is a `Schema` node of the same shape the OpenAPI rules already +// judge, and its direction comes from the `action` of the operations that reference the +// channel (see `polarity.ts`), so the whole schema rule set is reused as it is. +export const async3Rules: DiffRuleRegistry = { + Channel: [channelRemoved, channelAddressChanged, refTargetChanged], + NamedChannels: [channelRemoved], + Message: [messageRemoved, messageContentTypeChanged, refTargetChanged], + NamedMessages: [messageRemoved], + Operation: [operationRemoved, operationActionChanged, refTargetChanged], + Server: [serverRemoved], + ServerMap: [serverRemoved], + Schema: [...schemaRules, refTargetChanged], +}; diff --git a/packages/cli/src/commands/diff/engine/classify/chain.ts b/packages/cli/src/commands/diff/engine/classify/chain.ts new file mode 100644 index 0000000000..4630f061f8 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/chain.ts @@ -0,0 +1,20 @@ +import type { NodeEntry } from '../types.js'; + +/** Looks a node up on either side; ancestors of a removed node only exist in the base. */ +export type NodeLookup = (pointer: string) => NodeEntry | undefined; + +/** The node's ancestors and itself, root first, as far as the maps can resolve them. */ +export function ancestorChain(pointer: string, lookup: NodeLookup): NodeEntry[] { + const chain: NodeEntry[] = []; + const seen = new Set(); + + for (let current: string | null = pointer; current && !seen.has(current); ) { + seen.add(current); + const entry = lookup(current); + if (!entry) break; + chain.unshift(entry); + current = entry.parentPointer; + } + + return chain; +} diff --git a/packages/cli/src/commands/diff/engine/classify/index.ts b/packages/cli/src/commands/diff/engine/classify/index.ts new file mode 100644 index 0000000000..698545cf44 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/index.ts @@ -0,0 +1,86 @@ +import type { SpecVersion } from '@redocly/openapi-core'; + +import { + compatRank, + type Change, + type ChangeVerdict, + type DiffRuleRegistry, + type NodeEntry, + type Polarity, + type RawChange, +} from '../types.js'; +import { async3Rules } from './async3.js'; +import type { NodeLookup } from './chain.js'; +import { oas3Rules } from './oas3.js'; +import { getAsync3Polarity, getOas3Polarity, type PolarityResolver } from './polarity.js'; +import type { UsageIndex } from './usage.js'; + +/** + * What a specification family brings to classification: the rules to run, and the way + * that family states which direction the data in a node travels. + */ +const SPECS: Partial< + Record +> = { + oas3_0: { rules: oas3Rules, polarityOf: getOas3Polarity }, + oas3_1: { rules: oas3Rules, polarityOf: getOas3Polarity }, + // A version gets its own entry once it needs a rule the others must not run. + oas3_2: { rules: oas3Rules, polarityOf: getOas3Polarity }, + async3: { rules: async3Rules, polarityOf: getAsync3Polarity }, +}; + +function expandPolarity(polarity: Polarity): Polarity[] { + return polarity === 'both' ? ['request', 'response'] : [polarity]; +} + +export function classifyChanges(opts: { + changes: RawChange[]; + specVersion: SpecVersion; + base: Map; + revision: Map; + usage: UsageIndex; +}): Change[] { + const { changes, specVersion, base, revision, usage } = opts; + const spec = SPECS[specVersion]; + if (!spec) { + // Structural comparison works for every specification; only these families are + // judged, so elsewhere no rule runs and nothing is called breaking. + return changes.map((change) => ({ ...change, compat: 'non-breaking' as const })); + } + + // A removed node only exists in the base, an added one only in the revision. + const nodeAt: NodeLookup = (pointer) => revision.get(pointer) ?? base.get(pointer); + + return changes.map((change) => { + const rules = spec.rules[change.typeName] ?? []; + const verdicts: ChangeVerdict[] = []; + + for (const polarity of expandPolarity(spec.polarityOf(change.pointer, usage, nodeAt))) { + const ctx = { + polarity, + specVersion, + base: (pointer: string) => base.get(pointer), + revision: (pointer: string) => revision.get(pointer), + nodeAt, + }; + for (const rule of rules) { + const verdict = rule.visit(change, ctx); + if (!verdict) continue; + // a 'both'-polarity node can fire the same rule twice with the same message + if (!verdicts.some((v) => v.ruleId === rule.id && v.message === verdict.message)) { + verdicts.push({ ruleId: rule.id, ...verdict }); + } + } + } + + verdicts.sort( + (a, b) => compatRank(b.compat) - compatRank(a.compat) || a.ruleId.localeCompare(b.ruleId) + ); + + return { + ...change, + compat: verdicts[0]?.compat ?? 'non-breaking', + ...(verdicts.length ? { verdicts } : {}), + }; + }); +} diff --git a/packages/cli/src/commands/diff/engine/classify/oas3.ts b/packages/cli/src/commands/diff/engine/classify/oas3.ts new file mode 100644 index 0000000000..e646048cef --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/oas3.ts @@ -0,0 +1,40 @@ +import type { DiffRuleRegistry } from '../types.js'; +import { operationRemoved, pathRemoved } from './rules/operation.js'; +import { + parameterAddedRequired, + parameterBecameRequired, + parameterRemoved, + parameterSerializationChanged, +} from './rules/parameter.js'; +import { refTargetChanged } from './rules/ref.js'; +import { requestBodyBecameRequired, requestBodyRemoved } from './rules/request-body.js'; +import { mediaTypeRemoved, responseHeaderRemoved, responseRemoved } from './rules/response.js'; +import { schemaRules } from './rules/schema.js'; +import { + securityRequirementAdded, + securitySchemeChanged, + securitySchemeRemoved, + securityScopesAdded, +} from './rules/security.js'; + +export const oas3Rules: DiffRuleRegistry = { + Operation: [operationRemoved], + PathItem: [pathRemoved, refTargetChanged], + Parameter: [ + parameterRemoved, + parameterAddedRequired, + parameterBecameRequired, + parameterSerializationChanged, + refTargetChanged, + ], + ParameterList: [parameterRemoved, parameterAddedRequired], + Response: [responseRemoved, refTargetChanged], + Header: [responseHeaderRemoved, refTargetChanged], + HeadersMap: [responseHeaderRemoved], + MediaType: [mediaTypeRemoved, refTargetChanged], + RequestBody: [requestBodyRemoved, requestBodyBecameRequired, refTargetChanged], + SecurityRequirementList: [securityRequirementAdded], + SecurityRequirement: [securityRequirementAdded, securityScopesAdded], + SecurityScheme: [securitySchemeChanged, securitySchemeRemoved, refTargetChanged], + Schema: [...schemaRules, refTargetChanged], +}; diff --git a/packages/cli/src/commands/diff/engine/classify/polarity.ts b/packages/cli/src/commands/diff/engine/classify/polarity.ts new file mode 100644 index 0000000000..3e6bae9288 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/polarity.ts @@ -0,0 +1,109 @@ +import type { NodeEntry, Polarity } from '../types.js'; +import { ancestorChain, type NodeLookup } from './chain.js'; +import { getComponentRoot, type UsageIndex } from './usage.js'; + +/** How one specification family decides which way the data in a node travels. */ +export type PolarityResolver = (pointer: string, usage: UsageIndex, lookup: NodeLookup) => Polarity; + +function opposite(polarity: Polarity): Polarity { + if (polarity === 'request') return 'response'; + if (polarity === 'response') return 'request'; + return polarity; +} + +// Direction comes from the node types the type tree assigns, not from pointer text: +// a schema property named `responses` is a `Schema`, so it can never be mistaken +// for the `Responses` node that actually carries a direction. +const RESPONSE_TYPES = new Set(['Responses', 'Response']); +const REQUEST_TYPES = new Set(['RequestBody', 'Parameter', 'ParameterList']); + +// A callback or a webhook is a request the API sends to the consumer, so every +// direction below it is flipped: its request body reaches the consumer the way a +// response does, and its responses travel back the way a request does. +// Only the containing map is listed, never the entry inside it — a callback path +// runs through `CallbacksMap` and then `Callback`, and counting both would flip +// twice and land back where it started. One entry per nesting level keeps a +// callback declared inside a callback pointing the right way. +const INVERTING_TYPES = new Set(['CallbacksMap', 'WebhooksMap']); + +function getOas3SitePolarity(pointer: string, lookup: NodeLookup): Polarity { + let inverted = false; + + for (const { typeName } of ancestorChain(pointer, lookup)) { + if (INVERTING_TYPES.has(typeName)) { + inverted = !inverted; + } else if (RESPONSE_TYPES.has(typeName)) { + return inverted ? 'request' : 'response'; + } else if (REQUEST_TYPES.has(typeName)) { + return inverted ? 'response' : 'request'; + } + } + + return 'neutral'; +} + +export const getOas3Polarity: PolarityResolver = (pointer, usage, lookup) => { + // A component is compared at its own path, so its direction comes from the + // sites that reference it rather than from its own position. + const componentRoot = getComponentRoot(pointer, lookup); + if (componentRoot) { + return usage.polarityOf(componentRoot, (site) => getOas3SitePolarity(site, lookup)); + } + + return getOas3SitePolarity(pointer, lookup); +}; + +/** + * `receive` means another application produces the message, so its payload is judged + * the way a request body is; `send` means this application produces it, so its payload + * is judged the way a response is. + */ +function actionPolarity(action: unknown): Polarity { + if (action === 'receive') return 'request'; + if (action === 'send') return 'response'; + return 'neutral'; +} + +function getOperationPolarity(chain: NodeEntry[]): Polarity { + const operation = [...chain].reverse().find((entry) => entry.typeName === 'Operation'); + if (!operation) return 'neutral'; + + const polarity = actionPolarity(operation.scalars.action); + // A reply answers the operation, so it travels back the other way. + const underReply = chain.some((entry) => entry.typeName === 'OperationReply'); + return underReply ? opposite(polarity) : polarity; +} + +/** + * AsyncAPI declares the direction instead of implying it from the position, so the + * `action` of the operation decides it. Channels and their messages sit outside the + * operations, so their direction comes from every operation that references them. + */ +export const getAsync3Polarity: PolarityResolver = (pointer, usage, lookup) => + resolveAsync3Polarity(pointer, usage, lookup, new Set()); + +function resolveAsync3Polarity( + pointer: string, + usage: UsageIndex, + lookup: NodeLookup, + resolving: Set +): Polarity { + // A payload that refers back into its own channel would otherwise resolve forever. + if (resolving.has(pointer)) return 'neutral'; + resolving.add(pointer); + + const chain = ancestorChain(pointer, lookup); + const own = getOperationPolarity(chain); + if (own !== 'neutral') return own; + + // The nearest referenced ancestor wins: a change deep inside a payload is only + // reachable through the message or channel that holds it. + for (const entry of [...chain].reverse()) { + const polarity = usage.polarityOf(entry.pointer, (site) => + resolveAsync3Polarity(site, usage, lookup, resolving) + ); + if (polarity !== 'neutral') return polarity; + } + + return 'neutral'; +} diff --git a/packages/cli/src/commands/diff/engine/classify/rules/channel.ts b/packages/cli/src/commands/diff/engine/classify/rules/channel.ts new file mode 100644 index 0000000000..4d588d8e15 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/rules/channel.ts @@ -0,0 +1,30 @@ +import { breaking, type DiffRule } from '../../types.js'; + +// A channel is where AsyncAPI messages travel, so both sides of it break together and +// these rules do not read the polarity. + +// Registered for both `Channel` and `NamedChannels`: dropping every channel collapses +// into a single change on the map, dropping one lands on the channel itself. +export const channelRemoved: DiffRule = { + id: 'channel-removed', + description: 'Removing a channel leaves its publishers and subscribers with nowhere to go.', + visit(change) { + if (change.kind !== 'removed') return; + return breaking( + change.typeName === 'NamedChannels' + ? 'Every channel was removed.' + : 'The channel was removed.' + ); + }, +}; + +export const channelAddressChanged: DiffRule = { + id: 'channel-address-changed', + description: 'The address is what clients publish to and subscribe on.', + visit(change) { + if (change.property !== 'address') return; + return breaking( + `The channel address changed from '${change.base?.value}' to '${change.revision?.value}'.` + ); + }, +}; diff --git a/packages/cli/src/commands/diff/engine/classify/rules/message.ts b/packages/cli/src/commands/diff/engine/classify/rules/message.ts new file mode 100644 index 0000000000..9d435b761b --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/rules/message.ts @@ -0,0 +1,27 @@ +import { breaking, type DiffRule } from '../../types.js'; + +// Registered for both `Message` and `NamedMessages`: dropping every message of a channel +// collapses into a single change on the map, dropping one lands on the message itself. +export const messageRemoved: DiffRule = { + id: 'message-removed', + description: 'Removing a message breaks every application that sends or receives it.', + visit(change) { + if (change.kind !== 'removed') return; + return breaking( + change.typeName === 'NamedMessages' + ? 'Every message of the channel was removed.' + : 'The message was removed.' + ); + }, +}; + +export const messageContentTypeChanged: DiffRule = { + id: 'message-content-type-changed', + description: 'A message in another content type cannot be decoded by existing clients.', + visit(change) { + if (change.property !== 'contentType') return; + return breaking( + `The message content type changed from '${change.base?.value}' to '${change.revision?.value}'.` + ); + }, +}; diff --git a/packages/cli/src/commands/diff/engine/classify/rules/operation.ts b/packages/cli/src/commands/diff/engine/classify/rules/operation.ts new file mode 100644 index 0000000000..e66a9e5f31 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/rules/operation.ts @@ -0,0 +1,32 @@ +import { breaking, type DiffRule } from '../../types.js'; + +export const operationRemoved: DiffRule = { + id: 'operation-removed', + description: 'Removing an operation breaks all of its consumers.', + visit(change) { + if (change.kind !== 'removed') return; + return breaking('Operation was removed.'); + }, +}; + +export const pathRemoved: DiffRule = { + id: 'path-removed', + description: 'Removing a path breaks all consumers of its operations.', + visit(change) { + if (change.kind !== 'removed') return; + return breaking('Path was removed.'); + }, +}; + +// AsyncAPI only: an operation states its own direction, and swapping it turns every +// message of the channel around. +export const operationActionChanged: DiffRule = { + id: 'operation-action-changed', + description: 'Swapping send and receive reverses which side of the channel the API is on.', + visit(change) { + if (change.property !== 'action') return; + return breaking( + `The operation action changed from '${change.base?.value}' to '${change.revision?.value}'.` + ); + }, +}; diff --git a/packages/cli/src/commands/diff/engine/classify/rules/parameter.ts b/packages/cli/src/commands/diff/engine/classify/rules/parameter.ts new file mode 100644 index 0000000000..9546e88126 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/rules/parameter.ts @@ -0,0 +1,63 @@ +import { isPlainObject } from '@redocly/openapi-core'; + +import { becameTrue } from '../../predicates.js'; +import { breaking, type DiffRule } from '../../types.js'; + +// Registered for both `Parameter` and `ParameterList`: the last parameter of an +// operation leaves with the whole `parameters` list, and the first one arrives with it, +// so those changes land on the list rather than on a parameter. +export const parameterRemoved: DiffRule = { + id: 'parameter-removed', + description: 'Removing a request parameter breaks clients that send it.', + visit(change, ctx) { + if (change.kind !== 'removed' || ctx.polarity !== 'request') return; + return breaking( + change.typeName === 'ParameterList' + ? 'Every parameter was removed.' + : 'Parameter was removed.' + ); + }, +}; + +export const parameterAddedRequired: DiffRule = { + id: 'parameter-added-required', + description: 'Adding a new required parameter breaks clients that do not send it.', + visit(change, ctx) { + if (change.kind !== 'added' || ctx.polarity !== 'request') return; + const added = + change.typeName === 'ParameterList' ? change.revision?.value : [change.revision?.value]; + if (!Array.isArray(added)) return; + if (added.some((parameter) => isPlainObject(parameter) && parameter.required === true)) { + return breaking('A new required parameter was added.'); + } + return undefined; + }, +}; + +export const parameterBecameRequired: DiffRule = { + id: 'parameter-became-required', + description: 'Marking an existing request parameter as required breaks clients that omit it.', + visit(change, ctx) { + if (change.property !== 'required' || ctx.polarity !== 'request') return; + if (becameTrue(change.base?.value, change.revision?.value)) { + return breaking('Parameter became required.'); + } + return undefined; + }, +}; + +// 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 = { + id: 'parameter-serialization-changed', + description: 'Changing how a parameter is serialized breaks clients that encode it the old way.', + visit(change, ctx) { + if (!change.property || !SERIALIZATION.has(change.property)) return; + if (ctx.polarity !== 'request') return; + return breaking( + `Parameter \`${change.property}\` changed from '${change.base?.value}' to '${change.revision?.value}'.` + ); + }, +}; diff --git a/packages/cli/src/commands/diff/engine/classify/rules/ref.ts b/packages/cli/src/commands/diff/engine/classify/rules/ref.ts new file mode 100644 index 0000000000..04de27cf12 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/rules/ref.ts @@ -0,0 +1,18 @@ +import { breaking, type DiffRule } from '../../types.js'; + +// Pointer-aligned comparison cannot verify whether two different targets are +// content-equivalent (spec §7.3, §13) — the conservative verdict is breaking. +export const refTargetChanged: DiffRule = { + id: 'ref-target-changed', + description: + 'The `$ref` points to a different target. The diff cannot check that the new target is equivalent.', + visit(change, ctx) { + if (change.kind !== 'changed' || !change.property) return; + const wasRef = change.property in (ctx.base(change.pointer)?.refs ?? {}); + const isRefNow = change.property in (ctx.revision(change.pointer)?.refs ?? {}); + if (!wasRef && !isRefNow) return; + return breaking( + `The reference target changed from '${change.base?.value}' to '${change.revision?.value}'. The diff cannot check that the two targets are equivalent.` + ); + }, +}; diff --git a/packages/cli/src/commands/diff/engine/classify/rules/request-body.ts b/packages/cli/src/commands/diff/engine/classify/rules/request-body.ts new file mode 100644 index 0000000000..0e57106aab --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/rules/request-body.ts @@ -0,0 +1,24 @@ +import { becameTrue } from '../../predicates.js'; +import { breaking, type DiffRule } from '../../types.js'; + +export const requestBodyBecameRequired: DiffRule = { + id: 'request-body-became-required', + description: 'Requiring a body that used to be optional breaks clients that send none.', + visit(change, ctx) { + if (change.property !== 'required' || ctx.polarity !== 'request') return; + if (becameTrue(change.base?.value, change.revision?.value)) { + return breaking('The request body became required.'); + } + return undefined; + }, +}; + +export const requestBodyRemoved: DiffRule = { + id: 'request-body-removed', + description: + 'When the request body is removed, the API no longer reads the data that clients send.', + visit(change, ctx) { + if (change.kind !== 'removed' || ctx.polarity !== 'request') return; + return breaking('The request body was removed.'); + }, +}; diff --git a/packages/cli/src/commands/diff/engine/classify/rules/response.ts b/packages/cli/src/commands/diff/engine/classify/rules/response.ts new file mode 100644 index 0000000000..a3ae7eeb03 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/rules/response.ts @@ -0,0 +1,34 @@ +import { breaking, type DiffRule } from '../../types.js'; + +export const responseRemoved: DiffRule = { + id: 'response-removed', + description: 'Removing a response breaks clients that handle it.', + visit(change) { + if (change.kind !== 'removed') return; + return breaking('Response was removed.'); + }, +}; + +export const mediaTypeRemoved: DiffRule = { + id: 'media-type-removed', + description: 'Removing a media type breaks clients that produce or consume it.', + visit(change) { + if (change.kind !== 'removed') return; + return breaking('Media type was removed.'); + }, +}; + +// Registered for both `Header` and `HeadersMap`: dropping every header collapses +// into a single change on the map, dropping one lands on the header itself. +export const responseHeaderRemoved: DiffRule = { + id: 'response-header-removed', + description: 'Removing a response header breaks clients that read it.', + visit(change, ctx) { + if (change.kind !== 'removed' || ctx.polarity !== 'response') return; + return breaking( + change.typeName === 'HeadersMap' + ? 'The response headers were removed.' + : 'A response header was removed.' + ); + }, +}; diff --git a/packages/cli/src/commands/diff/engine/classify/rules/schema.ts b/packages/cli/src/commands/diff/engine/classify/rules/schema.ts new file mode 100644 index 0000000000..6b7a7958d8 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/rules/schema.ts @@ -0,0 +1,215 @@ +import { + addedItems, + constraintDirection, + effectiveTypes, + isTypeSetNarrowed, + isTypeSetWidened, + missingItems, + type ConstraintDirection, +} from '../../predicates.js'; +import { breaking, type DiffRule, type Polarity, type Verdict } from '../../types.js'; + +/** + * A tightening rejects input the API used to accept, so it breaks a request; a + * loosening lets the API return something a consumer never handled, so it breaks + * a response. + */ +function verdictFor( + direction: ConstraintDirection, + polarity: Polarity, + message: string +): Verdict | undefined { + if (direction === 'tighter' && polarity === 'request') return breaking(message); + if (direction === 'looser' && polarity === 'response') return breaking(message); + return undefined; +} + +export const schemaTypeChanged: DiffRule = { + id: 'schema-type-changed', + description: + 'A narrower type rejects values that clients send. A wider type returns values that clients do not handle.', + visit(change, ctx) { + if (change.property !== 'type') return; + // `nullable: true` is 3.0's spelling of `type: [..., 'null']`, so both sides are + // read through the node itself rather than from the changed value alone. + const before = effectiveTypes(change.base?.value, ctx.base(change.pointer)?.scalars.nullable); + const after = effectiveTypes( + change.revision?.value, + ctx.revision(change.pointer)?.scalars.nullable + ); + const described = `from '${[...before].join(' | ')}' to '${[...after].join(' | ')}'`; + + if (ctx.polarity === 'request' && isTypeSetNarrowed(before, after)) { + return breaking(`Schema type narrowed ${described}.`); + } + if (ctx.polarity === 'response' && isTypeSetWidened(before, after)) { + return breaking(`Schema type widened ${described}.`); + } + return undefined; + }, +}; + +export const enumValuesRemoved: DiffRule = { + id: 'enum-values-removed', + description: 'Removing enum values restricts what clients may send.', + visit(change, ctx) { + if (change.property !== 'enum' || ctx.polarity !== 'request') return; + const removed = missingItems(change.base?.value, change.revision?.value); + if (removed.length) { + return breaking(`Enum values removed: ${removed.join(', ')}.`); + } + return undefined; + }, +}; + +export const enumValuesAdded: DiffRule = { + id: 'enum-values-added', + description: 'Adding enum values to response data may send clients values they never handled.', + visit(change, ctx) { + if (change.property !== 'enum' || ctx.polarity !== 'response') return; + const added = addedItems(change.base?.value, change.revision?.value); + if (added.length) { + return breaking(`Enum values added: ${added.join(', ')}.`); + } + return undefined; + }, +}; + +export const requiredPropertiesAdded: DiffRule = { + id: 'required-properties-added', + description: 'Requiring new request properties breaks clients that do not send them.', + visit(change, ctx) { + if (change.property !== 'required' || ctx.polarity !== 'request') return; + const added = addedItems(change.base?.value, change.revision?.value); + if (added.length) { + return breaking(`Properties became required: ${added.join(', ')}.`); + } + return undefined; + }, +}; + +export const requiredPropertiesRemoved: DiffRule = { + id: 'required-properties-removed', + description: + 'A response property that is no longer required can be absent, which breaks clients that read it.', + visit(change, ctx) { + if (change.property !== 'required' || ctx.polarity !== 'response') return; + const removed = missingItems(change.base?.value, change.revision?.value); + if (removed.length) { + return breaking(`Properties are no longer required: ${removed.join(', ')}.`); + } + return undefined; + }, +}; + +export const propertyRemovedFromResponse: DiffRule = { + id: 'property-removed-from-response', + description: 'Removing a response property breaks clients that read it.', + visit(change, ctx) { + if (change.kind !== 'removed' || ctx.polarity !== 'response') return; + // Only a member of a `properties` map counts; a subschema of `oneOf` does not. + const parentPointer = ctx.nodeAt(change.pointer)?.parentPointer; + if (!parentPointer || ctx.nodeAt(parentPointer)?.typeName !== 'SchemaProperties') return; + return breaking('Schema property was removed.'); + }, +}; + +function describeConstraint(property: string, before: unknown, after: unknown): string { + if (before === undefined) return `\`${property}\` was added with value '${after}'.`; + if (after === undefined) return `\`${property}\` was removed.`; + return `\`${property}\` changed from '${before}' to '${after}'.`; +} + +/** + * A rule over one group of constraints on a value: the direction the constraint + * moved in, together with the node's polarity, decides the verdict. The groups stay + * separate rules so a report can name the constraint that actually moved. + */ +function constraintRule(rule: { id: string; description: string; properties: string[] }): DiffRule { + const properties = new Set(rule.properties); + return { + id: rule.id, + description: rule.description, + visit(change, ctx) { + if (!change.property || !properties.has(change.property)) return; + const before = change.base?.value; + const after = change.revision?.value; + return verdictFor( + constraintDirection(change.property, before, after), + ctx.polarity, + describeConstraint(change.property, before, after) + ); + }, + }; +} + +export const numericRangeChanged = constraintRule({ + id: 'numeric-range-changed', + description: 'Moving a numeric bound changes which values the API accepts or returns.', + properties: ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf'], +}); + +export const stringLengthChanged = constraintRule({ + id: 'string-length-changed', + description: 'Changing a string constraint changes which values the API accepts or returns.', + properties: ['minLength', 'maxLength', 'pattern'], +}); + +export const schemaFormatChanged = constraintRule({ + id: 'schema-format-changed', + description: 'A format constrains the accepted values beyond the type itself.', + properties: ['format'], +}); + +export const additionalPropertiesChanged = constraintRule({ + id: 'additional-properties-changed', + description: 'The `additionalProperties` value decides which extra properties an object accepts.', + properties: ['additionalProperties'], +}); + +// `oneOf`/`anyOf` list alternatives, so dropping one accepts less; `allOf` combines +// constraints, so adding one accepts less. The key comes from the walker, which is +// why the combinator can be told apart without reading the pointer. +const ALTERNATIVE_COMBINATORS = new Set(['oneOf', 'anyOf']); + +export const schemaCombinatorChanged: DiffRule = { + id: 'schema-combinator-changed', + description: 'Adding or dropping a subschema changes which shapes the API accepts.', + visit(change, ctx) { + if (change.kind === 'changed') return; + + const parentPointer = ctx.nodeAt(change.pointer)?.parentPointer; + const parent = parentPointer ? ctx.nodeAt(parentPointer) : undefined; + if (parent?.typeName !== 'SchemaList') return; + + const combinator = String(parent.keyInParent); + if (combinator !== 'allOf' && !ALTERNATIVE_COMBINATORS.has(combinator)) return; + + const removed = change.kind === 'removed'; + const acceptsLess = combinator === 'allOf' ? !removed : removed; + + return verdictFor( + acceptsLess ? 'tighter' : 'looser', + ctx.polarity, + `A \`${combinator}\` subschema was ${removed ? 'removed' : 'added'}.` + ); + }, +}; + +/** + * Every rule over a `Schema` node, shared by the specification registries: an AsyncAPI + * payload is the same node type, judged by the same questions. + */ +export const schemaRules: DiffRule[] = [ + schemaTypeChanged, + enumValuesRemoved, + enumValuesAdded, + requiredPropertiesAdded, + requiredPropertiesRemoved, + propertyRemovedFromResponse, + numericRangeChanged, + stringLengthChanged, + schemaFormatChanged, + additionalPropertiesChanged, + schemaCombinatorChanged, +]; diff --git a/packages/cli/src/commands/diff/engine/classify/rules/security.ts b/packages/cli/src/commands/diff/engine/classify/rules/security.ts new file mode 100644 index 0000000000..b27ccd4518 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/rules/security.ts @@ -0,0 +1,77 @@ +import { addedItems } from '../../predicates.js'; +import { breaking, type DiffRule, type RawChange, type RuleContext } from '../../types.js'; + +// Security sits outside the request/response split, so these rules do not read +// the polarity: introducing authentication breaks every client either way. + +/** + * `security: []` states that no authentication is needed, so a first entry filling + * that list introduces it. An entry added to a list that already had one only offers + * one more way to authenticate, which no existing client has to follow. + */ +function fillsAnEmptyList(change: RawChange, ctx: RuleContext): boolean { + const parentPointer = ctx.nodeAt(change.pointer)?.parentPointer; + const baseList = parentPointer ? ctx.base(parentPointer)?.raw : undefined; + return Array.isArray(baseList) && baseList.length === 0; +} + +// Registered for both `SecurityRequirementList` and `SecurityRequirement`: a +// `security` list that appears where there was none lands on the list, and a first +// entry filling an empty list lands on the entry. +export const securityRequirementAdded: DiffRule = { + id: 'security-requirement-added', + description: 'Requiring authentication where there was none breaks every existing client.', + visit(change, ctx) { + if (change.kind !== 'added') return; + if (change.typeName !== 'SecurityRequirementList' && !fillsAnEmptyList(change, ctx)) return; + return breaking('The API now requires authentication.'); + }, +}; + +const SCHEME_IDENTITY = new Set([ + 'type', + 'scheme', + 'in', + 'name', + 'bearerFormat', + 'openIdConnectUrl', +]); + +export const securitySchemeChanged: DiffRule = { + id: 'security-scheme-changed', + description: 'Changing how a scheme authenticates breaks clients that implemented the old way.', + visit(change, ctx) { + if (!change.property || !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 = + ctx.base(change.pointer)?.scalars.type !== ctx.revision(change.pointer)?.scalars.type; + if (typeChanged && change.property !== 'type') return; + + return breaking( + `Security scheme \`${change.property}\` changed from '${change.base?.value}' to '${change.revision?.value}'.` + ); + }, +}; + +export const securitySchemeRemoved: DiffRule = { + id: 'security-scheme-removed', + description: 'Removing a scheme leaves clients with no way to authenticate through it.', + visit(change) { + if (change.kind !== 'removed') return; + return breaking('A security scheme was removed.'); + }, +}; + +export const securityScopesAdded: DiffRule = { + id: 'security-scopes-added', + description: 'A new required scope breaks clients whose credentials do not include it.', + visit(change) { + // A requirement's properties are scheme names, and each value is its scope list. + if (change.kind !== 'changed' || !change.property) return; + const added = addedItems(change.base?.value, change.revision?.value); + if (!added.length) return; + return breaking(`Scheme \`${change.property}\` requires new scopes: ${added.join(', ')}.`); + }, +}; diff --git a/packages/cli/src/commands/diff/engine/classify/rules/server.ts b/packages/cli/src/commands/diff/engine/classify/rules/server.ts new file mode 100644 index 0000000000..b47f8880ae --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/rules/server.ts @@ -0,0 +1,14 @@ +import { breaking, type DiffRule } from '../../types.js'; + +// Registered for both `Server` and `ServerMap`: dropping every server collapses into a +// single change on the map, dropping one lands on the server itself. +export const serverRemoved: DiffRule = { + id: 'server-removed', + description: 'Removing a server leaves clients connected to a host that no longer serves them.', + visit(change) { + if (change.kind !== 'removed') return; + return breaking( + change.typeName === 'ServerMap' ? 'Every server was removed.' : 'The server was removed.' + ); + }, +}; diff --git a/packages/cli/src/commands/diff/engine/classify/usage.ts b/packages/cli/src/commands/diff/engine/classify/usage.ts new file mode 100644 index 0000000000..707240367e --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/usage.ts @@ -0,0 +1,57 @@ +import type { Polarity } from '../types.js'; +import { ancestorChain, type NodeLookup } from './chain.js'; + +/** + * The pointer of the reusable component a node belongs to, or `undefined` when the + * node is not inside one. Found structurally: the type tree marks the container as + * `Components`, its children are the per-kind maps (`NamedSchemas`, …), and their + * children are the components themselves. + */ +export function getComponentRoot(pointer: string, lookup: NodeLookup): string | undefined { + const chain = ancestorChain(pointer, lookup); + const componentsIndex = chain.findIndex((entry) => entry.typeName === 'Components'); + return componentsIndex === -1 ? undefined : chain[componentsIndex + 2]?.pointer; +} + +export function mergePolarity(a: Polarity, b: Polarity): Polarity { + if (a === b) return a; + if (a === 'neutral') return b; + if (b === 'neutral') return a; + return 'both'; +} + +export class UsageIndex { + private sitesByTarget = new Map>(); + + constructor( + edges: Array<{ site: string; target: string }>, + private lookup: NodeLookup + ) { + for (const { site, target } of edges) { + const root = getComponentRoot(target, lookup) ?? target; + if (!this.sitesByTarget.has(root)) this.sitesByTarget.set(root, new Set()); + this.sitesByTarget.get(root)!.add(site); + } + } + + /** `resolveSitePolarity` receives the pointer of the node that holds the reference. */ + polarityOf(componentPointer: string, resolveSitePolarity: (site: string) => Polarity): Polarity { + const seen = new Set(); + const visit = (pointer: string): Polarity => { + if (seen.has(pointer)) return 'neutral'; // cycle guard + seen.add(pointer); + let result: Polarity = 'neutral'; + for (const site of this.sitesByTarget.get(pointer) ?? []) { + // a ref site inside another component chains to that component's own usage + const siteComponentRoot = getComponentRoot(site, this.lookup); + const sitePolarity = siteComponentRoot + ? visit(siteComponentRoot) + : resolveSitePolarity(site); + result = mergePolarity(result, sitePolarity); + if (result === 'both') return 'both'; + } + return result; + }; + return visit(getComponentRoot(componentPointer, this.lookup) ?? componentPointer); + } +} diff --git a/packages/cli/src/commands/diff/engine/collect.ts b/packages/cli/src/commands/diff/engine/collect.ts new file mode 100644 index 0000000000..714f832081 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/collect.ts @@ -0,0 +1,119 @@ +import { + isPlainObject, + isRef, + normalizeVisitors, + walkDocument, + type Config, + type Document, + type NormalizedNodeType, + type SpecVersion, + type UserContext, + type WalkContext, +} from '@redocly/openapi-core'; + +import { getIdentityKey } from './node-identity.js'; +import { isScalar, isScalarArray } from './predicates.js'; +import type { NodeEntry } from './types.js'; + +export interface CollectedDocument { + entries: Map; + usageEdges: Array<{ site: string; target: string }>; +} + +export function collectDocumentMap(opts: { + document: Document; + types: Record; + specVersion: SpecVersion; + config: Config; +}): CollectedDocument { + const { document, types, specVersion, config } = opts; + const entries = new Map(); + const usageEdges: Array<{ site: string; target: string }> = []; + // realPointer → stablePointer, filled top-down (walk is pre-order) + const stableByReal = new Map(); + const collisionCounts = new Map(); + + const visitor = { + any: { + enter(node: unknown, ctx: UserContext) { + if (!isPlainObject(node) && !Array.isArray(node)) return; + + const realPointer = ctx.location.pointer; + const { parentReal, segment } = splitPointer(realPointer); + const stableParent = + parentReal === null ? null : (stableByReal.get(parentReal) ?? parentReal); + + let stableSegment = segment; + if (Array.isArray(ctx.parent)) { + const identity = getIdentityKey(ctx.type.name, node); + if (identity !== undefined) stableSegment = identity; + } + + // The root's own pointer already ends in a slash, so a child of the root + // must not add a second one. + const stablePrefix = stableParent === '#/' ? '#' : stableParent; + let pointer = stablePrefix === null ? realPointer : `${stablePrefix}/${stableSegment}`; + + if (entries.has(pointer)) { + const occurrence = (collisionCounts.get(pointer) ?? 1) + 1; + collisionCounts.set(pointer, occurrence); + pointer = `${pointer}#${occurrence}`; + } + stableByReal.set(realPointer, pointer); + + const scalars: Record = {}; + const refs: Record = {}; + if (isPlainObject(node)) { + for (const [prop, value] of Object.entries(node)) { + if (isRef(value)) { + refs[prop] = value.$ref; + // The site is the node holding the reference, not the reference's own + // path: a `$ref` is not a node, so only the owner can be looked up later. + usageEdges.push({ site: pointer, target: value.$ref }); + } else if (isScalar(value) || isScalarArray(value)) { + scalars[prop] = value; + } + } + } + + entries.set(pointer, { + pointer, + realPointer, + parentPointer: stableParent, + keyInParent: ctx.key, + typeName: ctx.type.name, + scalars, + refs, + raw: node, + }); + }, + }, + }; + + const normalizedVisitors = normalizeVisitors( + [{ severity: 'warn', ruleId: 'diff-collect', visitor }], + types + ); + const ctx: WalkContext = { problems: [], specVersion, config, visitorsData: {} }; + + walkDocument({ + document, + rootType: types.Root, + normalizedVisitors, + // Empty map: $ref nodes fail to resolve and are NOT traversed — + // refs are recorded as node attributes above ($ref-as-scalar, spec §5.3). + resolvedRefMap: new Map(), + ctx, + }); + + return { entries, usageEdges }; +} + +function splitPointer(pointer: string): { parentReal: string | null; segment: string } { + if (pointer === '#/' || pointer === '#') { + return { parentReal: null, segment: pointer }; + } + const lastSlash = pointer.lastIndexOf('/'); + const parentReal = lastSlash <= 1 ? '#/' : pointer.slice(0, lastSlash); + return { parentReal, segment: pointer.slice(lastSlash + 1) }; +} diff --git a/packages/cli/src/commands/diff/engine/compare.ts b/packages/cli/src/commands/diff/engine/compare.ts new file mode 100644 index 0000000000..20ff50fc2a --- /dev/null +++ b/packages/cli/src/commands/diff/engine/compare.ts @@ -0,0 +1,93 @@ +import { dequal } from '@redocly/openapi-core'; + +import type { NodeEntry, RawChange } from './types.js'; + +function removalOf(pointer: string, entry: NodeEntry): RawChange { + return { + pointer, + kind: 'removed', + typeName: entry.typeName, + base: { pointer: entry.realPointer, value: entry.raw }, + }; +} + +function additionOf(pointer: string, entry: NodeEntry): RawChange { + return { + pointer, + kind: 'added', + typeName: entry.typeName, + revision: { pointer: entry.realPointer, value: entry.raw }, + }; +} + +export function compareMaps( + base: Map, + revision: Map +): RawChange[] { + const changes: RawChange[] = []; + const keys = new Set([...base.keys(), ...revision.keys()]); + + // Pass 1: boundary nodes — added roots, removed roots, replaced (typeName differs). + const boundaries = new Set(); + for (const key of keys) { + const baseEntry = base.get(key); + const revisionEntry = revision.get(key); + if (!baseEntry || !revisionEntry || baseEntry.typeName !== revisionEntry.typeName) { + boundaries.add(key); + } + } + + const getEntry = (key: string) => base.get(key) ?? revision.get(key); + + const hasBoundaryAncestor = (key: string): boolean => { + let parent = getEntry(key)?.parentPointer ?? null; + while (parent !== null) { + if (boundaries.has(parent)) return true; + parent = getEntry(parent)?.parentPointer ?? null; + } + return false; + }; + + // Pass 2: emission, in deterministic pointer order. + for (const key of [...keys].sort()) { + if (hasBoundaryAncestor(key)) continue; // implied by a reported ancestor + const baseEntry = base.get(key); + const revisionEntry = revision.get(key); + + if (!baseEntry || !revisionEntry) { + // Present on one side only, so the whole node was added or removed. + if (baseEntry) changes.push(removalOf(key, baseEntry)); + if (revisionEntry) changes.push(additionOf(key, revisionEntry)); + } else if (baseEntry.typeName !== revisionEntry.typeName) { + // replaced → a removed+added pair at the same pointer + changes.push(removalOf(key, baseEntry), additionOf(key, revisionEntry)); + } else { + const properties = new Set([ + ...Object.keys(baseEntry.scalars), + ...Object.keys(baseEntry.refs), + ...Object.keys(revisionEntry.scalars), + ...Object.keys(revisionEntry.refs), + ]); + for (const property of [...properties].sort()) { + const before = + property in baseEntry.refs ? baseEntry.refs[property] : baseEntry.scalars[property]; + const after = + property in revisionEntry.refs + ? revisionEntry.refs[property] + : revisionEntry.scalars[property]; + if (!dequal(before, after)) { + changes.push({ + pointer: key, + property, + kind: 'changed', + typeName: baseEntry.typeName, + base: { pointer: `${baseEntry.realPointer}/${property}`, value: before }, + revision: { pointer: `${revisionEntry.realPointer}/${property}`, value: after }, + }); + } + } + } + } + + return changes; +} diff --git a/packages/cli/src/commands/diff/engine/index.ts b/packages/cli/src/commands/diff/engine/index.ts new file mode 100644 index 0000000000..d035f0103a --- /dev/null +++ b/packages/cli/src/commands/diff/engine/index.ts @@ -0,0 +1,106 @@ +import { + detectSpec, + getMajorSpecVersion, + getTypes, + normalizeTypes, + type Config, + type Document, + type SpecVersion, +} from '@redocly/openapi-core'; + +import { alignRenamedPaths, type PathRename } from './align-paths.js'; +import { classifyChanges } from './classify/index.js'; +import { UsageIndex } from './classify/usage.js'; +import { collectDocumentMap } from './collect.js'; +import { compareMaps } from './compare.js'; +import { locateChanges } from './locate.js'; +import type { DiffResult, DiffSummary, RawChange } from './types.js'; + +export class DiffError extends Error {} + +// The path template itself is a map key, not a node property, so the rename is +// surfaced as a synthetic 'changed' on the PathItem with property 'path'. +function toRenameChange(rename: PathRename): RawChange { + return { + pointer: rename.basePointer, + property: 'path', + kind: 'changed', + typeName: 'PathItem', + base: { pointer: rename.baseRealPointer, value: rename.baseTemplate }, + revision: { pointer: rename.revisionRealPointer, value: rename.revisionTemplate }, + }; +} + +export function diffDocuments(opts: { + base: Document; + revision: Document; + config: Config; +}): DiffResult { + const { base, revision, config } = opts; + + const baseVersion = detectSpec(base.parsed); + const revisionVersion = detectSpec(revision.parsed); + if (getMajorSpecVersion(baseVersion) !== getMajorSpecVersion(revisionVersion)) { + throw new DiffError( + `The base and the revision use different specification families: '${baseVersion}' and '${revisionVersion}'. The diff command compares documents of one family only.` + ); + } + + // Each side is collected with ITS OWN type tree (spec §5.6). + const collect = (document: Document, specVersion: SpecVersion) => + collectDocumentMap({ + document, + types: normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config), + specVersion, + config, + }); + + const baseCollected = collect(base, baseVersion); + const revisionCollected = collect(revision, revisionVersion); + + const { revision: alignedRevision, renames } = alignRenamedPaths( + baseCollected.entries, + revisionCollected.entries + ); + + const rawChanges = [ + ...renames.map(toRenameChange), + ...compareMaps(baseCollected.entries, alignedRevision), + ]; + // usage edges are NOT rewritten: polarity reads node types along the ancestor + // chain and component roots, neither of which a path rename alters + const nodeAt = (pointer: string) => + alignedRevision.get(pointer) ?? baseCollected.entries.get(pointer); + const usage = new UsageIndex( + [...baseCollected.usageEdges, ...revisionCollected.usageEdges], + nodeAt + ); + + const changes = locateChanges( + classifyChanges({ + changes: rawChanges, + specVersion: revisionVersion, + base: baseCollected.entries, + revision: alignedRevision, + usage, + }), + base.source, + revision.source + ); + + const summary = changes.reduce( + (acc, change) => { + if (change.compat === 'breaking') acc.breaking++; + else acc.nonBreaking++; + return acc; + }, + { breaking: 0, nonBreaking: 0 } + ); + + return { + version: '1', + specVersions: { base: baseVersion, revision: revisionVersion }, + summary, + changes, + }; +} diff --git a/packages/cli/src/commands/diff/engine/locate.ts b/packages/cli/src/commands/diff/engine/locate.ts new file mode 100644 index 0000000000..b20d26c8f8 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/locate.ts @@ -0,0 +1,22 @@ +import { getLineColLocation, type Source } from '@redocly/openapi-core'; + +import type { Change, ChangeSide } from './types.js'; + +// Nodes inlined by bundling do not exist in the root source AST; +// getLineColLocation falls back to 1:1 for such pointers. +function locateSide(side: ChangeSide, source: Source): ChangeSide { + const { start } = getLineColLocation({ source, pointer: side.pointer, reportOnKey: false }); + return { ...side, file: source.absoluteRef, line: start.line, col: start.col }; +} + +export function locateChanges( + changes: Change[], + baseSource: Source, + revisionSource: Source +): Change[] { + return changes.map((change) => ({ + ...change, + ...(change.base ? { base: locateSide(change.base, baseSource) } : {}), + ...(change.revision ? { revision: locateSide(change.revision, revisionSource) } : {}), + })); +} diff --git a/packages/cli/src/commands/diff/engine/node-identity.ts b/packages/cli/src/commands/diff/engine/node-identity.ts new file mode 100644 index 0000000000..9a2828ed14 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/node-identity.ts @@ -0,0 +1,26 @@ +import { isPlainObject } from '@redocly/openapi-core'; + +// JSON Pointer escaping for identity-key content: keys become pointer segments. +export function escapeIdentityKeyPart(value: string): string { + return value.replace(/~/g, '~0').replace(/\//g, '~1'); +} + +type IdentityKeyFn = (value: Record) => string | undefined; + +// Identity keys for list items that have a natural identity. +// Everything else falls back to positional matching (see spec §5.2). +const IDENTITY_KEYS: Record = { + Parameter: (v) => + typeof v.in === 'string' && typeof v.name === 'string' + ? `{${escapeIdentityKeyPart(v.in)}:${escapeIdentityKeyPart(v.name)}}` + : undefined, + Server: (v) => (typeof v.url === 'string' ? `{${escapeIdentityKeyPart(v.url)}}` : undefined), + Tag: (v) => (typeof v.name === 'string' ? `{${escapeIdentityKeyPart(v.name)}}` : undefined), + SecurityRequirement: (v) => `{${Object.keys(v).sort().map(escapeIdentityKeyPart).join('+')}}`, +}; + +export function getIdentityKey(typeName: string, value: unknown): string | undefined { + const keyFn = IDENTITY_KEYS[typeName]; + if (!keyFn || !isPlainObject(value)) return undefined; + return keyFn(value as Record); +} diff --git a/packages/cli/src/commands/diff/engine/predicates.ts b/packages/cli/src/commands/diff/engine/predicates.ts new file mode 100644 index 0000000000..9fd9691911 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/predicates.ts @@ -0,0 +1,115 @@ +export function isScalar(value: unknown): boolean { + return ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ); +} + +// An empty array carries no scalars to compare and is walked as a node in its own +// right, so treating it as a scalar too would report the same change twice. +export function isScalarArray(value: unknown): boolean { + return Array.isArray(value) && value.length > 0 && value.every(isScalar); +} + +export function missingItems(before: unknown, after: unknown): unknown[] { + if (!Array.isArray(before)) return []; + const afterItems = Array.isArray(after) ? after : []; + return before.filter((item) => !afterItems.includes(item)); +} + +export function addedItems(before: unknown, after: unknown): unknown[] { + return missingItems(after, before); +} + +export function becameTrue(before: unknown, after: unknown): boolean { + return before !== true && after === true; +} + +// `integer` accepts a subset of what `number` does, so it is the one implicit +// widening among the JSON Schema primitive types. +const WIDER_TYPE: Record = { integer: 'number' }; + +/** + * The set of types a schema accepts, folding OpenAPI 3.0's `nullable: true` into + * the 3.1 spelling (`type: [..., 'null']`) so the two compare as equal. + */ +export function effectiveTypes(type: unknown, nullable?: unknown): Set { + const declared = Array.isArray(type) ? type : type === undefined ? [] : [type]; + const types = new Set(declared.filter((value): value is string => typeof value === 'string')); + if (nullable === true) types.add('null'); + return types; +} + +function accepts(types: Set, type: string): boolean { + const wider = WIDER_TYPE[type]; + return types.has(type) || (wider !== undefined && types.has(wider)); +} + +/** Some type the base accepted is no longer accepted. */ +export function isTypeSetNarrowed(before: Set, after: Set): boolean { + if (!before.size || !after.size) return false; // an absent `type` accepts anything + return [...before].some((type) => !accepts(after, type)); +} + +/** The revision accepts some type the base did not. */ +export function isTypeSetWidened(before: Set, after: Set): boolean { + if (!before.size || !after.size) return false; + return [...after].some((type) => !accepts(before, type)); +} + +/** + * Which way a constraint moved. `tighter` means the schema now accepts less, + * which breaks a request; `looser` means it accepts more, which breaks a response. + */ +export type ConstraintDirection = 'tighter' | 'looser' | 'same'; + +const TIGHTER_WHEN_RAISED = new Set([ + 'minimum', + 'exclusiveMinimum', + 'minLength', + 'minItems', + 'minProperties', +]); +const TIGHTER_WHEN_LOWERED = new Set([ + 'maximum', + 'exclusiveMaximum', + 'maxLength', + 'maxItems', + 'maxProperties', +]); +// Equivalence of these cannot be decided by comparing values, so any change to +// one is treated as a tightening rather than guessed at. +const OPAQUE = new Set(['pattern', 'format', 'multipleOf']); + +export function constraintDirection( + property: string, + before: unknown, + after: unknown +): ConstraintDirection { + if (before === after) return 'same'; + if (before === undefined) return 'tighter'; // a new constraint + if (after === undefined) return 'looser'; // one dropped + + if (property === 'additionalProperties') { + if (before === true && after === false) return 'tighter'; + if (before === false && after === true) return 'looser'; + return 'tighter'; // swapped for a schema: narrower than an open object + } + + if (OPAQUE.has(property)) return 'tighter'; + + if (typeof before === 'number' && typeof after === 'number') { + const raised = after > before; + if (TIGHTER_WHEN_RAISED.has(property)) return raised ? 'tighter' : 'looser'; + if (TIGHTER_WHEN_LOWERED.has(property)) return raised ? 'looser' : 'tighter'; + } + + // `exclusiveMinimum`/`exclusiveMaximum` are booleans in OpenAPI 3.0. + if (typeof before === 'boolean' && typeof after === 'boolean') { + return after ? 'tighter' : 'looser'; + } + + return 'tighter'; +} diff --git a/packages/cli/src/commands/diff/engine/types.ts b/packages/cli/src/commands/diff/engine/types.ts new file mode 100644 index 0000000000..59006e847a --- /dev/null +++ b/packages/cli/src/commands/diff/engine/types.ts @@ -0,0 +1,88 @@ +import type { SpecVersion } from '@redocly/openapi-core'; + +export type Compat = 'breaking' | 'non-breaking'; + +export type ChangeKind = 'added' | 'removed' | 'changed'; + +export interface NodeEntry { + pointer: string; // stable matching key, e.g. '#/paths/~1pets/get/parameters/{query:limit}' + realPointer: string; // actual JSON Pointer in THIS document, e.g. '#/paths/~1pets/get/parameters/1' + parentPointer: string | null; // stable pointer of the parent node + keyInParent: string | number; // the walker's own key, e.g. 'oneOf' for a combinator list + typeName: string; // from this side's type tree + scalars: Record; // shallow primitives and arrays of primitives (enum, required, ...) + refs: Record; // $ref-valued properties, recorded as attributes (not followed) + raw: unknown; // the raw node value — payload for added/removed changes +} + +export interface ChangeSide { + pointer: string; // real JSON Pointer in this document + file?: string; // absoluteRef of the side's document — filled by locateChanges() + line?: number; // 1-based — filled by locateChanges() + col?: number; // 1-based — filled by locateChanges() + value?: unknown; +} + +export interface Change { + pointer: string; // stable node pointer — the change's identity + property?: string; // set for property-level changes + kind: ChangeKind; + typeName: string; + base?: ChangeSide; // absent for added + revision?: ChangeSide; // absent for removed + compat: Compat; // worst verdict's level; 'non-breaking' when no rule fired + verdicts?: ChangeVerdict[]; // every rule verdict, worst-first +} + +// What compare() emits — classification fields are filled later by classify(). +export type RawChange = Omit; + +export interface DiffSummary { + breaking: number; + nonBreaking: number; +} + +export interface DiffResult { + version: '1'; + specVersions: { base: SpecVersion; revision: SpecVersion }; + summary: DiffSummary; + changes: Change[]; +} + +export interface Verdict { + compat: Compat; + message: string; +} + +export interface ChangeVerdict extends Verdict { + ruleId: string; +} + +export type Polarity = 'request' | 'response' | 'both' | 'neutral'; + +export interface RuleContext { + polarity: Polarity; + specVersion: SpecVersion; + base: (pointer: string) => NodeEntry | undefined; + revision: (pointer: string) => NodeEntry | undefined; + /** Either side, revision first — for reading a node's own type or its ancestors. */ + nodeAt: (pointer: string) => NodeEntry | undefined; +} + +export interface DiffRule { + id: string; + description: string; + visit(change: RawChange, ctx: RuleContext): Verdict | undefined; +} + +export type DiffRuleRegistry = Record; + +const COMPAT_RANK: Record = { breaking: 1, 'non-breaking': 0 }; + +export function compatRank(compat: Compat): number { + return COMPAT_RANK[compat]; +} + +export function breaking(message: string): Verdict { + return { compat: 'breaking', message }; +} 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..77cbc0f12e --- /dev/null +++ b/packages/cli/src/commands/diff/fail-on.ts @@ -0,0 +1,15 @@ +import { pluralize } from '@redocly/openapi-core'; + +import type { DiffSummary } from './engine/types.js'; + +export type DiffFailOn = 'breaking' | 'none'; + +export function getDiffFailure(summary: DiffSummary, failOn: DiffFailOn): string | undefined { + if (failOn === 'breaking' && summary.breaking > 0) { + return `❌ Diff failed with ${summary.breaking} breaking ${pluralize( + 'change', + summary.breaking + )}.`; + } + return undefined; +} diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts new file mode 100644 index 0000000000..20fd0193d5 --- /dev/null +++ b/packages/cli/src/commands/diff/index.ts @@ -0,0 +1,113 @@ +import { + bundle, + formatProblems, + getTotals, + logger, + type OutputFormat, +} from '@redocly/openapi-core'; +import { writeFileSync } from 'node:fs'; + +import type { VerifyConfigOptions } from '../../types.js'; +import { AbortFlowError, exitWithError } from '../../utils/error.js'; +import { getFallbackApisOrExit, printExecutionTime } from '../../utils/miscellaneous.js'; +import type { CommandArgs } from '../../wrapper.js'; +import { DiffError, diffDocuments } from './engine/index.js'; +import type { DiffResult } from './engine/types.js'; +import { getDiffFailure, type DiffFailOn } from './fail-on.js'; +import { htmlDiff } from './serializers/html.js'; +import { jsonDiff } from './serializers/json.js'; +import { markdownDiff } from './serializers/markdown.js'; +import { breakingChangesToProblems } from './serializers/problems.js'; +import { stylishDiff } from './serializers/stylish.js'; + +/** + * Formats rendered by this command from the full DiffResult. `html` is ours alone, + * so this union is spelled out rather than extracted from core's lint formats. + */ +export type DiffOwnFormat = 'stylish' | 'json' | 'markdown' | 'html'; + +/** + * Formats delegated to core's lint formatters. They describe breaking changes + * only, because a lint problem always carries a severity (see problems.ts). + */ +export type DiffProblemFormat = Extract< + OutputFormat, + 'codeframe' | 'checkstyle' | 'codeclimate' | 'summary' | 'github-actions' | 'junit' +>; + +export type DiffOutputFormat = DiffOwnFormat | DiffProblemFormat; +export type { DiffFailOn }; + +export type DiffArgv = { + base: string; + revision: string; + format: DiffOutputFormat; + output?: string; + 'fail-on': DiffFailOn; +} & VerifyConfigOptions; + +const SERIALIZERS: Record string> = { + stylish: stylishDiff, + json: jsonDiff, + markdown: markdownDiff, + html: htmlDiff, +}; + +function isOwnFormat(format: DiffOutputFormat): format is DiffOwnFormat { + return format in SERIALIZERS; +} + +export async function handleDiff({ argv, config, collectSpecData }: CommandArgs) { + if (argv.output && !isOwnFormat(argv.format)) { + return exitWithError( + `The ${argv.format} format prints to stdout only. To write a report to a file, use one of these formats: ${Object.keys(SERIALIZERS).join(', ')}.` + ); + } + + const startedAt = performance.now(); + const [{ path: basePath }] = await getFallbackApisOrExit([argv.base], config); + const [{ path: revisionPath }] = await getFallbackApisOrExit([argv.revision], config); + + const { bundle: baseDocument } = await bundle({ config, ref: basePath }); + const { bundle: revisionDocument } = await bundle({ config, ref: revisionPath }); + collectSpecData?.(revisionDocument); + + let result: DiffResult; + try { + result = diffDocuments({ base: baseDocument, revision: revisionDocument, config }); + } catch (error) { + if (error instanceof DiffError) { + return exitWithError(error.message); + } + throw error; + } + + if (isOwnFormat(argv.format)) { + const output = SERIALIZERS[argv.format](result); + if (argv.output) { + writeFileSync(argv.output, output); + logger.info(`Diff report written to ${argv.output}.\n`); + } else { + logger.output(output + '\n'); + } + } else { + const problems = breakingChangesToProblems( + result, + baseDocument.source, + revisionDocument.source + ); + formatProblems(problems, { + format: argv.format, + totals: getTotals(problems), + maxProblems: problems.length, + }); + } + + printExecutionTime('diff', startedAt, `${basePath} vs ${revisionPath}`); + + const failure = getDiffFailure(result.summary, argv['fail-on']); + if (failure) { + logger.error(`${failure}\n`); + throw new AbortFlowError('Diff failed.'); + } +} diff --git a/packages/cli/src/commands/diff/serializers/change-side.ts b/packages/cli/src/commands/diff/serializers/change-side.ts new file mode 100644 index 0000000000..2dddcf498d --- /dev/null +++ b/packages/cli/src/commands/diff/serializers/change-side.ts @@ -0,0 +1,9 @@ +import type { Change, ChangeSide } from '../engine/types.js'; + +// The side shown to the user: what was removed lives in the base document, +// everything else is best inspected in the revision. +export function displaySide(change: Change): ChangeSide | undefined { + return change.kind === 'removed' + ? (change.base ?? change.revision) + : (change.revision ?? change.base); +} diff --git a/packages/cli/src/commands/diff/serializers/html.ts b/packages/cli/src/commands/diff/serializers/html.ts new file mode 100644 index 0000000000..5830dfd796 --- /dev/null +++ b/packages/cli/src/commands/diff/serializers/html.ts @@ -0,0 +1,75 @@ +import type { Change, DiffResult } from '../engine/types.js'; + +function escapeHtml(value: unknown): string { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +const IMPACT_CLASS: Record = { + breaking: 'breaking', + 'non-breaking': 'ok', +}; + +function renderChange(change: Change): string { + const location = change.property ? `${change.pointer} · ${change.property}` : change.pointer; + const payload = { + ...(change.base ? { base: change.base } : {}), + ...(change.revision ? { revision: change.revision } : {}), + }; + return ` +
+ + ${escapeHtml(change.compat)} + ${escapeHtml(change.kind)} + ${escapeHtml(location)} + ${(change.verdicts ?? []) + .map( + (v) => + `${escapeHtml(v.message)} ${escapeHtml( + v.ruleId + )}` + ) + .join(' ')} + +
${escapeHtml(JSON.stringify(payload, null, 2))}
+
`; +} + +export function htmlDiff(result: DiffResult): string { + const { breaking, nonBreaking } = result.summary; + return ` + + + +API diff + + + +

API diff

+

+ ${breaking} breaking + ${nonBreaking} non-breaking + ${escapeHtml(result.specVersions.base)} → ${escapeHtml( + result.specVersions.revision + )} +

+${result.changes.map(renderChange).join('\n')} + +`; +} diff --git a/packages/cli/src/commands/diff/serializers/json.ts b/packages/cli/src/commands/diff/serializers/json.ts new file mode 100644 index 0000000000..acbad38f47 --- /dev/null +++ b/packages/cli/src/commands/diff/serializers/json.ts @@ -0,0 +1,5 @@ +import type { DiffResult } from '../engine/types.js'; + +export function jsonDiff(result: DiffResult): string { + return JSON.stringify(result, null, 2); +} diff --git a/packages/cli/src/commands/diff/serializers/markdown.ts b/packages/cli/src/commands/diff/serializers/markdown.ts new file mode 100644 index 0000000000..243b22581f --- /dev/null +++ b/packages/cli/src/commands/diff/serializers/markdown.ts @@ -0,0 +1,42 @@ +import type { Change, DiffResult } from '../engine/types.js'; + +const IMPACT_LABEL: Record = { + breaking: '🔴 breaking', + 'non-breaking': '🟢 non-breaking', +}; + +// 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 + .replace(/\|/g, '\\|') + .replace(/`/g, '\\`') + .replace(/[\r\n]+/g, ' '); +} + +export function markdownDiff(result: DiffResult): string { + const { breaking, nonBreaking } = result.summary; + const lines = [ + '## API diff', + '', + `**${breaking}** breaking · **${nonBreaking}** non-breaking`, + '', + '| Impact | Change | Location | Details |', + '| --- | --- | --- | --- |', + ]; + + for (const change of result.changes) { + const location = change.property ? `${change.pointer} · ${change.property}` : change.pointer; + // 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_LABEL[change.compat]} | ${change.kind} | \`${escapeCell(location)}\` | ${details} |` + ); + } + + return lines.join('\n'); +} diff --git a/packages/cli/src/commands/diff/serializers/problems.ts b/packages/cli/src/commands/diff/serializers/problems.ts new file mode 100644 index 0000000000..55b6ce2436 --- /dev/null +++ b/packages/cli/src/commands/diff/serializers/problems.ts @@ -0,0 +1,37 @@ +import type { NormalizedProblem, Source } from '@redocly/openapi-core'; + +import type { DiffResult } from '../engine/types.js'; +import { displaySide } from './change-side.js'; + +// Lint's problem model describes defects carrying a severity, so only breaking +// changes map onto it — the complete change list stays in the `json` format. +// This is what lets the diff report reuse the lint formatters in core +// (github-actions, checkstyle, codeclimate, junit, summary, codeframe). +export function breakingChangesToProblems( + result: DiffResult, + baseSource: Source, + revisionSource: Source +): NormalizedProblem[] { + return result.changes + .filter((change) => change.compat === 'breaking') + .map((change) => { + const side = displaySide(change); + const source = side === change.base ? baseSource : revisionSource; + + // verdicts are worst-first, so the first one carries the breaking verdict. + const verdict = change.verdicts?.[0]; + + return { + message: verdict?.message ?? `${change.kind} ${change.pointer}`, + ruleId: verdict?.ruleId ?? 'diff', + severity: 'error' as const, + location: side ? [{ source, pointer: side.pointer }] : [], + // Point at the counterpart in the other document, so formats that render + // a `from` location show both sides of the change. + ...(change.base && change.revision && side !== change.base + ? { from: { source: baseSource, pointer: change.base.pointer } } + : {}), + suggest: [], + }; + }); +} diff --git a/packages/cli/src/commands/diff/serializers/stylish.ts b/packages/cli/src/commands/diff/serializers/stylish.ts new file mode 100644 index 0000000000..3fd04de96d --- /dev/null +++ b/packages/cli/src/commands/diff/serializers/stylish.ts @@ -0,0 +1,97 @@ +import { isAbsoluteUrl, unescapePointerFragment } from '@redocly/openapi-core'; +import { blue, bold, gray, green, red } from 'colorette'; +import * as path from 'node:path'; + +import { compatRank, type Change, type Compat, type DiffResult } from '../engine/types.js'; +import { displaySide } from './change-side.js'; + +const ICONS: Record = { + breaking: red('✖ breaking '), + 'non-breaking': green('✔ non-breaking'), +}; + +const HTTP_METHODS = new Set([ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', + 'query', +]); + +// Identity keys escape '/' (node-identity.ts), so plain splitting is safe. +function segmentsOf(pointer: string): string[] { + return pointer.replace(/^#\//, '').split('/'); +} + +function groupOf(change: Change): string { + const segments = segmentsOf(displaySide(change)?.pointer ?? change.pointer); + if (segments[0] === 'paths' && segments.length > 1) { + const pathKey = unescapePointerFragment(segments[1]); + const method = segments[2]; + return method && HTTP_METHODS.has(method) ? `${method.toUpperCase()} ${pathKey}` : pathKey; + } + return segments[0] || 'document'; +} + +// The group heading already says which operation this is, so the label starts after +// `paths//`. +function labelSegments(segments: string[]): string[] { + if (segments[0] !== 'paths') return segments; + const underOperation = segments.length > 2 && HTTP_METHODS.has(segments[2]); + return segments.slice(underOperation ? 3 : 2); +} + +function labelOf(change: Change): string { + const segments = segmentsOf(change.pointer); + const named = labelSegments(segments); + // A change on the operation itself leaves nothing after the prefix, so the whole + // pointer is shown instead — there each segment is unescaped, so `~1pets` reads as + // the path `/pets` rather than as one more separator. + const label = named.length ? named.join('/') : segments.map(unescapePointerFragment).join(' · '); + + if (!label) return change.property ?? change.pointer; + return change.property ? `${label} · ${change.property}` : label; +} + +function locationOf(change: Change, cwd: string): string | undefined { + const side = displaySide(change); + if (!side?.file) return undefined; + const file = isAbsoluteUrl(side.file) ? side.file : path.relative(cwd, side.file); + return `${file}:${side.line}:${side.col}`; +} + +export function stylishDiff(result: DiffResult): string { + const cwd = process.cwd(); + const groups = new Map(); + for (const change of result.changes) { + const key = groupOf(change); + const group = groups.get(key) ?? []; + group.push(change); + groups.set(key, group); + } + + const lines: string[] = []; + for (const [key, changes] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) { + lines.push(bold(blue(key))); + const sorted = [...changes].sort( + (a, b) => compatRank(b.compat) - compatRank(a.compat) || a.pointer.localeCompare(b.pointer) + ); + for (const change of sorted) { + lines.push(` ${ICONS[change.compat]} ${bold(change.kind)} ${labelOf(change)}`); + for (const verdict of change.verdicts ?? []) { + lines.push(gray(` ${verdict.message} (${verdict.ruleId})`)); + } + const location = locationOf(change, cwd); + if (location) lines.push(gray(` at ${location}`)); + } + lines.push(''); + } + + const { breaking, nonBreaking } = result.summary; + lines.push(`${red(`${breaking} breaking`)}, ${green(`${nonBreaking} non-breaking`)}.`); + return lines.join('\n'); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 894cb5d568..cc29ff6e94 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -15,6 +15,7 @@ import { hideBin } from 'yargs/helpers'; import { handleLogin, handleLogout } from './commands/auth.js'; import type { BuildDocsArgv } from './commands/build-docs/types.js'; import { handleBundle } from './commands/bundle.js'; +import { handleDiff, type DiffArgv, type DiffOutputFormat } from './commands/diff/index.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'; @@ -87,6 +88,52 @@ yargs(hideBin(process.argv)) commandWrapper(handleStats)(argv); } ) + .command( + 'diff ', + 'Compare two API descriptions and detect breaking changes [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', + 'codeframe', + 'checkstyle', + 'codeclimate', + 'summary', + 'github-actions', + 'junit', + ] 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 level are found.', + choices: ['breaking', 'none'] as ReadonlyArray<'breaking' | 'none'>, + default: 'breaking' as const, + }, + }), + (argv) => { + commandWrapper(handleDiff)(argv as Arguments); + } + ) .command( 'score [api]', 'Score an API description for integration simplicity and agent readiness.', diff --git a/tests/e2e/diff/async3-channel-address-changed/base.yaml b/tests/e2e/diff/async3-channel-address-changed/base.yaml new file mode 100644 index 0000000000..6a91e5a603 --- /dev/null +++ b/tests/e2e/diff/async3-channel-address-changed/base.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-channel-address-changed + version: '1.0' +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + properties: + id: + type: string + plan: + type: string +operations: + onUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-channel-address-changed/revision.yaml b/tests/e2e/diff/async3-channel-address-changed/revision.yaml new file mode 100644 index 0000000000..b4f404c2cd --- /dev/null +++ b/tests/e2e/diff/async3-channel-address-changed/revision.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-channel-address-changed + version: '1.0' +channels: + userSignedUp: + address: user/signed-up + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + properties: + id: + type: string + plan: + type: string +operations: + onUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-channel-address-changed/snapshot.txt b/tests/e2e/diff/async3-channel-address-changed/snapshot.txt new file mode 100644 index 0000000000..b3998baef0 --- /dev/null +++ b/tests/e2e/diff/async3-channel-address-changed/snapshot.txt @@ -0,0 +1,11 @@ +channels + ✖ breaking changed channels/userSignedUp · address + The channel address changed from 'user/signedup' to 'user/signed-up'. (channel-address-changed) + at revision.yaml:7:14 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/async3-channel-removed/base.yaml b/tests/e2e/diff/async3-channel-removed/base.yaml new file mode 100644 index 0000000000..30c1077f53 --- /dev/null +++ b/tests/e2e/diff/async3-channel-removed/base.yaml @@ -0,0 +1,36 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-channel-removed + version: '1.0' +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + properties: + id: + type: string + plan: + type: string + userDeleted: + address: user/deleted + messages: + deleted: + payload: + type: object +operations: + onUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' + onUserDeleted: + action: receive + channel: + $ref: '#/channels/userDeleted' diff --git a/tests/e2e/diff/async3-channel-removed/revision.yaml b/tests/e2e/diff/async3-channel-removed/revision.yaml new file mode 100644 index 0000000000..63b85d9f7f --- /dev/null +++ b/tests/e2e/diff/async3-channel-removed/revision.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-channel-removed + version: '1.0' +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + properties: + id: + type: string + plan: + type: string +operations: + onUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-channel-removed/snapshot.txt b/tests/e2e/diff/async3-channel-removed/snapshot.txt new file mode 100644 index 0000000000..ed90427a63 --- /dev/null +++ b/tests/e2e/diff/async3-channel-removed/snapshot.txt @@ -0,0 +1,16 @@ +channels + ✖ breaking removed channels/userDeleted + The channel was removed. (channel-removed) + at base.yaml:21:5 + +operations + ✖ breaking removed operations/onUserDeleted + Operation was removed. (operation-removed) + at base.yaml:34:5 + +2 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 2 breaking changes. diff --git a/tests/e2e/diff/async3-message-content-type-changed/base.yaml b/tests/e2e/diff/async3-message-content-type-changed/base.yaml new file mode 100644 index 0000000000..0eb9420dae --- /dev/null +++ b/tests/e2e/diff/async3-message-content-type-changed/base.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-message-content-type-changed + version: '1.0' +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + properties: + id: + type: string + plan: + type: string +operations: + onUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-message-content-type-changed/revision.yaml b/tests/e2e/diff/async3-message-content-type-changed/revision.yaml new file mode 100644 index 0000000000..984e2d39e3 --- /dev/null +++ b/tests/e2e/diff/async3-message-content-type-changed/revision.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-message-content-type-changed + version: '1.0' +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/avro + payload: + type: object + required: + - id + properties: + id: + type: string + plan: + type: string +operations: + onUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt b/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt new file mode 100644 index 0000000000..f030f429dd --- /dev/null +++ b/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt @@ -0,0 +1,11 @@ +channels + ✖ breaking changed channels/userSignedUp/messages/signup · contentType + The message content type changed from 'application/json' to 'application/avro'. (message-content-type-changed) + at revision.yaml:10:22 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/async3-message-removed/base.yaml b/tests/e2e/diff/async3-message-removed/base.yaml new file mode 100644 index 0000000000..1987ea964d --- /dev/null +++ b/tests/e2e/diff/async3-message-removed/base.yaml @@ -0,0 +1,29 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-message-removed + version: '1.0' +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + properties: + id: + type: string + plan: + type: string + signout: + payload: + type: object +operations: + onUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-message-removed/revision.yaml b/tests/e2e/diff/async3-message-removed/revision.yaml new file mode 100644 index 0000000000..e8d79ec952 --- /dev/null +++ b/tests/e2e/diff/async3-message-removed/revision.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-message-removed + version: '1.0' +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + properties: + id: + type: string + plan: + type: string +operations: + onUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-message-removed/snapshot.txt b/tests/e2e/diff/async3-message-removed/snapshot.txt new file mode 100644 index 0000000000..c42a726004 --- /dev/null +++ b/tests/e2e/diff/async3-message-removed/snapshot.txt @@ -0,0 +1,11 @@ +channels + ✖ breaking removed channels/userSignedUp/messages/signout + The message was removed. (message-removed) + at base.yaml:21:9 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/async3-operation-action-changed/base.yaml b/tests/e2e/diff/async3-operation-action-changed/base.yaml new file mode 100644 index 0000000000..9a3518e7d3 --- /dev/null +++ b/tests/e2e/diff/async3-operation-action-changed/base.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-operation-action-changed + version: '1.0' +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + properties: + id: + type: string + plan: + type: string +operations: + onUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-operation-action-changed/revision.yaml b/tests/e2e/diff/async3-operation-action-changed/revision.yaml new file mode 100644 index 0000000000..135d2a51f8 --- /dev/null +++ b/tests/e2e/diff/async3-operation-action-changed/revision.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-operation-action-changed + version: '1.0' +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + properties: + id: + type: string + plan: + type: string +operations: + onUserSignedUp: + action: send + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-operation-action-changed/snapshot.txt b/tests/e2e/diff/async3-operation-action-changed/snapshot.txt new file mode 100644 index 0000000000..11e9116987 --- /dev/null +++ b/tests/e2e/diff/async3-operation-action-changed/snapshot.txt @@ -0,0 +1,11 @@ +operations + ✖ breaking changed operations/onUserSignedUp · action + The operation action changed from 'receive' to 'send'. (operation-action-changed) + at revision.yaml:22:13 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/async3-payload-property-removed/base.yaml b/tests/e2e/diff/async3-payload-property-removed/base.yaml new file mode 100644 index 0000000000..81ee7b0fc7 --- /dev/null +++ b/tests/e2e/diff/async3-payload-property-removed/base.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-payload-property-removed + version: '1.0' +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + properties: + id: + type: string + plan: + type: string +operations: + onUserSignedUp: + action: send + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-payload-property-removed/revision.yaml b/tests/e2e/diff/async3-payload-property-removed/revision.yaml new file mode 100644 index 0000000000..771a295c8a --- /dev/null +++ b/tests/e2e/diff/async3-payload-property-removed/revision.yaml @@ -0,0 +1,24 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-payload-property-removed + version: '1.0' +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + properties: + id: + type: string +operations: + onUserSignedUp: + action: send + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-payload-property-removed/snapshot.txt b/tests/e2e/diff/async3-payload-property-removed/snapshot.txt new file mode 100644 index 0000000000..9f88ee366d --- /dev/null +++ b/tests/e2e/diff/async3-payload-property-removed/snapshot.txt @@ -0,0 +1,11 @@ +channels + ✖ breaking removed channels/userSignedUp/messages/signup/payload/properties/plan + Schema property was removed. (property-removed-from-response) + at base.yaml:19:15 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/async3-payload-required-added/base.yaml b/tests/e2e/diff/async3-payload-required-added/base.yaml new file mode 100644 index 0000000000..3e7f8fc2dc --- /dev/null +++ b/tests/e2e/diff/async3-payload-required-added/base.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-payload-required-added + version: '1.0' +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + properties: + id: + type: string + plan: + type: string +operations: + onUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-payload-required-added/revision.yaml b/tests/e2e/diff/async3-payload-required-added/revision.yaml new file mode 100644 index 0000000000..bb9b3f3102 --- /dev/null +++ b/tests/e2e/diff/async3-payload-required-added/revision.yaml @@ -0,0 +1,27 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-payload-required-added + version: '1.0' +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + - plan + properties: + id: + type: string + plan: + type: string +operations: + onUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-payload-required-added/snapshot.txt b/tests/e2e/diff/async3-payload-required-added/snapshot.txt new file mode 100644 index 0000000000..19d5987d7a --- /dev/null +++ b/tests/e2e/diff/async3-payload-required-added/snapshot.txt @@ -0,0 +1,11 @@ +channels + ✖ breaking changed channels/userSignedUp/messages/signup/payload · required + Properties became required: plan. (required-properties-added) + at revision.yaml:14:13 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/async3-sent-payload-required-added/base.yaml b/tests/e2e/diff/async3-sent-payload-required-added/base.yaml new file mode 100644 index 0000000000..6cac870c6a --- /dev/null +++ b/tests/e2e/diff/async3-sent-payload-required-added/base.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-sent-payload-required-added + version: '1.0' +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + properties: + id: + type: string + plan: + type: string +operations: + onUserSignedUp: + action: send + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-sent-payload-required-added/revision.yaml b/tests/e2e/diff/async3-sent-payload-required-added/revision.yaml new file mode 100644 index 0000000000..7307495421 --- /dev/null +++ b/tests/e2e/diff/async3-sent-payload-required-added/revision.yaml @@ -0,0 +1,27 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-sent-payload-required-added + version: '1.0' +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + - plan + properties: + id: + type: string + plan: + type: string +operations: + onUserSignedUp: + action: send + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt b/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt new file mode 100644 index 0000000000..39a4ec7f25 --- /dev/null +++ b/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt @@ -0,0 +1,9 @@ +channels + ✔ non-breaking changed channels/userSignedUp/messages/signup/payload · required + at revision.yaml:14:13 + +0 breaking, 1 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + diff --git a/tests/e2e/diff/async3-server-removed/base.yaml b/tests/e2e/diff/async3-server-removed/base.yaml new file mode 100644 index 0000000000..5edceb3bc5 --- /dev/null +++ b/tests/e2e/diff/async3-server-removed/base.yaml @@ -0,0 +1,33 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-server-removed + version: '1.0' +servers: + production: + host: broker.example.com + protocol: kafka + staging: + host: staging.example.com + protocol: kafka +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + properties: + id: + type: string + plan: + type: string +operations: + onUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-server-removed/revision.yaml b/tests/e2e/diff/async3-server-removed/revision.yaml new file mode 100644 index 0000000000..6c0429246b --- /dev/null +++ b/tests/e2e/diff/async3-server-removed/revision.yaml @@ -0,0 +1,30 @@ +asyncapi: 3.0.0 +info: + title: asyncapi3-server-removed + version: '1.0' +servers: + production: + host: broker.example.com + protocol: kafka +channels: + userSignedUp: + address: user/signedup + messages: + signup: + contentType: application/json + payload: + type: object + required: + - id + properties: + id: + type: string + plan: + type: string +operations: + onUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/signup' diff --git a/tests/e2e/diff/async3-server-removed/snapshot.txt b/tests/e2e/diff/async3-server-removed/snapshot.txt new file mode 100644 index 0000000000..0d7bed1488 --- /dev/null +++ b/tests/e2e/diff/async3-server-removed/snapshot.txt @@ -0,0 +1,11 @@ +servers + ✖ breaking removed servers/staging + The server was removed. (server-removed) + at base.yaml:10:5 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/cli.test.ts b/tests/e2e/diff/cli.test.ts new file mode 100644 index 0000000000..e0bb02d0ae --- /dev/null +++ b/tests/e2e/diff/cli.test.ts @@ -0,0 +1,41 @@ +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; + +import { fixturePath, runDiff } from './helpers.js'; + +const indexEntryPoint = join(process.cwd(), 'packages/cli/lib/index.js'); + +// The exit code is the whole point of the command in a pipeline, so it is read off a +// real process rather than out of captured output. +function diffExitCode(fixture: string, ...args: string[]): number | null { + const { status } = spawnSync( + 'node', + [indexEntryPoint, 'diff', 'base.yaml', 'revision.yaml', ...args], + { encoding: 'utf-8', cwd: fixturePath(fixture), env: { ...process.env, NO_COLOR: 'TRUE' } } + ); + return status; +} + +describe('diff command', () => { + test('exits 1 on breaking changes with the default --fail-on=breaking', () => { + expect(diffExitCode('oas3-breaking-changes')).toBe(1); + }); + + test('exits 0 with --fail-on=none', () => { + expect(diffExitCode('oas3-breaking-changes', '--fail-on=none')).toBe(0); + }); + + test('exits 0 when the only changes are non-breaking', () => { + expect(diffExitCode('oas3-parameter-added-optional')).toBe(0); + }); + + test('rejects --output for formats that only print to stdout', () => { + const output = runDiff('oas3-breaking-changes', '--format=summary', '-o', 'out.txt'); + expect(output).toContain('prints to stdout only'); + }); + + test('refuses to compare across specification families', () => { + const output = runDiff('cross-family'); + expect(output).toContain('different specification families'); + }); +}); diff --git a/tests/e2e/diff/cross-family/base.yaml b/tests/e2e/diff/cross-family/base.yaml new file mode 100644 index 0000000000..3d5ca76687 --- /dev/null +++ b/tests/e2e/diff/cross-family/base.yaml @@ -0,0 +1,5 @@ +swagger: '2.0' +info: + title: cross-family + version: '1.0' +paths: {} diff --git a/tests/e2e/diff/cross-family/revision.yaml b/tests/e2e/diff/cross-family/revision.yaml new file mode 100644 index 0000000000..a52ea4d006 --- /dev/null +++ b/tests/e2e/diff/cross-family/revision.yaml @@ -0,0 +1,5 @@ +openapi: 3.1.0 +info: + title: cross-family + version: '1.0' +paths: {} diff --git a/tests/e2e/diff/helpers.ts b/tests/e2e/diff/helpers.ts new file mode 100644 index 0000000000..6a881e7208 --- /dev/null +++ b/tests/e2e/diff/helpers.ts @@ -0,0 +1,35 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { cleanupOutput, getCommandOutput, getParams } from '../helpers.js'; + +const diffDir = dirname(fileURLToPath(import.meta.url)); +const indexEntryPoint = join(process.cwd(), 'packages/cli/lib/index.js'); + +/** Every fixture is a `base.yaml`/`revision.yaml` pair in its own folder under this one. */ +export function fixturePath(fixture: string): string { + return join(diffDir, fixture); +} + +export function runDiff(fixture: string, ...args: string[]): string { + const params = getParams(indexEntryPoint, ['diff', 'base.yaml', 'revision.yaml', ...args]); + return cleanupOutput(getCommandOutput(params, { testPath: fixturePath(fixture) })); +} + +/** + * The `json` report, parsed. The command prints its timing line to stderr as well, so + * the document is cut out of the captured output. + */ +export function runJsonDiff(fixture: string): { + summary: { breaking: number; nonBreaking: number }; + changes: { + pointer: string; + property?: string; + kind: string; + compat: string; + verdicts?: { ruleId: string; compat: string; message: string }[]; + }[]; +} { + const output = runDiff(fixture, '--format=json'); + return JSON.parse(output.slice(output.indexOf('{'), output.lastIndexOf('}') + 1)); +} diff --git a/tests/e2e/diff/oas3-additional-properties-changed/base.yaml b/tests/e2e/diff/oas3-additional-properties-changed/base.yaml new file mode 100644 index 0000000000..2da090dc58 --- /dev/null +++ b/tests/e2e/diff/oas3-additional-properties-changed/base.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: additional-properties-changed + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: true + properties: + name: + type: string + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-additional-properties-changed/revision.yaml b/tests/e2e/diff/oas3-additional-properties-changed/revision.yaml new file mode 100644 index 0000000000..aba2088e72 --- /dev/null +++ b/tests/e2e/diff/oas3-additional-properties-changed/revision.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: additional-properties-changed + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + name: + type: string + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt b/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt new file mode 100644 index 0000000000..e4060dbf09 --- /dev/null +++ b/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt @@ -0,0 +1,11 @@ +POST /pets + ✖ breaking changed requestBody/content/application~1json/schema · additionalProperties + `additionalProperties` changed from 'true' to 'false'. (additional-properties-changed) + at revision.yaml:13:37 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-breaking-changes/base.yaml b/tests/e2e/diff/oas3-breaking-changes/base.yaml new file mode 100644 index 0000000000..2187217b99 --- /dev/null +++ b/tests/e2e/diff/oas3-breaking-changes/base.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Diff E2E + version: '1.0' +paths: + /pets: + get: + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + delete: + responses: + '204': + description: Deleted +components: + schemas: + Pet: + type: object + required: [name] + properties: + name: + type: string + tag: + type: string diff --git a/tests/e2e/diff/oas3-breaking-changes/revision.yaml b/tests/e2e/diff/oas3-breaking-changes/revision.yaml new file mode 100644 index 0000000000..cf3b37628b --- /dev/null +++ b/tests/e2e/diff/oas3-breaking-changes/revision.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Diff E2E + version: '2.0' +paths: + /pets: + get: + parameters: + - name: limit + in: query + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + type: object + required: [name] + properties: + name: + type: string diff --git a/tests/e2e/diff/oas3-breaking-changes/snapshot.txt b/tests/e2e/diff/oas3-breaking-changes/snapshot.txt new file mode 100644 index 0000000000..e6e12ab774 --- /dev/null +++ b/tests/e2e/diff/oas3-breaking-changes/snapshot.txt @@ -0,0 +1,25 @@ +components + ✖ breaking removed components/schemas/Pet/properties/tag + Schema property was removed. (property-removed-from-response) + at base.yaml:33:11 + +DELETE /pets + ✖ breaking removed paths · /pets · delete + Operation was removed. (operation-removed) + at base.yaml:21:7 + +GET /pets + ✖ breaking changed parameters/{query:limit} · required + Parameter became required. (parameter-became-required) + at revision.yaml:11:21 + +info + ✔ non-breaking changed info · version + at revision.yaml:4:12 + +3 breaking, 1 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 3 breaking changes. diff --git a/tests/e2e/diff/oas3-enum-values-added-to-request/base.yaml b/tests/e2e/diff/oas3-enum-values-added-to-request/base.yaml new file mode 100644 index 0000000000..c78a921748 --- /dev/null +++ b/tests/e2e/diff/oas3-enum-values-added-to-request/base.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: enum-values-added-to-request + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: string + enum: [available, pending] + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-enum-values-added-to-request/revision.yaml b/tests/e2e/diff/oas3-enum-values-added-to-request/revision.yaml new file mode 100644 index 0000000000..6195758a03 --- /dev/null +++ b/tests/e2e/diff/oas3-enum-values-added-to-request/revision.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: enum-values-added-to-request + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: string + enum: [available, pending, sold] + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-enum-values-added-to-request/snapshot.txt b/tests/e2e/diff/oas3-enum-values-added-to-request/snapshot.txt new file mode 100644 index 0000000000..325e0bed1b --- /dev/null +++ b/tests/e2e/diff/oas3-enum-values-added-to-request/snapshot.txt @@ -0,0 +1,9 @@ +POST /pets + ✔ non-breaking changed requestBody/content/application~1json/schema · enum + at revision.yaml:13:21 + +0 breaking, 1 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + diff --git a/tests/e2e/diff/oas3-enum-values-added/base.yaml b/tests/e2e/diff/oas3-enum-values-added/base.yaml new file mode 100644 index 0000000000..8f25eb3baa --- /dev/null +++ b/tests/e2e/diff/oas3-enum-values-added/base.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: enum-values-added + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + type: string + enum: [available, pending] diff --git a/tests/e2e/diff/oas3-enum-values-added/revision.yaml b/tests/e2e/diff/oas3-enum-values-added/revision.yaml new file mode 100644 index 0000000000..fff8009d68 --- /dev/null +++ b/tests/e2e/diff/oas3-enum-values-added/revision.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: enum-values-added + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + type: string + enum: [available, pending, sold] diff --git a/tests/e2e/diff/oas3-enum-values-added/snapshot.txt b/tests/e2e/diff/oas3-enum-values-added/snapshot.txt new file mode 100644 index 0000000000..6bb421439e --- /dev/null +++ b/tests/e2e/diff/oas3-enum-values-added/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking changed responses/200/content/application~1json/schema · enum + Enum values added: sold. (enum-values-added) + at revision.yaml:15:23 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-enum-values-removed/base.yaml b/tests/e2e/diff/oas3-enum-values-removed/base.yaml new file mode 100644 index 0000000000..15fd71566a --- /dev/null +++ b/tests/e2e/diff/oas3-enum-values-removed/base.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: enum-values-removed + version: '1.0' +paths: + /pets: + get: + parameters: + - name: sort + in: query + schema: + type: string + enum: + - asc + - desc + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-enum-values-removed/revision.yaml b/tests/e2e/diff/oas3-enum-values-removed/revision.yaml new file mode 100644 index 0000000000..4d00a7e145 --- /dev/null +++ b/tests/e2e/diff/oas3-enum-values-removed/revision.yaml @@ -0,0 +1,17 @@ +openapi: 3.1.0 +info: + title: enum-values-removed + version: '1.0' +paths: + /pets: + get: + parameters: + - name: sort + in: query + schema: + type: string + enum: + - asc + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt b/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt new file mode 100644 index 0000000000..ec95a3dfc3 --- /dev/null +++ b/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking changed parameters/{query:sort}/schema · enum + Enum values removed: desc. (enum-values-removed) + at revision.yaml:14:15 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-media-type-removed/base.yaml b/tests/e2e/diff/oas3-media-type-removed/base.yaml new file mode 100644 index 0000000000..4c12fe0dd1 --- /dev/null +++ b/tests/e2e/diff/oas3-media-type-removed/base.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: media-type-removed + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + application/xml: + schema: { type: object } diff --git a/tests/e2e/diff/oas3-media-type-removed/revision.yaml b/tests/e2e/diff/oas3-media-type-removed/revision.yaml new file mode 100644 index 0000000000..11d150cc8c --- /dev/null +++ b/tests/e2e/diff/oas3-media-type-removed/revision.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.0 +info: + title: media-type-removed + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } diff --git a/tests/e2e/diff/oas3-media-type-removed/snapshot.txt b/tests/e2e/diff/oas3-media-type-removed/snapshot.txt new file mode 100644 index 0000000000..faba51c3ab --- /dev/null +++ b/tests/e2e/diff/oas3-media-type-removed/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking removed responses/200/content/application~1xml + Media type was removed. (media-type-removed) + at base.yaml:15:15 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-nullability-changed/base.yaml b/tests/e2e/diff/oas3-nullability-changed/base.yaml new file mode 100644 index 0000000000..adaeb2f2b6 --- /dev/null +++ b/tests/e2e/diff/oas3-nullability-changed/base.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: nullability-changed + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: + - string + - 'null' + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-nullability-changed/revision.yaml b/tests/e2e/diff/oas3-nullability-changed/revision.yaml new file mode 100644 index 0000000000..2cedf3ad32 --- /dev/null +++ b/tests/e2e/diff/oas3-nullability-changed/revision.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: nullability-changed + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-nullability-changed/snapshot.txt b/tests/e2e/diff/oas3-nullability-changed/snapshot.txt new file mode 100644 index 0000000000..06275817d4 --- /dev/null +++ b/tests/e2e/diff/oas3-nullability-changed/snapshot.txt @@ -0,0 +1,11 @@ +POST /pets + ✖ breaking changed requestBody/content/application~1json/schema/properties/name · type + Schema type narrowed from 'string | null' to 'string'. (schema-type-changed) + at revision.yaml:15:25 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-nullable-equivalence-across-versions/base.yaml b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/base.yaml new file mode 100644 index 0000000000..b4719daabf --- /dev/null +++ b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/base.yaml @@ -0,0 +1,19 @@ +openapi: 3.0.3 +info: + title: nullable-equivalence-across-versions + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + nullable: true + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-nullable-equivalence-across-versions/revision.yaml b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/revision.yaml new file mode 100644 index 0000000000..1af0bee554 --- /dev/null +++ b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/revision.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: nullable-equivalence-across-versions + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: + - string + - 'null' + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt new file mode 100644 index 0000000000..4520ba5a16 --- /dev/null +++ b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt @@ -0,0 +1,15 @@ +document + ✔ non-breaking changed openapi + at revision.yaml:1:10 + +POST /pets + ✔ non-breaking changed requestBody/content/application~1json/schema/properties/name · nullable + at revision.yaml:15:19 + ✔ non-breaking changed requestBody/content/application~1json/schema/properties/name · type + at revision.yaml:16:21 + +0 breaking, 3 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + diff --git a/tests/e2e/diff/oas3-numeric-range-changed/base.yaml b/tests/e2e/diff/oas3-numeric-range-changed/base.yaml new file mode 100644 index 0000000000..61532db626 --- /dev/null +++ b/tests/e2e/diff/oas3-numeric-range-changed/base.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: numeric-range-changed + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + age: + type: integer + minimum: 0 + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-numeric-range-changed/revision.yaml b/tests/e2e/diff/oas3-numeric-range-changed/revision.yaml new file mode 100644 index 0000000000..0aba616219 --- /dev/null +++ b/tests/e2e/diff/oas3-numeric-range-changed/revision.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: numeric-range-changed + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + age: + type: integer + minimum: 10 + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt b/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt new file mode 100644 index 0000000000..865e3f27a1 --- /dev/null +++ b/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt @@ -0,0 +1,11 @@ +POST /pets + ✖ breaking changed requestBody/content/application~1json/schema/properties/age · minimum + `minimum` changed from '0' to '10'. (numeric-range-changed) + at revision.yaml:16:28 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-operation-removed/base.yaml b/tests/e2e/diff/oas3-operation-removed/base.yaml new file mode 100644 index 0000000000..3f103f844f --- /dev/null +++ b/tests/e2e/diff/oas3-operation-removed/base.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: operation-removed + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK + delete: + responses: + '204': + description: Deleted diff --git a/tests/e2e/diff/oas3-operation-removed/revision.yaml b/tests/e2e/diff/oas3-operation-removed/revision.yaml new file mode 100644 index 0000000000..5d6b1398ef --- /dev/null +++ b/tests/e2e/diff/oas3-operation-removed/revision.yaml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +info: + title: operation-removed + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-operation-removed/snapshot.txt b/tests/e2e/diff/oas3-operation-removed/snapshot.txt new file mode 100644 index 0000000000..4da15359f1 --- /dev/null +++ b/tests/e2e/diff/oas3-operation-removed/snapshot.txt @@ -0,0 +1,11 @@ +DELETE /pets + ✖ breaking removed paths · /pets · delete + Operation was removed. (operation-removed) + at base.yaml:12:7 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-parameter-added-optional/base.yaml b/tests/e2e/diff/oas3-parameter-added-optional/base.yaml new file mode 100644 index 0000000000..3020af9337 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-added-optional/base.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: + title: parameter-added-optional + version: '1.0' +paths: + /pets: + get: + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-parameter-added-optional/revision.yaml b/tests/e2e/diff/oas3-parameter-added-optional/revision.yaml new file mode 100644 index 0000000000..81a806ae81 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-added-optional/revision.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.0 +info: + title: parameter-added-optional + version: '1.0' +paths: + /pets: + get: + parameters: + - name: tenant + in: query + schema: { type: string } + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-parameter-added-optional/snapshot.txt b/tests/e2e/diff/oas3-parameter-added-optional/snapshot.txt new file mode 100644 index 0000000000..fb73842e72 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-added-optional/snapshot.txt @@ -0,0 +1,9 @@ +GET /pets + ✔ non-breaking added parameters + at revision.yaml:9:9 + +0 breaking, 1 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + diff --git a/tests/e2e/diff/oas3-parameter-added-required/base.yaml b/tests/e2e/diff/oas3-parameter-added-required/base.yaml new file mode 100644 index 0000000000..5f02acab87 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-added-required/base.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: + title: parameter-added-required + version: '1.0' +paths: + /pets: + get: + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-parameter-added-required/revision.yaml b/tests/e2e/diff/oas3-parameter-added-required/revision.yaml new file mode 100644 index 0000000000..6e2b165c4c --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-added-required/revision.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: parameter-added-required + version: '1.0' +paths: + /pets: + get: + parameters: + - name: tenant + in: query + required: true + schema: { type: string } + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-parameter-added-required/snapshot.txt b/tests/e2e/diff/oas3-parameter-added-required/snapshot.txt new file mode 100644 index 0000000000..f393eed44c --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-added-required/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking added parameters + A new required parameter was added. (parameter-added-required) + at revision.yaml:9:9 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-parameter-became-required/base.yaml b/tests/e2e/diff/oas3-parameter-became-required/base.yaml new file mode 100644 index 0000000000..07037e92bd --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-became-required/base.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: parameter-became-required + version: '1.0' +paths: + /pets: + get: + parameters: + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-parameter-became-required/revision.yaml b/tests/e2e/diff/oas3-parameter-became-required/revision.yaml new file mode 100644 index 0000000000..3ed33a78f9 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-became-required/revision.yaml @@ -0,0 +1,16 @@ +openapi: 3.1.0 +info: + title: parameter-became-required + version: '1.0' +paths: + /pets: + get: + parameters: + - name: limit + in: query + required: true + schema: + type: integer + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt b/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt new file mode 100644 index 0000000000..7d6169c90b --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking changed parameters/{query:limit} · required + Parameter became required. (parameter-became-required) + at revision.yaml:11:21 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-parameter-removed-last/base.yaml b/tests/e2e/diff/oas3-parameter-removed-last/base.yaml new file mode 100644 index 0000000000..eeefa6ebd8 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-removed-last/base.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.0 +info: + title: parameter-removed-last + version: '1.0' +paths: + /pets: + get: + parameters: + - name: limit + in: query + schema: { type: integer } + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-parameter-removed-last/revision.yaml b/tests/e2e/diff/oas3-parameter-removed-last/revision.yaml new file mode 100644 index 0000000000..aaa022934e --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-removed-last/revision.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: + title: parameter-removed-last + version: '1.0' +paths: + /pets: + get: + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-parameter-removed-last/snapshot.txt b/tests/e2e/diff/oas3-parameter-removed-last/snapshot.txt new file mode 100644 index 0000000000..814803d611 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-removed-last/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking removed parameters + Every parameter was removed. (parameter-removed) + at base.yaml:9:9 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-parameter-removed/base.yaml b/tests/e2e/diff/oas3-parameter-removed/base.yaml new file mode 100644 index 0000000000..de55637e04 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-removed/base.yaml @@ -0,0 +1,16 @@ +openapi: 3.1.0 +info: + title: parameter-removed + version: '1.0' +paths: + /pets: + get: + parameters: + - name: limit + in: query + schema: { type: integer } + - name: offset + in: query + schema: { type: integer } + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-parameter-removed/revision.yaml b/tests/e2e/diff/oas3-parameter-removed/revision.yaml new file mode 100644 index 0000000000..8f38486660 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-removed/revision.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.0 +info: + title: parameter-removed + version: '1.0' +paths: + /pets: + get: + parameters: + - name: offset + in: query + schema: { type: integer } + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-parameter-removed/snapshot.txt b/tests/e2e/diff/oas3-parameter-removed/snapshot.txt new file mode 100644 index 0000000000..bb1a437685 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-removed/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking removed parameters/{query:limit} + Parameter was removed. (parameter-removed) + at base.yaml:9:11 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-parameter-serialization-changed/base.yaml b/tests/e2e/diff/oas3-parameter-serialization-changed/base.yaml new file mode 100644 index 0000000000..481c022a8e --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-serialization-changed/base.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: parameter-serialization-changed + version: '1.0' +paths: + /pets: + get: + parameters: + - name: tags + in: query + style: form + schema: + type: array + items: + type: string + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-parameter-serialization-changed/revision.yaml b/tests/e2e/diff/oas3-parameter-serialization-changed/revision.yaml new file mode 100644 index 0000000000..261635dc07 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-serialization-changed/revision.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: parameter-serialization-changed + version: '1.0' +paths: + /pets: + get: + parameters: + - name: tags + in: query + style: spaceDelimited + schema: + type: array + items: + type: string + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt b/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt new file mode 100644 index 0000000000..8bdf94a51e --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking changed parameters/{query:tags} · style + Parameter `style` changed from 'form' to 'spaceDelimited'. (parameter-serialization-changed) + at revision.yaml:11:18 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-path-parameter-renamed/base.yaml b/tests/e2e/diff/oas3-path-parameter-renamed/base.yaml new file mode 100644 index 0000000000..b8ed4e1246 --- /dev/null +++ b/tests/e2e/diff/oas3-path-parameter-renamed/base.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: path-parameter-renamed + version: '1.0' +paths: + /pets/{id}: + get: + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-path-parameter-renamed/revision.yaml b/tests/e2e/diff/oas3-path-parameter-renamed/revision.yaml new file mode 100644 index 0000000000..8d380b1ba7 --- /dev/null +++ b/tests/e2e/diff/oas3-path-parameter-renamed/revision.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: path-parameter-renamed + version: '1.0' +paths: + /pets/{petId}: + get: + parameters: + - name: petId + in: path + required: true + schema: { type: string } + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt b/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt new file mode 100644 index 0000000000..30aa95cdd8 --- /dev/null +++ b/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt @@ -0,0 +1,13 @@ +/pets/{petId} + ✔ non-breaking changed paths · /pets/{id} · path + at revision.yaml:7:5 + +GET /pets/{petId} + ✔ non-breaking changed parameters/{path:id} · name + at revision.yaml:9:17 + +0 breaking, 2 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + diff --git a/tests/e2e/diff/oas3-path-removed/base.yaml b/tests/e2e/diff/oas3-path-removed/base.yaml new file mode 100644 index 0000000000..a07f9e0859 --- /dev/null +++ b/tests/e2e/diff/oas3-path-removed/base.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: path-removed + version: '1.0' +paths: + /pets: + get: + responses: + '200': { description: OK } + /pets/{petId}: + get: + parameters: + - name: petId + in: path + required: true + schema: { type: string } + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-path-removed/revision.yaml b/tests/e2e/diff/oas3-path-removed/revision.yaml new file mode 100644 index 0000000000..b47c85f0e4 --- /dev/null +++ b/tests/e2e/diff/oas3-path-removed/revision.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: + title: path-removed + version: '1.0' +paths: + /pets: + get: + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-path-removed/snapshot.txt b/tests/e2e/diff/oas3-path-removed/snapshot.txt new file mode 100644 index 0000000000..3eb58edc05 --- /dev/null +++ b/tests/e2e/diff/oas3-path-removed/snapshot.txt @@ -0,0 +1,11 @@ +/pets/{petId} + ✖ breaking removed paths · /pets/{petId} + Path was removed. (path-removed) + at base.yaml:11:5 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-property-removed-from-response/base.yaml b/tests/e2e/diff/oas3-property-removed-from-response/base.yaml new file mode 100644 index 0000000000..425629b615 --- /dev/null +++ b/tests/e2e/diff/oas3-property-removed-from-response/base.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: property-removed-from-response + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + name: + type: string + tag: + type: string diff --git a/tests/e2e/diff/oas3-property-removed-from-response/revision.yaml b/tests/e2e/diff/oas3-property-removed-from-response/revision.yaml new file mode 100644 index 0000000000..3fd7e36afd --- /dev/null +++ b/tests/e2e/diff/oas3-property-removed-from-response/revision.yaml @@ -0,0 +1,17 @@ +openapi: 3.1.0 +info: + title: property-removed-from-response + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + name: + type: string diff --git a/tests/e2e/diff/oas3-property-removed-from-response/snapshot.txt b/tests/e2e/diff/oas3-property-removed-from-response/snapshot.txt new file mode 100644 index 0000000000..65960b1c20 --- /dev/null +++ b/tests/e2e/diff/oas3-property-removed-from-response/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking removed responses/200/content/application~1json/schema/properties/tag + Schema property was removed. (property-removed-from-response) + at base.yaml:19:21 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-ref-target-changed/base.yaml b/tests/e2e/diff/oas3-ref-target-changed/base.yaml new file mode 100644 index 0000000000..a6cedb66c7 --- /dev/null +++ b/tests/e2e/diff/oas3-ref-target-changed/base.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: ref-target-changed + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + type: object + properties: + id: { type: string } + PetV2: + type: object + properties: + id: { type: string } diff --git a/tests/e2e/diff/oas3-ref-target-changed/revision.yaml b/tests/e2e/diff/oas3-ref-target-changed/revision.yaml new file mode 100644 index 0000000000..3c8d3c5f8e --- /dev/null +++ b/tests/e2e/diff/oas3-ref-target-changed/revision.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: ref-target-changed + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PetV2' +components: + schemas: + Pet: + type: object + properties: + id: { type: string } + PetV2: + type: object + properties: + id: { type: string } diff --git a/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt b/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt new file mode 100644 index 0000000000..fbaca8753e --- /dev/null +++ b/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking changed responses/200/content/application~1json · schema + The reference target changed from '#/components/schemas/Pet' to '#/components/schemas/PetV2'. The diff cannot check that the two targets are equivalent. (ref-target-changed) + at revision.yaml:14:17 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-request-body-became-required/base.yaml b/tests/e2e/diff/oas3-request-body-became-required/base.yaml new file mode 100644 index 0000000000..00e85c0e6a --- /dev/null +++ b/tests/e2e/diff/oas3-request-body-became-required/base.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: request-body-became-required + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-request-body-became-required/revision.yaml b/tests/e2e/diff/oas3-request-body-became-required/revision.yaml new file mode 100644 index 0000000000..e671eb370e --- /dev/null +++ b/tests/e2e/diff/oas3-request-body-became-required/revision.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: request-body-became-required + version: '1.0' +paths: + /pets: + post: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt b/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt new file mode 100644 index 0000000000..dca9842584 --- /dev/null +++ b/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt @@ -0,0 +1,11 @@ +POST /pets + ✖ breaking changed requestBody · required + The request body became required. (request-body-became-required) + at revision.yaml:9:19 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-request-body-removed/base.yaml b/tests/e2e/diff/oas3-request-body-removed/base.yaml new file mode 100644 index 0000000000..ca860ce0d4 --- /dev/null +++ b/tests/e2e/diff/oas3-request-body-removed/base.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: request-body-removed + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-request-body-removed/revision.yaml b/tests/e2e/diff/oas3-request-body-removed/revision.yaml new file mode 100644 index 0000000000..ada55feb27 --- /dev/null +++ b/tests/e2e/diff/oas3-request-body-removed/revision.yaml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +info: + title: request-body-removed + version: '1.0' +paths: + /pets: + post: + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-request-body-removed/snapshot.txt b/tests/e2e/diff/oas3-request-body-removed/snapshot.txt new file mode 100644 index 0000000000..390a6396b5 --- /dev/null +++ b/tests/e2e/diff/oas3-request-body-removed/snapshot.txt @@ -0,0 +1,11 @@ +POST /pets + ✖ breaking removed requestBody + The request body was removed. (request-body-removed) + at base.yaml:9:9 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-required-properties-added/base.yaml b/tests/e2e/diff/oas3-required-properties-added/base.yaml new file mode 100644 index 0000000000..37577815ff --- /dev/null +++ b/tests/e2e/diff/oas3-required-properties-added/base.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: required-properties-added + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + required: [id] + properties: + id: { type: string } + name: { type: string } + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-required-properties-added/revision.yaml b/tests/e2e/diff/oas3-required-properties-added/revision.yaml new file mode 100644 index 0000000000..1e8a03a1af --- /dev/null +++ b/tests/e2e/diff/oas3-required-properties-added/revision.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: required-properties-added + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + required: [id, name] + properties: + id: { type: string } + name: { type: string } + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-required-properties-added/snapshot.txt b/tests/e2e/diff/oas3-required-properties-added/snapshot.txt new file mode 100644 index 0000000000..53e1d5f1fb --- /dev/null +++ b/tests/e2e/diff/oas3-required-properties-added/snapshot.txt @@ -0,0 +1,11 @@ +POST /pets + ✖ breaking changed requestBody/content/application~1json/schema · required + Properties became required: name. (required-properties-added) + at revision.yaml:13:25 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-required-properties-removed/base.yaml b/tests/e2e/diff/oas3-required-properties-removed/base.yaml new file mode 100644 index 0000000000..ab23719a6c --- /dev/null +++ b/tests/e2e/diff/oas3-required-properties-removed/base.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: required-properties-removed + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [id, name] + properties: + id: { type: string } + name: { type: string } diff --git a/tests/e2e/diff/oas3-required-properties-removed/revision.yaml b/tests/e2e/diff/oas3-required-properties-removed/revision.yaml new file mode 100644 index 0000000000..65baf80f5c --- /dev/null +++ b/tests/e2e/diff/oas3-required-properties-removed/revision.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: required-properties-removed + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [id] + properties: + id: { type: string } + name: { type: string } diff --git a/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt b/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt new file mode 100644 index 0000000000..c9029a0774 --- /dev/null +++ b/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking changed responses/200/content/application~1json/schema · required + Properties are no longer required: name. (required-properties-removed) + at revision.yaml:15:27 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-response-header-removed/base.yaml b/tests/e2e/diff/oas3-response-header-removed/base.yaml new file mode 100644 index 0000000000..3140ddd050 --- /dev/null +++ b/tests/e2e/diff/oas3-response-header-removed/base.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: response-header-removed + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK + headers: + X-Rate-Limit: + schema: + type: integer diff --git a/tests/e2e/diff/oas3-response-header-removed/revision.yaml b/tests/e2e/diff/oas3-response-header-removed/revision.yaml new file mode 100644 index 0000000000..44a3476a70 --- /dev/null +++ b/tests/e2e/diff/oas3-response-header-removed/revision.yaml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +info: + title: response-header-removed + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-response-header-removed/snapshot.txt b/tests/e2e/diff/oas3-response-header-removed/snapshot.txt new file mode 100644 index 0000000000..0c043103b6 --- /dev/null +++ b/tests/e2e/diff/oas3-response-header-removed/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking removed responses/200/headers + The response headers were removed. (response-header-removed) + at base.yaml:12:13 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-response-removed/base.yaml b/tests/e2e/diff/oas3-response-removed/base.yaml new file mode 100644 index 0000000000..141482e5b6 --- /dev/null +++ b/tests/e2e/diff/oas3-response-removed/base.yaml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +info: + title: response-removed + version: '1.0' +paths: + /pets: + get: + responses: + '200': { description: OK } + '404': { description: Not found } diff --git a/tests/e2e/diff/oas3-response-removed/revision.yaml b/tests/e2e/diff/oas3-response-removed/revision.yaml new file mode 100644 index 0000000000..f23234e656 --- /dev/null +++ b/tests/e2e/diff/oas3-response-removed/revision.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: + title: response-removed + version: '1.0' +paths: + /pets: + get: + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/oas3-response-removed/snapshot.txt b/tests/e2e/diff/oas3-response-removed/snapshot.txt new file mode 100644 index 0000000000..c18e0fb18c --- /dev/null +++ b/tests/e2e/diff/oas3-response-removed/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking removed responses/404 + Response was removed. (response-removed) + at base.yaml:10:16 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-schema-combinator-changed/base.yaml b/tests/e2e/diff/oas3-schema-combinator-changed/base.yaml new file mode 100644 index 0000000000..36a186a9a9 --- /dev/null +++ b/tests/e2e/diff/oas3-schema-combinator-changed/base.yaml @@ -0,0 +1,17 @@ +openapi: 3.1.0 +info: + title: schema-combinator-changed + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + oneOf: + - type: string + - type: number + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-schema-combinator-changed/revision.yaml b/tests/e2e/diff/oas3-schema-combinator-changed/revision.yaml new file mode 100644 index 0000000000..83abd72a60 --- /dev/null +++ b/tests/e2e/diff/oas3-schema-combinator-changed/revision.yaml @@ -0,0 +1,16 @@ +openapi: 3.1.0 +info: + title: schema-combinator-changed + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + oneOf: + - type: string + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt b/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt new file mode 100644 index 0000000000..c31e906f42 --- /dev/null +++ b/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt @@ -0,0 +1,11 @@ +POST /pets + ✖ breaking removed requestBody/content/application~1json/schema/oneOf/1 + A `oneOf` subschema was removed. (schema-combinator-changed) + at base.yaml:14:19 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-schema-format-changed/base.yaml b/tests/e2e/diff/oas3-schema-format-changed/base.yaml new file mode 100644 index 0000000000..4684285afa --- /dev/null +++ b/tests/e2e/diff/oas3-schema-format-changed/base.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: schema-format-changed + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: string + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-schema-format-changed/revision.yaml b/tests/e2e/diff/oas3-schema-format-changed/revision.yaml new file mode 100644 index 0000000000..66899dba44 --- /dev/null +++ b/tests/e2e/diff/oas3-schema-format-changed/revision.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: schema-format-changed + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: string + format: uuid + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt b/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt new file mode 100644 index 0000000000..a229e7805b --- /dev/null +++ b/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt @@ -0,0 +1,11 @@ +POST /pets + ✖ breaking changed requestBody/content/application~1json/schema/properties/id · format + `format` was added with value 'uuid'. (schema-format-changed) + at revision.yaml:16:27 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-schema-type-widened-in-request/base.yaml b/tests/e2e/diff/oas3-schema-type-widened-in-request/base.yaml new file mode 100644 index 0000000000..ad7df78e04 --- /dev/null +++ b/tests/e2e/diff/oas3-schema-type-widened-in-request/base.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: schema-type-widened-in-request + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: string + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-schema-type-widened-in-request/revision.yaml b/tests/e2e/diff/oas3-schema-type-widened-in-request/revision.yaml new file mode 100644 index 0000000000..b2a87f4e0f --- /dev/null +++ b/tests/e2e/diff/oas3-schema-type-widened-in-request/revision.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: schema-type-widened-in-request + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: + - string + - number + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-schema-type-widened-in-request/snapshot.txt b/tests/e2e/diff/oas3-schema-type-widened-in-request/snapshot.txt new file mode 100644 index 0000000000..9279f6d5d8 --- /dev/null +++ b/tests/e2e/diff/oas3-schema-type-widened-in-request/snapshot.txt @@ -0,0 +1,9 @@ +POST /pets + ✔ non-breaking changed requestBody/content/application~1json/schema/properties/id · type + at revision.yaml:16:21 + +0 breaking, 1 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + diff --git a/tests/e2e/diff/oas3-security-requirement-added-to-empty-list/base.yaml b/tests/e2e/diff/oas3-security-requirement-added-to-empty-list/base.yaml new file mode 100644 index 0000000000..a4864fed53 --- /dev/null +++ b/tests/e2e/diff/oas3-security-requirement-added-to-empty-list/base.yaml @@ -0,0 +1,17 @@ +openapi: 3.1.0 +info: + title: security-requirement-added-to-empty-list + version: '1.0' +paths: + /pets: + get: + security: [] + responses: + '200': + description: OK +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: X-Key diff --git a/tests/e2e/diff/oas3-security-requirement-added-to-empty-list/revision.yaml b/tests/e2e/diff/oas3-security-requirement-added-to-empty-list/revision.yaml new file mode 100644 index 0000000000..2835f1989b --- /dev/null +++ b/tests/e2e/diff/oas3-security-requirement-added-to-empty-list/revision.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: security-requirement-added-to-empty-list + version: '1.0' +paths: + /pets: + get: + security: + - apiKey: [] + responses: + '200': + description: OK +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: X-Key diff --git a/tests/e2e/diff/oas3-security-requirement-added-to-empty-list/snapshot.txt b/tests/e2e/diff/oas3-security-requirement-added-to-empty-list/snapshot.txt new file mode 100644 index 0000000000..c634e3c3fa --- /dev/null +++ b/tests/e2e/diff/oas3-security-requirement-added-to-empty-list/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking added security/{apiKey} + The API now requires authentication. (security-requirement-added) + at revision.yaml:9:11 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-security-requirement-added/base.yaml b/tests/e2e/diff/oas3-security-requirement-added/base.yaml new file mode 100644 index 0000000000..34665c03b2 --- /dev/null +++ b/tests/e2e/diff/oas3-security-requirement-added/base.yaml @@ -0,0 +1,16 @@ +openapi: 3.1.0 +info: + title: security-requirement-added + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: X-Key diff --git a/tests/e2e/diff/oas3-security-requirement-added/revision.yaml b/tests/e2e/diff/oas3-security-requirement-added/revision.yaml new file mode 100644 index 0000000000..13eee83ffb --- /dev/null +++ b/tests/e2e/diff/oas3-security-requirement-added/revision.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: security-requirement-added + version: '1.0' +paths: + /pets: + get: + security: + - apiKey: [] + responses: + '200': + description: OK +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: X-Key diff --git a/tests/e2e/diff/oas3-security-requirement-added/snapshot.txt b/tests/e2e/diff/oas3-security-requirement-added/snapshot.txt new file mode 100644 index 0000000000..ec246f8fd7 --- /dev/null +++ b/tests/e2e/diff/oas3-security-requirement-added/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking added security + The API now requires authentication. (security-requirement-added) + at revision.yaml:9:9 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-security-scheme-changed/base.yaml b/tests/e2e/diff/oas3-security-scheme-changed/base.yaml new file mode 100644 index 0000000000..7f277d9e1a --- /dev/null +++ b/tests/e2e/diff/oas3-security-scheme-changed/base.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: security-scheme-changed + version: '1.0' +security: + - apiKey: [] +paths: + /pets: + get: + responses: + '200': + description: OK +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: X-Key diff --git a/tests/e2e/diff/oas3-security-scheme-changed/revision.yaml b/tests/e2e/diff/oas3-security-scheme-changed/revision.yaml new file mode 100644 index 0000000000..f8d4842e61 --- /dev/null +++ b/tests/e2e/diff/oas3-security-scheme-changed/revision.yaml @@ -0,0 +1,17 @@ +openapi: 3.1.0 +info: + title: security-scheme-changed + version: '1.0' +security: + - apiKey: [] +paths: + /pets: + get: + responses: + '200': + description: OK +components: + securitySchemes: + apiKey: + type: http + scheme: bearer diff --git a/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt b/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt new file mode 100644 index 0000000000..cb76fa7b2c --- /dev/null +++ b/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt @@ -0,0 +1,17 @@ +components + ✖ breaking changed components/securitySchemes/apiKey · type + Security scheme `type` changed from 'apiKey' to 'http'. (security-scheme-changed) + at revision.yaml:16:13 + ✔ non-breaking changed components/securitySchemes/apiKey · in + at revision.yaml:16:7 + ✔ non-breaking changed components/securitySchemes/apiKey · name + at revision.yaml:16:7 + ✔ non-breaking changed components/securitySchemes/apiKey · scheme + at revision.yaml:17:15 + +1 breaking, 3 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-security-scheme-removed/base.yaml b/tests/e2e/diff/oas3-security-scheme-removed/base.yaml new file mode 100644 index 0000000000..228883f77e --- /dev/null +++ b/tests/e2e/diff/oas3-security-scheme-removed/base.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: security-scheme-removed + version: '1.0' +paths: + /pets: + get: + responses: + '200': { description: OK } +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: X-Key + basicAuth: + type: http + scheme: basic diff --git a/tests/e2e/diff/oas3-security-scheme-removed/revision.yaml b/tests/e2e/diff/oas3-security-scheme-removed/revision.yaml new file mode 100644 index 0000000000..e961d6d125 --- /dev/null +++ b/tests/e2e/diff/oas3-security-scheme-removed/revision.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: security-scheme-removed + version: '1.0' +paths: + /pets: + get: + responses: + '200': { description: OK } +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: X-Key diff --git a/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt b/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt new file mode 100644 index 0000000000..1d75f18e55 --- /dev/null +++ b/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt @@ -0,0 +1,11 @@ +components + ✖ breaking removed components/securitySchemes/basicAuth + A security scheme was removed. (security-scheme-removed) + at base.yaml:17:7 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-security-scopes-added/base.yaml b/tests/e2e/diff/oas3-security-scopes-added/base.yaml new file mode 100644 index 0000000000..2222b52c68 --- /dev/null +++ b/tests/e2e/diff/oas3-security-scopes-added/base.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: security-scopes-added + version: '1.0' +paths: + /pets: + get: + security: + - oauth: + - pets:read + responses: + '200': + description: OK +components: + securitySchemes: + oauth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.com/token + scopes: + pets:read: Read pets + pets:write: Write pets diff --git a/tests/e2e/diff/oas3-security-scopes-added/revision.yaml b/tests/e2e/diff/oas3-security-scopes-added/revision.yaml new file mode 100644 index 0000000000..bb38c0e13f --- /dev/null +++ b/tests/e2e/diff/oas3-security-scopes-added/revision.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: security-scopes-added + version: '1.0' +paths: + /pets: + get: + security: + - oauth: + - pets:read + - pets:write + responses: + '200': + description: OK +components: + securitySchemes: + oauth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.com/token + scopes: + pets:read: Read pets + pets:write: Write pets diff --git a/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt b/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt new file mode 100644 index 0000000000..6984715a2b --- /dev/null +++ b/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking changed security/{oauth} · oauth + Scheme `oauth` requires new scopes: pets:write. (security-scopes-added) + at revision.yaml:10:13 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-string-length-changed/base.yaml b/tests/e2e/diff/oas3-string-length-changed/base.yaml new file mode 100644 index 0000000000..06b43c8c71 --- /dev/null +++ b/tests/e2e/diff/oas3-string-length-changed/base.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: string-length-changed + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 100 + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-string-length-changed/revision.yaml b/tests/e2e/diff/oas3-string-length-changed/revision.yaml new file mode 100644 index 0000000000..463e03674f --- /dev/null +++ b/tests/e2e/diff/oas3-string-length-changed/revision.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: string-length-changed + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 10 + responses: + '200': + description: OK diff --git a/tests/e2e/diff/oas3-string-length-changed/snapshot.txt b/tests/e2e/diff/oas3-string-length-changed/snapshot.txt new file mode 100644 index 0000000000..4743ffd825 --- /dev/null +++ b/tests/e2e/diff/oas3-string-length-changed/snapshot.txt @@ -0,0 +1,11 @@ +POST /pets + ✖ breaking changed requestBody/content/application~1json/schema/properties/name · maxLength + `maxLength` changed from '100' to '10'. (string-length-changed) + at revision.yaml:16:30 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/oas3-webhook-payload-property-removed/base.yaml b/tests/e2e/diff/oas3-webhook-payload-property-removed/base.yaml new file mode 100644 index 0000000000..065c27d592 --- /dev/null +++ b/tests/e2e/diff/oas3-webhook-payload-property-removed/base.yaml @@ -0,0 +1,21 @@ +openapi: 3.1.0 +info: + title: webhook-payload-property-removed + version: '1.0' +paths: {} +webhooks: + newPet: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + tag: + type: string + responses: + '200': + description: Acknowledged diff --git a/tests/e2e/diff/oas3-webhook-payload-property-removed/revision.yaml b/tests/e2e/diff/oas3-webhook-payload-property-removed/revision.yaml new file mode 100644 index 0000000000..93c17528e0 --- /dev/null +++ b/tests/e2e/diff/oas3-webhook-payload-property-removed/revision.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: webhook-payload-property-removed + version: '1.0' +paths: {} +webhooks: + newPet: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + '200': + description: Acknowledged diff --git a/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt b/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt new file mode 100644 index 0000000000..747856e588 --- /dev/null +++ b/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt @@ -0,0 +1,11 @@ +webhooks + ✖ breaking removed webhooks/newPet/post/requestBody/content/application~1json/schema/properties/tag + Schema property was removed. (property-removed-from-response) + at base.yaml:18:19 + +1 breaking, 0 non-breaking. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 breaking change. diff --git a/tests/e2e/diff/rules.test.ts b/tests/e2e/diff/rules.test.ts new file mode 100644 index 0000000000..9ebbb318b8 --- /dev/null +++ b/tests/e2e/diff/rules.test.ts @@ -0,0 +1,270 @@ +import { join } from 'node:path'; + +import { fixturePath, runDiff, runJsonDiff } from './helpers.js'; + +type RuleCase = { fixture: string; ruleId: string; describes: string }; + +/** + * One fixture per rule, each a base/revision pair differing only in what it exercises. + * A rule is registered per node type, so running the real command is the only way to + * catch a rule wired to a type the walker never reports. + */ +const OPENAPI: RuleCase[] = [ + { fixture: 'oas3-path-removed', ruleId: 'path-removed', describes: 'a path disappears' }, + { + fixture: 'oas3-operation-removed', + ruleId: 'operation-removed', + describes: 'an operation disappears', + }, + { + fixture: 'oas3-parameter-removed', + ruleId: 'parameter-removed', + describes: 'a query parameter disappears', + }, + { + fixture: 'oas3-parameter-removed-last', + ruleId: 'parameter-removed', + describes: 'the last parameter leaves with the whole `parameters` list', + }, + { + fixture: 'oas3-parameter-added-required', + ruleId: 'parameter-added-required', + describes: 'a new required query parameter appears', + }, + { + fixture: 'oas3-parameter-became-required', + ruleId: 'parameter-became-required', + describes: 'an optional query parameter becomes required', + }, + { + fixture: 'oas3-parameter-serialization-changed', + ruleId: 'parameter-serialization-changed', + describes: 'an array parameter changes its serialization style', + }, + { + fixture: 'oas3-response-removed', + ruleId: 'response-removed', + describes: 'a response disappears', + }, + { + fixture: 'oas3-response-header-removed', + ruleId: 'response-header-removed', + describes: 'a response header disappears', + }, + { + fixture: 'oas3-media-type-removed', + ruleId: 'media-type-removed', + describes: 'a response drops one of its media types', + }, + { + fixture: 'oas3-request-body-became-required', + ruleId: 'request-body-became-required', + describes: 'an optional request body becomes required', + }, + { + fixture: 'oas3-request-body-removed', + ruleId: 'request-body-removed', + describes: 'the request body disappears', + }, + { + fixture: 'oas3-property-removed-from-response', + ruleId: 'property-removed-from-response', + describes: 'a response property disappears', + }, + { + fixture: 'oas3-webhook-payload-property-removed', + // A webhook body travels to the consumer, so it is judged as a response. + ruleId: 'property-removed-from-response', + describes: 'a webhook payload drops a property', + }, + { + fixture: 'oas3-required-properties-added', + ruleId: 'required-properties-added', + describes: 'a request payload requires one more property', + }, + { + fixture: 'oas3-required-properties-removed', + ruleId: 'required-properties-removed', + describes: 'a response property stops being required', + }, + { + fixture: 'oas3-enum-values-removed', + ruleId: 'enum-values-removed', + describes: 'a request enum drops an accepted value', + }, + { + fixture: 'oas3-enum-values-added', + ruleId: 'enum-values-added', + describes: 'a response enum gains a value', + }, + { + fixture: 'oas3-nullability-changed', + // Nullability rides on the type rule today; a dedicated id would be a refinement. + ruleId: 'schema-type-changed', + describes: 'a request property stops accepting null', + }, + { + fixture: 'oas3-string-length-changed', + ruleId: 'string-length-changed', + describes: 'maxLength shrinks on a request property', + }, + { + fixture: 'oas3-numeric-range-changed', + ruleId: 'numeric-range-changed', + describes: 'minimum rises on a request property', + }, + { + fixture: 'oas3-schema-format-changed', + ruleId: 'schema-format-changed', + describes: 'a request property gains a format constraint', + }, + { + fixture: 'oas3-additional-properties-changed', + ruleId: 'additional-properties-changed', + describes: 'a request object stops accepting extra properties', + }, + { + fixture: 'oas3-schema-combinator-changed', + ruleId: 'schema-combinator-changed', + describes: 'a request oneOf drops an accepted subschema', + }, + { + fixture: 'oas3-ref-target-changed', + ruleId: 'ref-target-changed', + describes: 'a $ref points at another schema', + }, + { + fixture: 'oas3-security-requirement-added', + ruleId: 'security-requirement-added', + describes: 'an open operation starts requiring authentication', + }, + { + fixture: 'oas3-security-requirement-added-to-empty-list', + ruleId: 'security-requirement-added', + describes: 'an explicitly empty `security` list gets its first entry', + }, + { + fixture: 'oas3-security-scheme-changed', + ruleId: 'security-scheme-changed', + describes: 'a security scheme switches from apiKey to bearer', + }, + { + fixture: 'oas3-security-scheme-removed', + ruleId: 'security-scheme-removed', + describes: 'a security scheme disappears', + }, + { + fixture: 'oas3-security-scopes-added', + ruleId: 'security-scopes-added', + describes: 'a security requirement demands one more scope', + }, +]; + +/** + * AsyncAPI 3 fixtures. The last two carry no AsyncAPI-specific rule: they prove that a + * payload is judged by the schema rules, in the direction the operation's `action` + * declares. + */ +const ASYNCAPI: RuleCase[] = [ + { + fixture: 'async3-channel-removed', + ruleId: 'channel-removed', + describes: 'a channel disappears', + }, + { + fixture: 'async3-channel-address-changed', + ruleId: 'channel-address-changed', + describes: 'a channel moves to another address', + }, + { + fixture: 'async3-message-removed', + ruleId: 'message-removed', + describes: 'a channel drops one of its messages', + }, + { + fixture: 'async3-message-content-type-changed', + ruleId: 'message-content-type-changed', + describes: 'a message switches from JSON to Avro', + }, + { + fixture: 'async3-operation-action-changed', + ruleId: 'operation-action-changed', + describes: 'an operation starts sending where it used to receive', + }, + { + fixture: 'async3-server-removed', + ruleId: 'server-removed', + describes: 'a server disappears', + }, + { + fixture: 'async3-payload-required-added', + ruleId: 'required-properties-added', + describes: 'a received payload requires one more property', + }, + { + fixture: 'async3-payload-property-removed', + ruleId: 'property-removed-from-response', + describes: 'a sent payload drops a property', + }, +]; + +/** + * Edits that must NOT be reported as breaking. Each one is the mirror of a rule above, + * so a rule that stops reading the direction — or the polarity of the node — fails here + * instead of passing everywhere. + */ +const NON_BREAKING: { fixture: string; describes: string }[] = [ + { + fixture: 'oas3-parameter-added-optional', + describes: 'a new optional query parameter appears', + }, + { + fixture: 'oas3-enum-values-added-to-request', + describes: 'a request enum accepts one more value', + }, + { + fixture: 'oas3-schema-type-widened-in-request', + describes: 'a request property accepts more types than before', + }, + { + fixture: 'oas3-nullable-equivalence-across-versions', + describes: "3.0 `nullable: true` and 3.1 `type: [.., 'null']` describe the same schema", + }, + { + fixture: 'oas3-path-parameter-renamed', + describes: 'a path parameter is renamed on both the path and the parameter', + }, + { + fixture: 'async3-sent-payload-required-added', + describes: 'a sent payload requires one more property', + }, +]; + +describe('diff rules', () => { + for (const { fixture, ruleId, describes } of [...OPENAPI, ...ASYNCAPI]) { + test(`${ruleId}: ${describes}`, async () => { + // The verdict is read off the machine-readable report, so a change of wording in + // the terminal output cannot quietly stop the rule from being exercised. + const breaking = runJsonDiff(fixture).changes.filter( + (change) => change.compat === 'breaking' + ); + expect(breaking.flatMap((change) => change.verdicts ?? []).map((v) => v.ruleId)).toContain( + ruleId + ); + + await expect(runDiff(fixture)).toMatchFileSnapshot( + join(fixturePath(fixture), 'snapshot.txt') + ); + }); + } + + for (const { fixture, describes } of NON_BREAKING) { + test(`no breaking change when ${describes}`, async () => { + expect(runJsonDiff(fixture).summary.breaking).toBe(0); + + await expect(runDiff(fixture)).toMatchFileSnapshot( + join(fixturePath(fixture), 'snapshot.txt') + ); + }); + } +});