From 92f50e5b30f61a206fbda5d620e1aee9823c0a9c Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:06:54 +0300 Subject: [PATCH 01/57] docs: add diff command design spec Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-07-07-diff-command-design.md | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-07-diff-command-design.md 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..2ee60e6510 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-diff-command-design.md @@ -0,0 +1,303 @@ +# `redocly diff` — Design + +- **Date:** 2026-07-07 +- **Status:** Approved for implementation planning +- **Scope:** New CLI command that compares two API descriptions and reports structural changes, with breaking-change classification for OpenAPI 3.x. + +## 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. + +### 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 + --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 +``` + +Core (stages 3–5) lives in `packages/core/src/diff/`; the command and serializers live in `packages/cli/src/commands/diff/`. + +## 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 +} + +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 — public contract from day one + 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, + parameterInChanged, + ], + 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`, `parameter-in-changed`, `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). + +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/core/src/diff/ + index.ts # diffDocuments() orchestrator + types.ts # NodeEntry, Change, DiffResult, Compat, DiffRule + 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/ # one file per rule (lint-rule idiom) + __tests__/ + +packages/cli/src/commands/diff/ + index.ts # handleDiff: resolve sides, call core, serialize, exit code + serializers/ + stylish.ts json.ts markdown.ts html.ts + __tests__/ + +docs/commands/diff.md +``` + +## 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). + +## 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. From a2caa2723887857816a863a60762f83add8d3ec2 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:22:36 +0300 Subject: [PATCH 02/57] docs: add diff command implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-07-diff-command.md | 3078 +++++++++++++++++ 1 file changed, 3078 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-diff-command.md 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..f1fb1ecbc4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-diff-command.md @@ -0,0 +1,3078 @@ +# `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`. 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 { isRef } from '../ref-utils.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. +- **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/core/src/diff/types.ts` +- Test: `packages/core/src/diff/__tests__/types.test.ts` + +**Interfaces:** + +- Consumes: `SpecVersion` from `../oas-types.js`. +- 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/core/src/diff/__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/core/src/diff/__tests__/types.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../types.js`. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/core/src/diff/types.ts +import type { SpecVersion } from '../oas-types.js'; + +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/core/src/diff/__tests__/types.test.ts --coverage.enabled=false` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/diff/types.ts packages/core/src/diff/__tests__/types.test.ts +git commit -m "feat(core): add diff data model and verdict helpers" +``` + +--- + +### Task 2: Predicates library + +**Files:** + +- Create: `packages/core/src/diff/predicates.ts` +- Test: `packages/core/src/diff/__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/core/src/diff/__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/core/src/diff/__tests__/predicates.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../predicates.js`. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/core/src/diff/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/core/src/diff/__tests__/predicates.test.ts --coverage.enabled=false` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/diff/predicates.ts packages/core/src/diff/__tests__/predicates.test.ts +git commit -m "feat(core): add diff predicate helpers" +``` + +--- + +### Task 3: Identity registry + +**Files:** + +- Create: `packages/core/src/diff/node-identity.ts` +- Test: `packages/core/src/diff/__tests__/node-identity.test.ts` + +**Interfaces:** + +- Consumes: `isPlainObject` from `../utils/is-plain-object.js`. +- 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/core/src/diff/__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/core/src/diff/__tests__/node-identity.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../node-identity.js`. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/core/src/diff/node-identity.ts +import { isPlainObject } from '../utils/is-plain-object.js'; + +// 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/core/src/diff/__tests__/node-identity.test.ts --coverage.enabled=false` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/diff/node-identity.ts packages/core/src/diff/__tests__/node-identity.test.ts +git commit -m "feat(core): add diff list-item identity registry" +``` + +--- + +### Task 4: Collection — document → flat map + +**Files:** + +- Create: `packages/core/src/diff/collect.ts` +- Test: `packages/core/src/diff/__tests__/collect.test.ts` + +**Interfaces:** + +- Consumes: `walkDocument`, `type WalkContext`, `type UserContext` from `../walk.js`; `normalizeVisitors` from `../visitors.js`; `isRef` from `../ref-utils.js`; `isPlainObject` from `../utils/is-plain-object.js`; `getIdentityKey` (Task 3); `isScalar`, `isScalarArray` (Task 2); `NodeEntry` (Task 1); `Document` from `../resolve.js`; `NormalizedNodeType` from `../types/index.js`; `Config` from `../config/index.js`; `SpecVersion` from `../oas-types.js`. +- 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/core/src/diff/__tests__/collect.test.ts +import { outdent } from 'outdent'; + +import { parseYamlToDocument } from '../../../__tests__/utils.js'; +import { createConfig } from '../../config/index.js'; +import { detectSpec } from '../../detect-spec.js'; +import { getTypes } from '../../oas-types.js'; +import { normalizeTypes } from '../../types/index.js'; +import { collectDocumentMap } from '../collect.js'; + +async function collect(yaml: string) { + const document = parseYamlToDocument(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/core/src/diff/__tests__/collect.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../collect.js`. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/core/src/diff/collect.ts +import { isRef } from '../ref-utils.js'; +import { isPlainObject } from '../utils/is-plain-object.js'; +import { normalizeVisitors } from '../visitors.js'; +import { walkDocument } from '../walk.js'; +import { getIdentityKey } from './node-identity.js'; +import { isScalar, isScalarArray } from './predicates.js'; + +import type { Config } from '../config/index.js'; +import type { SpecVersion } from '../oas-types.js'; +import type { Document } from '../resolve.js'; +import type { NormalizedNodeType } from '../types/index.js'; +import type { UserContext, WalkContext } from '../walk.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; + } + + 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/core/src/diff/__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 core diff test suite and typecheck** + +Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff --coverage.enabled=false && npm run typecheck` +Expected: all tests PASS; no type errors. + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/src/diff/collect.ts packages/core/src/diff/__tests__/collect.test.ts +git commit -m "feat(core): collect documents into flat stable-pointer maps for diff" +``` + +--- + +### Task 5: Compare — two maps → RawChange[] + +**Files:** + +- Create: `packages/core/src/diff/compare.ts` +- Test: `packages/core/src/diff/__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/core/src/diff/__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/core/src/diff/__tests__/compare.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../compare.js`. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/core/src/diff/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/core/src/diff/__tests__/compare.test.ts --coverage.enabled=false` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/diff/compare.ts packages/core/src/diff/__tests__/compare.test.ts +git commit -m "feat(core): add two-pass flat map comparison for diff" +``` + +--- + +### Task 6: Usage index and polarity + +**Files:** + +- Create: `packages/core/src/diff/classify/usage.ts` +- Create: `packages/core/src/diff/classify/polarity.ts` +- Test: `packages/core/src/diff/__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/core/src/diff/__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/core/src/diff/__tests__/polarity.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../classify/polarity.js`. + +- [ ] **Step 3: Write the implementation (both files)** + +```ts +// packages/core/src/diff/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/core/src/diff/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/core/src/diff/__tests__/polarity.test.ts --coverage.enabled=false` +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/diff/classify/usage.ts packages/core/src/diff/classify/polarity.ts packages/core/src/diff/__tests__/polarity.test.ts +git commit -m "feat(core): add diff polarity engine with component usage index" +``` + +--- + +### Task 7: Classification engine + first rules (operation, path) + +**Files:** + +- Create: `packages/core/src/diff/classify/index.ts` +- Create: `packages/core/src/diff/classify/rules/operation-rules.ts` +- Create: `packages/core/src/diff/classify/oas3.ts` +- Create: `packages/core/src/diff/classify/oas3_1.ts` +- Test: `packages/core/src/diff/__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/core/src/diff/__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/core/src/diff/__tests__/classify.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../classify/index.js`. + +- [ ] **Step 3: Write the implementation (four files)** + +```ts +// packages/core/src/diff/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/core/src/diff/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/core/src/diff/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/core/src/diff/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 '../../oas-types.js'; +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/core/src/diff/__tests__/classify.test.ts --coverage.enabled=false` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/diff/classify packages/core/src/diff/__tests__/classify.test.ts +git commit -m "feat(core): add diff classification engine with worst-verdict policy" +``` + +--- + +### Task 8: Parameter, response, and media-type rules + +**Files:** + +- Create: `packages/core/src/diff/classify/rules/parameter-rules.ts` +- Create: `packages/core/src/diff/classify/rules/response-rules.ts` +- Modify: `packages/core/src/diff/classify/oas3.ts` +- Test: `packages/core/src/diff/__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/core/src/diff/__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/core/src/diff/__tests__/rules-parameter-response.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve rule modules. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/core/src/diff/classify/rules/parameter-rules.ts +import { isPlainObject } from '../../../utils/is-plain-object.js'; +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/core/src/diff/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/core/src/diff/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/core/src/diff/__tests__/rules-parameter-response.test.ts packages/core/src/diff/__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/core/src/diff/classify packages/core/src/diff/__tests__/rules-parameter-response.test.ts +git commit -m "feat(core): add diff parameter and response breaking rules" +``` + +--- + +### Task 9: Schema rules and ref-target rule + +**Files:** + +- Create: `packages/core/src/diff/classify/rules/schema-rules.ts` +- Create: `packages/core/src/diff/classify/rules/ref-rules.ts` +- Modify: `packages/core/src/diff/classify/oas3.ts` +- Test: `packages/core/src/diff/__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/core/src/diff/__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/core/src/diff/__tests__/rules-schema.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve rule modules. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/core/src/diff/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/core/src/diff/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/core/src/diff/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/core/src/diff --coverage.enabled=false` +Expected: PASS — all diff tests, including earlier tasks'. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/diff/classify packages/core/src/diff/__tests__/rules-schema.test.ts +git commit -m "feat(core): add diff schema and ref-target breaking rules" +``` + +--- + +### Task 10: Orchestrator `diffDocuments` + output schema + public export + +**Files:** + +- Create: `packages/core/src/diff/index.ts` +- Create: `packages/core/src/diff/output-schema.ts` +- Modify: `packages/core/src/index.ts` (add exports at the end of the file) +- Test: `packages/core/src/diff/__tests__/diff-documents.test.ts` + +**Interfaces:** + +- Consumes: everything above; `detectSpec`, `getMajorSpecVersion` from `../detect-spec.js`; `getTypes` from `../oas-types.js`; `normalizeTypes` from `../types/index.js`. +- Produces: + - `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`. + - Core public exports: `diffDocuments`, `DiffError`, `DIFF_OUTPUT_SCHEMA`, types `DiffResult`, `Change`, `Compat`. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/core/src/diff/__tests__/diff-documents.test.ts +import Ajv from '@redocly/ajv/dist/2020.js'; +import { outdent } from 'outdent'; + +import { parseYamlToDocument } from '../../../__tests__/utils.js'; +import { createConfig } from '../../config/index.js'; +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: parseYamlToDocument(BASE, ''), + revision: parseYamlToDocument(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: parseYamlToDocument(BASE, ''), + revision: parseYamlToDocument(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 = parseYamlToDocument( + outdent` + swagger: '2.0' + info: { title: Test, version: '1.0' } + paths: {} + `, + '' + ); + expect(() => + diffDocuments({ base: oas2, revision: parseYamlToDocument(REVISION, ''), config }) + ).toThrow(DiffError); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/diff-documents.test.ts --coverage.enabled=false` +Expected: FAIL — cannot resolve `../index.js` / `../output-schema.js`. + +- [ ] **Step 3: Write the implementation** + +```ts +// packages/core/src/diff/index.ts +import { detectSpec, getMajorSpecVersion } from '../detect-spec.js'; +import { getTypes } from '../oas-types.js'; +import { normalizeTypes } from '../types/index.js'; +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 } from '../config/index.js'; +import type { SpecVersion } from '../oas-types.js'; +import type { Document } from '../resolve.js'; +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/core/src/diff/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; +``` + +Add to the END of `packages/core/src/index.ts`: + +```ts +export { diffDocuments, DiffError } from './diff/index.js'; +export { DIFF_OUTPUT_SCHEMA } from './diff/output-schema.js'; +export type { DiffResult, Change, ChangeSide, Compat, DiffSummary } from './diff/types.js'; +``` + +- [ ] **Step 4: Run test to verify it passes, then typecheck** + +Run: `VITEST_SUITE=unit npx vitest run packages/core/src/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/core/src/diff packages/core/src/index.ts +git commit -m "feat(core): 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: `diffDocuments`, `DiffError`, `bundle`, `logger`, types from `@redocly/openapi-core`; `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 '@redocly/openapi-core'; + +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 '@redocly/openapi-core'; + +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 '@redocly/openapi-core'; + +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 { DiffError, bundle, diffDocuments, logger } from '@redocly/openapi-core'; + +import { AbortFlowError, exitWithError } from '../../utils/error.js'; +import { getFallbackApisOrExit } from '../../utils/miscellaneous.js'; +import { jsonDiff } from './serializers/json.js'; +import { stylishDiff } from './serializers/stylish.js'; + +import type { DiffResult } from '@redocly/openapi-core'; +import type { VerifyConfigOptions } from '../../types.js'; +import type { CommandArgs } from '../../wrapper.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.', + (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 '@redocly/openapi-core'; + +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 core 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. Content for `docs/@v2/commands/diff.md`: + +````markdown +# diff + +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: Create the changeset** + +```markdown + +--- +'@redocly/openapi-core': minor +'@redocly/cli': minor +--- + +Added the `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. +```` + +(Remove the `` comment line — changeset files start directly with the `---` front matter.) + +- [ ] **Step 6: Run the full verification** + +Run: `npm run typecheck && VITEST_SUITE=unit npx vitest run packages/core/src/diff packages/cli/src/commands/diff --coverage.enabled=false && VITEST_SUITE=e2e npx vitest run tests/e2e/diff` +Expected: everything PASSES. + +- [ ] **Step 7: 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/core/src/diff 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). From 5c921b733638e49774eab7cacb0d61058951fef6 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:35:56 +0300 Subject: [PATCH 03/57] docs: isolate diff engine in CLI package, mark command experimental Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-07-diff-command.md | 347 +++++++++--------- .../specs/2026-07-07-diff-command-design.md | 63 ++-- 2 files changed, 206 insertions(+), 204 deletions(-) diff --git a/docs/superpowers/plans/2026-07-07-diff-command.md b/docs/superpowers/plans/2026-07-07-diff-command.md index f1fb1ecbc4..d1ee783921 100644 --- a/docs/superpowers/plans/2026-07-07-diff-command.md +++ b/docs/superpowers/plans/2026-07-07-diff-command.md @@ -4,17 +4,19 @@ **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`. Spec: `docs/superpowers/specs/2026-07-07-diff-command-design.md`. +**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 { isRef } from '../ref-utils.js'`). +- **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. --- @@ -23,18 +25,18 @@ **Files:** -- Create: `packages/core/src/diff/types.ts` -- Test: `packages/core/src/diff/__tests__/types.test.ts` +- Create: `packages/cli/src/commands/diff/engine/types.ts` +- Test: `packages/cli/src/commands/diff/engine/__tests__/types.test.ts` **Interfaces:** -- Consumes: `SpecVersion` from `../oas-types.js`. +- 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/core/src/diff/__tests__/types.test.ts +// packages/cli/src/commands/diff/engine/__tests__/types.test.ts import { worstOf, compatRank, breaking, warning } from '../types.js'; describe('diff types helpers', () => { @@ -58,14 +60,14 @@ describe('diff types helpers', () => { - [ ] **Step 2: Run test to verify it fails** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/types.test.ts --coverage.enabled=false` +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/core/src/diff/types.ts -import type { SpecVersion } from '../oas-types.js'; +// packages/cli/src/commands/diff/engine/types.ts +import type { SpecVersion } from '@redocly/openapi-core'; export type Compat = 'breaking' | 'warning' | 'non-breaking'; @@ -157,14 +159,14 @@ export function warning(message: string): Verdict { - [ ] **Step 4: Run test to verify it passes** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/types.test.ts --coverage.enabled=false` +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/core/src/diff/types.ts packages/core/src/diff/__tests__/types.test.ts -git commit -m "feat(core): add diff data model and verdict helpers" +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" ``` --- @@ -173,8 +175,8 @@ git commit -m "feat(core): add diff data model and verdict helpers" **Files:** -- Create: `packages/core/src/diff/predicates.ts` -- Test: `packages/core/src/diff/__tests__/predicates.test.ts` +- Create: `packages/cli/src/commands/diff/engine/predicates.ts` +- Test: `packages/cli/src/commands/diff/engine/__tests__/predicates.test.ts` **Interfaces:** @@ -183,7 +185,7 @@ git commit -m "feat(core): add diff data model and verdict helpers" - [ ] **Step 1: Write the failing test** ```ts -// packages/core/src/diff/__tests__/predicates.test.ts +// packages/cli/src/commands/diff/engine/__tests__/predicates.test.ts import { isScalar, isScalarArray, @@ -248,13 +250,13 @@ describe('diff predicates', () => { - [ ] **Step 2: Run test to verify it fails** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/predicates.test.ts --coverage.enabled=false` +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/core/src/diff/predicates.ts +// packages/cli/src/commands/diff/engine/predicates.ts export function isScalar(value: unknown): boolean { return ( @@ -308,14 +310,14 @@ export function isTypeWidened(before: unknown, after: unknown): boolean { - [ ] **Step 4: Run test to verify it passes** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/predicates.test.ts --coverage.enabled=false` +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/core/src/diff/predicates.ts packages/core/src/diff/__tests__/predicates.test.ts -git commit -m "feat(core): add diff predicate helpers" +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" ``` --- @@ -324,18 +326,18 @@ git commit -m "feat(core): add diff predicate helpers" **Files:** -- Create: `packages/core/src/diff/node-identity.ts` -- Test: `packages/core/src/diff/__tests__/node-identity.test.ts` +- 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 `../utils/is-plain-object.js`. +- 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/core/src/diff/__tests__/node-identity.test.ts +// packages/cli/src/commands/diff/engine/__tests__/node-identity.test.ts import { getIdentityKey } from '../node-identity.js'; describe('getIdentityKey', () => { @@ -367,14 +369,14 @@ describe('getIdentityKey', () => { - [ ] **Step 2: Run test to verify it fails** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/node-identity.test.ts --coverage.enabled=false` +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/core/src/diff/node-identity.ts -import { isPlainObject } from '../utils/is-plain-object.js'; +// 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 { @@ -404,14 +406,14 @@ export function getIdentityKey(typeName: string, value: unknown): string | undef - [ ] **Step 4: Run test to verify it passes** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/node-identity.test.ts --coverage.enabled=false` +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/core/src/diff/node-identity.ts packages/core/src/diff/__tests__/node-identity.test.ts -git commit -m "feat(core): add diff list-item identity registry" +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" ``` --- @@ -420,12 +422,12 @@ git commit -m "feat(core): add diff list-item identity registry" **Files:** -- Create: `packages/core/src/diff/collect.ts` -- Test: `packages/core/src/diff/__tests__/collect.test.ts` +- Create: `packages/cli/src/commands/diff/engine/collect.ts` +- Test: `packages/cli/src/commands/diff/engine/__tests__/collect.test.ts` **Interfaces:** -- Consumes: `walkDocument`, `type WalkContext`, `type UserContext` from `../walk.js`; `normalizeVisitors` from `../visitors.js`; `isRef` from `../ref-utils.js`; `isPlainObject` from `../utils/is-plain-object.js`; `getIdentityKey` (Task 3); `isScalar`, `isScalarArray` (Task 2); `NodeEntry` (Task 1); `Document` from `../resolve.js`; `NormalizedNodeType` from `../types/index.js`; `Config` from `../config/index.js`; `SpecVersion` from `../oas-types.js`. +- 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. @@ -433,18 +435,20 @@ git commit -m "feat(core): add diff list-item identity registry" - [ ] **Step 1: Write the failing test** ```ts -// packages/core/src/diff/__tests__/collect.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 { parseYamlToDocument } from '../../../__tests__/utils.js'; -import { createConfig } from '../../config/index.js'; -import { detectSpec } from '../../detect-spec.js'; -import { getTypes } from '../../oas-types.js'; -import { normalizeTypes } from '../../types/index.js'; import { collectDocumentMap } from '../collect.js'; async function collect(yaml: string) { - const document = parseYamlToDocument(yaml, ''); + const document = makeDocumentFromString(yaml, ''); const config = await createConfig({}); const specVersion = detectSpec(document.parsed); const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); @@ -569,25 +573,26 @@ describe('collectDocumentMap', () => { - [ ] **Step 2: Run test to verify it fails** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/collect.test.ts --coverage.enabled=false` +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/core/src/diff/collect.ts -import { isRef } from '../ref-utils.js'; -import { isPlainObject } from '../utils/is-plain-object.js'; -import { normalizeVisitors } from '../visitors.js'; -import { walkDocument } from '../walk.js'; +// 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 } from '../config/index.js'; -import type { SpecVersion } from '../oas-types.js'; -import type { Document } from '../resolve.js'; -import type { NormalizedNodeType } from '../types/index.js'; -import type { UserContext, WalkContext } from '../walk.js'; +import type { + Config, + Document, + NormalizedNodeType, + SpecVersion, + UserContext, + WalkContext, +} from '@redocly/openapi-core'; import type { NodeEntry } from './types.js'; export interface CollectedDocument { @@ -695,21 +700,21 @@ function splitPointer(pointer: string): { parentReal: string | null; segment: st - [ ] **Step 4: Run test to verify it passes** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/collect.test.ts --coverage.enabled=false` +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 core diff test suite and typecheck** +- [ ] **Step 5: Run the full diff test suite and typecheck** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff --coverage.enabled=false && npm run 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/core/src/diff/collect.ts packages/core/src/diff/__tests__/collect.test.ts -git commit -m "feat(core): collect documents into flat stable-pointer maps for diff" +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" ``` --- @@ -718,8 +723,8 @@ git commit -m "feat(core): collect documents into flat stable-pointer maps for d **Files:** -- Create: `packages/core/src/diff/compare.ts` -- Test: `packages/core/src/diff/__tests__/compare.test.ts` +- Create: `packages/cli/src/commands/diff/engine/compare.ts` +- Test: `packages/cli/src/commands/diff/engine/__tests__/compare.test.ts` **Interfaces:** @@ -731,7 +736,7 @@ git commit -m "feat(core): collect documents into flat stable-pointer maps for d - [ ] **Step 1: Write the failing test** ```ts -// packages/core/src/diff/__tests__/compare.test.ts +// packages/cli/src/commands/diff/engine/__tests__/compare.test.ts import { compareMaps } from '../compare.js'; import type { NodeEntry } from '../types.js'; @@ -868,13 +873,13 @@ describe('compareMaps', () => { - [ ] **Step 2: Run test to verify it fails** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/compare.test.ts --coverage.enabled=false` +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/core/src/diff/compare.ts +// packages/cli/src/commands/diff/engine/compare.ts import { scalarEquals } from './predicates.js'; import type { NodeEntry, RawChange } from './types.js'; @@ -971,14 +976,14 @@ export function compareMaps( - [ ] **Step 4: Run test to verify it passes** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/compare.test.ts --coverage.enabled=false` +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/core/src/diff/compare.ts packages/core/src/diff/__tests__/compare.test.ts -git commit -m "feat(core): add two-pass flat map comparison for diff" +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" ``` --- @@ -987,9 +992,9 @@ git commit -m "feat(core): add two-pass flat map comparison for diff" **Files:** -- Create: `packages/core/src/diff/classify/usage.ts` -- Create: `packages/core/src/diff/classify/polarity.ts` -- Test: `packages/core/src/diff/__tests__/polarity.test.ts` +- 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:** @@ -1001,7 +1006,7 @@ git commit -m "feat(core): add two-pass flat map comparison for diff" - [ ] **Step 1: Write the failing test** ```ts -// packages/core/src/diff/__tests__/polarity.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'; @@ -1093,13 +1098,13 @@ describe('getPolarity', () => { - [ ] **Step 2: Run test to verify it fails** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/polarity.test.ts --coverage.enabled=false` +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/core/src/diff/classify/usage.ts +// packages/cli/src/commands/diff/engine/classify/usage.ts import type { Polarity } from '../types.js'; export function getComponentRoot(pointer: string): string | undefined { @@ -1148,7 +1153,7 @@ export class UsageIndex { ``` ```ts -// packages/core/src/diff/classify/polarity.ts +// packages/cli/src/commands/diff/engine/classify/polarity.ts import { getComponentRoot, type UsageIndex } from './usage.js'; import type { Polarity } from '../types.js'; @@ -1181,14 +1186,14 @@ function getSitePolarity(pointer: string): Polarity { - [ ] **Step 4: Run test to verify it passes** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/polarity.test.ts --coverage.enabled=false` +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/core/src/diff/classify/usage.ts packages/core/src/diff/classify/polarity.ts packages/core/src/diff/__tests__/polarity.test.ts -git commit -m "feat(core): add diff polarity engine with component usage index" +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" ``` --- @@ -1197,11 +1202,11 @@ git commit -m "feat(core): add diff polarity engine with component usage index" **Files:** -- Create: `packages/core/src/diff/classify/index.ts` -- Create: `packages/core/src/diff/classify/rules/operation-rules.ts` -- Create: `packages/core/src/diff/classify/oas3.ts` -- Create: `packages/core/src/diff/classify/oas3_1.ts` -- Test: `packages/core/src/diff/__tests__/classify.test.ts` +- 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:** @@ -1216,7 +1221,7 @@ git commit -m "feat(core): add diff polarity engine with component usage index" - [ ] **Step 1: Write the failing test** ```ts -// packages/core/src/diff/__tests__/classify.test.ts +// packages/cli/src/commands/diff/engine/__tests__/classify.test.ts import { classifyChanges } from '../classify/index.js'; import { UsageIndex } from '../classify/usage.js'; @@ -1304,13 +1309,13 @@ describe('classifyChanges', () => { - [ ] **Step 2: Run test to verify it fails** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/classify.test.ts --coverage.enabled=false` +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/core/src/diff/classify/rules/operation-rules.ts +// packages/cli/src/commands/diff/engine/classify/rules/operation-rules.ts import { breaking } from '../../types.js'; import type { DiffRule } from '../../types.js'; @@ -1335,7 +1340,7 @@ export const pathRemoved: DiffRule = { ``` ```ts -// packages/core/src/diff/classify/oas3.ts +// packages/cli/src/commands/diff/engine/classify/oas3.ts import { operationRemoved, pathRemoved } from './rules/operation-rules.js'; import type { DiffRuleRegistry } from '../types.js'; @@ -1347,7 +1352,7 @@ export const oas3Rules: DiffRuleRegistry = { ``` ```ts -// packages/core/src/diff/classify/oas3_1.ts +// packages/cli/src/commands/diff/engine/classify/oas3_1.ts import { oas3Rules } from './oas3.js'; import type { DiffRuleRegistry } from '../types.js'; @@ -1357,13 +1362,13 @@ export const oas3_1Rules: DiffRuleRegistry = { ...oas3Rules }; ``` ```ts -// packages/core/src/diff/classify/index.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 '../../oas-types.js'; +import type { SpecVersion } from '@redocly/openapi-core'; import type { Change, DiffRuleRegistry, @@ -1428,14 +1433,14 @@ export function classifyChanges(opts: { - [ ] **Step 4: Run test to verify it passes** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/classify.test.ts --coverage.enabled=false` +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/core/src/diff/classify packages/core/src/diff/__tests__/classify.test.ts -git commit -m "feat(core): add diff classification engine with worst-verdict policy" +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" ``` --- @@ -1444,10 +1449,10 @@ git commit -m "feat(core): add diff classification engine with worst-verdict pol **Files:** -- Create: `packages/core/src/diff/classify/rules/parameter-rules.ts` -- Create: `packages/core/src/diff/classify/rules/response-rules.ts` -- Modify: `packages/core/src/diff/classify/oas3.ts` -- Test: `packages/core/src/diff/__tests__/rules-parameter-response.test.ts` +- 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:** @@ -1458,7 +1463,7 @@ git commit -m "feat(core): add diff classification engine with worst-verdict pol - [ ] **Step 1: Write the failing test** ```ts -// packages/core/src/diff/__tests__/rules-parameter-response.test.ts +// packages/cli/src/commands/diff/engine/__tests__/rules-parameter-response.test.ts import { parameterAddedRequired, parameterBecameRequired, @@ -1546,14 +1551,15 @@ describe('response rules', () => { - [ ] **Step 2: Run test to verify it fails** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/rules-parameter-response.test.ts --coverage.enabled=false` +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/core/src/diff/classify/rules/parameter-rules.ts -import { isPlainObject } from '../../../utils/is-plain-object.js'; +// 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'; @@ -1593,7 +1599,7 @@ export const parameterBecameRequired: DiffRule = { ``` ```ts -// packages/core/src/diff/classify/rules/response-rules.ts +// packages/cli/src/commands/diff/engine/classify/rules/response-rules.ts import { breaking } from '../../types.js'; import type { DiffRule } from '../../types.js'; @@ -1620,7 +1626,7 @@ export const mediaTypeRemoved: DiffRule = { Update the registry: ```ts -// packages/core/src/diff/classify/oas3.ts +// packages/cli/src/commands/diff/engine/classify/oas3.ts import { operationRemoved, pathRemoved } from './rules/operation-rules.js'; import { parameterAddedRequired, @@ -1642,14 +1648,14 @@ export const oas3Rules: DiffRuleRegistry = { - [ ] **Step 4: Run test to verify it passes** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/rules-parameter-response.test.ts packages/core/src/diff/__tests__/classify.test.ts --coverage.enabled=false` +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/core/src/diff/classify packages/core/src/diff/__tests__/rules-parameter-response.test.ts -git commit -m "feat(core): add diff parameter and response breaking rules" +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" ``` --- @@ -1658,10 +1664,10 @@ git commit -m "feat(core): add diff parameter and response breaking rules" **Files:** -- Create: `packages/core/src/diff/classify/rules/schema-rules.ts` -- Create: `packages/core/src/diff/classify/rules/ref-rules.ts` -- Modify: `packages/core/src/diff/classify/oas3.ts` -- Test: `packages/core/src/diff/__tests__/rules-schema.test.ts` +- 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:** @@ -1670,7 +1676,7 @@ git commit -m "feat(core): add diff parameter and response breaking rules" - [ ] **Step 1: Write the failing test** ```ts -// packages/core/src/diff/__tests__/rules-schema.test.ts +// packages/cli/src/commands/diff/engine/__tests__/rules-schema.test.ts import { refTargetChanged } from '../classify/rules/ref-rules.js'; import { enumValuesAdded, @@ -1801,13 +1807,13 @@ describe('ref-target-changed', () => { - [ ] **Step 2: Run test to verify it fails** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/rules-schema.test.ts --coverage.enabled=false` +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/core/src/diff/classify/rules/schema-rules.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'; @@ -1891,7 +1897,7 @@ export const propertyRemovedFromResponse: DiffRule = { ``` ```ts -// packages/core/src/diff/classify/rules/ref-rules.ts +// packages/cli/src/commands/diff/engine/classify/rules/ref-rules.ts import { warning } from '../../types.js'; import type { DiffRule } from '../../types.js'; @@ -1917,7 +1923,7 @@ export const refTargetChanged: DiffRule = { Update the registry (`refTargetChanged` is registered for every type that commonly owns refs): ```ts -// packages/core/src/diff/classify/oas3.ts +// packages/cli/src/commands/diff/engine/classify/oas3.ts import { operationRemoved, pathRemoved } from './rules/operation-rules.js'; import { parameterAddedRequired, @@ -1958,45 +1964,42 @@ export const oas3Rules: DiffRuleRegistry = { - [ ] **Step 4: Run test to verify it passes** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff --coverage.enabled=false` +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/core/src/diff/classify packages/core/src/diff/__tests__/rules-schema.test.ts -git commit -m "feat(core): add diff schema and ref-target breaking rules" +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 + public export +### Task 10: Orchestrator `diffDocuments` + output schema **Files:** -- Create: `packages/core/src/diff/index.ts` -- Create: `packages/core/src/diff/output-schema.ts` -- Modify: `packages/core/src/index.ts` (add exports at the end of the file) -- Test: `packages/core/src/diff/__tests__/diff-documents.test.ts` +- 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` from `../detect-spec.js`; `getTypes` from `../oas-types.js`; `normalizeTypes` from `../types/index.js`. -- Produces: +- 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`. - - Core public exports: `diffDocuments`, `DiffError`, `DIFF_OUTPUT_SCHEMA`, types `DiffResult`, `Change`, `Compat`. - [ ] **Step 1: Write the failing test** ```ts -// packages/core/src/diff/__tests__/diff-documents.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 { parseYamlToDocument } from '../../../__tests__/utils.js'; -import { createConfig } from '../../config/index.js'; import { DiffError, diffDocuments } from '../index.js'; import { DIFF_OUTPUT_SCHEMA } from '../output-schema.js'; @@ -2039,8 +2042,8 @@ describe('diffDocuments', () => { it('produces the running example from the design spec', async () => { const config = await createConfig({}); const result = diffDocuments({ - base: parseYamlToDocument(BASE, ''), - revision: parseYamlToDocument(REVISION, ''), + base: makeDocumentFromString(BASE, ''), + revision: makeDocumentFromString(REVISION, ''), config, }); @@ -2076,8 +2079,8 @@ describe('diffDocuments', () => { it('validates against the published output schema', async () => { const config = await createConfig({}); const result = diffDocuments({ - base: parseYamlToDocument(BASE, ''), - revision: parseYamlToDocument(REVISION, ''), + base: makeDocumentFromString(BASE, ''), + revision: makeDocumentFromString(REVISION, ''), config, }); @@ -2088,7 +2091,7 @@ describe('diffDocuments', () => { it('throws DiffError for different spec families', async () => { const config = await createConfig({}); - const oas2 = parseYamlToDocument( + const oas2 = makeDocumentFromString( outdent` swagger: '2.0' info: { title: Test, version: '1.0' } @@ -2097,7 +2100,7 @@ describe('diffDocuments', () => { '' ); expect(() => - diffDocuments({ base: oas2, revision: parseYamlToDocument(REVISION, ''), config }) + diffDocuments({ base: oas2, revision: makeDocumentFromString(REVISION, ''), config }) ).toThrow(DiffError); }); }); @@ -2105,24 +2108,21 @@ describe('diffDocuments', () => { - [ ] **Step 2: Run test to verify it fails** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff/__tests__/diff-documents.test.ts --coverage.enabled=false` +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/core/src/diff/index.ts -import { detectSpec, getMajorSpecVersion } from '../detect-spec.js'; -import { getTypes } from '../oas-types.js'; -import { normalizeTypes } from '../types/index.js'; +// 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 } from '../config/index.js'; -import type { SpecVersion } from '../oas-types.js'; -import type { Document } from '../resolve.js'; +import type { Config, Document, SpecVersion } from '@redocly/openapi-core'; import type { DiffResult, DiffSummary } from './types.js'; export class DiffError extends Error {} @@ -2185,7 +2185,7 @@ export function diffDocuments(opts: { ``` ```ts -// packages/core/src/diff/output-schema.ts +// packages/cli/src/commands/diff/engine/output-schema.ts const changeSideSchema = { type: 'object', @@ -2244,17 +2244,9 @@ export const DIFF_OUTPUT_SCHEMA = { } as const; ``` -Add to the END of `packages/core/src/index.ts`: - -```ts -export { diffDocuments, DiffError } from './diff/index.js'; -export { DIFF_OUTPUT_SCHEMA } from './diff/output-schema.js'; -export type { DiffResult, Change, ChangeSide, Compat, DiffSummary } from './diff/types.js'; -``` - - [ ] **Step 4: Run test to verify it passes, then typecheck** -Run: `VITEST_SUITE=unit npx vitest run packages/core/src/diff --coverage.enabled=false && npm run 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. @@ -2262,8 +2254,8 @@ Expected: all diff tests PASS; no type errors. - [ ] **Step 5: Commit** ```bash -git add packages/core/src/diff packages/core/src/index.ts -git commit -m "feat(core): add diffDocuments orchestrator with versioned output schema" +git add packages/cli/src/commands/diff +git commit -m "feat(cli): add diffDocuments orchestrator with versioned output schema" ``` --- @@ -2280,7 +2272,7 @@ git commit -m "feat(core): add diffDocuments orchestrator with versioned output **Interfaces:** -- Consumes: `diffDocuments`, `DiffError`, `bundle`, `logger`, types from `@redocly/openapi-core`; `getFallbackApisOrExit` from `../../utils/miscellaneous.js`; `AbortFlowError`, `exitWithError` from `../../utils/error.js`; `CommandArgs` from `../../wrapper.js`; `VerifyConfigOptions` from `../../types.js`. +- 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** @@ -2290,7 +2282,7 @@ git commit -m "feat(core): add diffDocuments orchestrator with versioned output import { jsonDiff } from '../serializers/json.js'; import { stylishDiff } from '../serializers/stylish.js'; -import type { DiffResult } from '@redocly/openapi-core'; +import type { DiffResult } from '../engine/types.js'; const RESULT: DiffResult = { version: '1', @@ -2368,7 +2360,7 @@ Expected: FAIL — cannot resolve serializer modules. ```ts // packages/cli/src/commands/diff/serializers/json.ts -import type { DiffResult } from '@redocly/openapi-core'; +import type { DiffResult } from '../engine/types.js'; export function jsonDiff(result: DiffResult): string { return JSON.stringify(result, null, 2); @@ -2379,7 +2371,7 @@ export function jsonDiff(result: DiffResult): string { // packages/cli/src/commands/diff/serializers/stylish.ts import { bold, gray, green, red, yellow } from 'colorette'; -import type { Change, Compat, DiffResult } from '@redocly/openapi-core'; +import type { Change, Compat, DiffResult } from '../engine/types.js'; const SEVERITY_ORDER: Compat[] = ['breaking', 'warning', 'non-breaking']; @@ -2429,16 +2421,17 @@ Expected: PASS (2 tests). // packages/cli/src/commands/diff/index.ts import { writeFileSync } from 'node:fs'; -import { DiffError, bundle, diffDocuments, logger } from '@redocly/openapi-core'; +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 { DiffResult } from '@redocly/openapi-core'; 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'; @@ -2514,7 +2507,7 @@ Add this `.command(...)` block adjacent to the `stats` registration (same level ```ts .command( 'diff ', - 'Compare two API descriptions and detect breaking changes.', + 'Compare two API descriptions and detect breaking changes [experimental].', (yargs) => yargs .env('REDOCLY_CLI_DIFF') @@ -2616,7 +2609,7 @@ git commit -m "feat(cli): add diff command with stylish and json formats" import { htmlDiff } from '../serializers/html.js'; import { markdownDiff } from '../serializers/markdown.js'; -import type { DiffResult } from '@redocly/openapi-core'; +import type { DiffResult } from '../engine/types.js'; const RESULT: DiffResult = { version: '1', @@ -2669,7 +2662,7 @@ Expected: FAIL — cannot resolve serializer modules. ```ts // packages/cli/src/commands/diff/serializers/markdown.ts -import type { Change, DiffResult } from '@redocly/openapi-core'; +import type { Change, DiffResult } from '../engine/types.js'; const IMPACT_LABEL: Record = { breaking: '🔴 breaking', @@ -2710,7 +2703,7 @@ export function markdownDiff(result: DiffResult): string { ```ts // packages/cli/src/commands/diff/serializers/html.ts -import type { Change, DiffResult } from '@redocly/openapi-core'; +import type { Change, DiffResult } from '../engine/types.js'; function escapeHtml(value: unknown): string { return String(value) @@ -2960,15 +2953,17 @@ describe('diff', () => { 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 core layer first (collect → compare → classify), not the snapshot. +**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. Content for `docs/@v2/commands/diff.md`: +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 @@ -3041,26 +3036,29 @@ redocly diff v1.yaml v2.yaml --format=html -o diff-report.html ```` -- [ ] **Step 5: Create the changeset** +- [ ] **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/openapi-core': minor '@redocly/cli': minor --- -Added the `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. +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. ```` -(Remove the `` comment line — changeset files start directly with the `---` front matter.) - -- [ ] **Step 6: Run the full verification** +- [ ] **Step 7: Run the full verification** -Run: `npm run typecheck && VITEST_SUITE=unit npx vitest run packages/core/src/diff packages/cli/src/commands/diff --coverage.enabled=false && VITEST_SUITE=e2e npx vitest run tests/e2e/diff` +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 7: Commit** +- [ ] **Step 8: Commit** ```bash git add tests/e2e/diff docs/@v2/commands/diff.md .changeset/diff-command.md @@ -3072,7 +3070,8 @@ 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/core/src/diff packages/cli/src/commands/diff --coverage.enabled=false` — all green. +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). +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 index 2ee60e6510..613c06512f 100644 --- a/docs/superpowers/specs/2026-07-07-diff-command-design.md +++ b/docs/superpowers/specs/2026-07-07-diff-command-design.md @@ -2,7 +2,7 @@ - **Date:** 2026-07-07 - **Status:** Approved for implementation planning -- **Scope:** New CLI command that compares two API descriptions and reports structural changes, with breaking-change classification for OpenAPI 3.x. +- **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 @@ -12,6 +12,8 @@ 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) @@ -25,7 +27,7 @@ ## 2. CLI ``` -redocly diff +redocly diff [experimental] --format stylish | json | markdown | html (default: stylish) --output -o --fail-on breaking | warning | none (default: breaking) @@ -47,7 +49,7 @@ redocly diff [6] report 4 serializers from one DiffResult; exit code ``` -Core (stages 3–5) lives in `packages/core/src/diff/`; the command and serializers live in `packages/cli/src/commands/diff/`. +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 @@ -59,6 +61,7 @@ interface NodeEntry { 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'; @@ -82,7 +85,7 @@ interface Change { } interface DiffResult { - version: '1'; // output schema version — public contract from day one + 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[]; @@ -165,12 +168,7 @@ interface RuleContext { // registry keyed by typeName — same mental model as lint visitors export const oas3Rules: Record = { Operation: [operationRemoved], - Parameter: [ - parameterRemoved, - parameterAddedRequired, - parameterBecameRequired, - parameterInChanged, - ], + Parameter: [parameterRemoved, parameterAddedRequired, parameterBecameRequired], Schema: [schemaTypeChanged, enumChanged, requiredChanged, propertyRemoved], Response: [responseRemoved], MediaType: [mediaTypeRemoved], @@ -202,7 +200,9 @@ Shared **predicate helpers** (`narrowed`, `missingItems`, `becameTrue`, …) liv ### 7.3 Starter rule set (initial, not exhaustive) -`operation-removed`, `path-removed`, `parameter-removed`, `parameter-added-required`, `parameter-became-required`, `parameter-in-changed`, `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). +`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. @@ -226,31 +226,33 @@ Large `before/after` payloads (e.g. `example` objects, whole subtrees) are trunc ## 10. File layout ``` -packages/core/src/diff/ - index.ts # diffDocuments() orchestrator - types.ts # NodeEntry, Change, DiffResult, Compat, DiffRule - 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/ # one file per rule (lint-rule idiom) - __tests__/ - packages/cli/src/commands/diff/ - index.ts # handleDiff: resolve sides, call core, serialize, exit code + 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/commands/diff.md +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. @@ -297,7 +299,8 @@ Testing: unit tests per layer and per rule (a rule test feeds a hand-built `Chan - **`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. +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. From d6e824e952e4a1e1acb19f2fe6a7488d8c259ad6 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:01:09 +0300 Subject: [PATCH 04/57] feat(cli): add experimental `diff` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare two API descriptions and report added, removed, and changed parts. Structural diff works for every supported spec type via the existing openapi-core type trees; breaking-change classification (breaking / warning / non-breaking) applies to OpenAPI 3.x. The diff engine lives entirely in the CLI package and consumes only the public @redocly/openapi-core API (walkDocument, type trees, bundle) — packages/core is untouched. Pipeline: collect each side into a flat stable-pointer map, two-pass compare into a change list, then classify with a polarity-aware lint-style rule registry (worst verdict wins). Supports stylish, json, markdown, and html output and a --fail-on CI gate. Marked [experimental]; 14 starter rules documented. Co-Authored-By: Claude Fable 5 --- .changeset/diff-command.md | 5 + docs/@v2/commands/diff.md | 91 ++++++++++++ docs/@v2/commands/index.md | 1 + docs/@v2/v2.sidebars.yaml | 2 + .../diff/__tests__/serializers-rich.test.ts | 44 ++++++ .../diff/__tests__/serializers.test.ts | 69 +++++++++ .../diff/engine/__tests__/classify.test.ts | 82 +++++++++++ .../diff/engine/__tests__/collect.test.ts | 133 ++++++++++++++++++ .../diff/engine/__tests__/compare.test.ts | 131 +++++++++++++++++ .../engine/__tests__/diff-documents.test.ts | 112 +++++++++++++++ .../engine/__tests__/node-identity.test.ts | 27 ++++ .../diff/engine/__tests__/polarity.test.ts | 87 ++++++++++++ .../diff/engine/__tests__/predicates.test.ts | 60 ++++++++ .../rules-parameter-response.test.ts | 82 +++++++++++ .../engine/__tests__/rules-schema.test.ts | 125 ++++++++++++++++ .../diff/engine/__tests__/types.test.ts | 19 +++ .../commands/diff/engine/classify/index.ts | 66 +++++++++ .../src/commands/diff/engine/classify/oas3.ts | 35 +++++ .../commands/diff/engine/classify/oas3_1.ts | 5 + .../commands/diff/engine/classify/polarity.ts | 27 ++++ .../engine/classify/rules/operation-rules.ts | 19 +++ .../engine/classify/rules/parameter-rules.ts | 38 +++++ .../diff/engine/classify/rules/ref-rules.ts | 18 +++ .../engine/classify/rules/response-rules.ts | 19 +++ .../engine/classify/rules/schema-rules.ts | 83 +++++++++++ .../commands/diff/engine/classify/usage.ts | 45 ++++++ .../cli/src/commands/diff/engine/collect.ts | 118 ++++++++++++++++ .../cli/src/commands/diff/engine/compare.ts | 91 ++++++++++++ .../cli/src/commands/diff/engine/index.ts | 73 ++++++++++ .../src/commands/diff/engine/node-identity.ts | 26 ++++ .../src/commands/diff/engine/output-schema.ts | 55 ++++++++ .../src/commands/diff/engine/predicates.ts | 48 +++++++ .../cli/src/commands/diff/engine/types.ts | 88 ++++++++++++ packages/cli/src/commands/diff/index.ts | 70 +++++++++ .../cli/src/commands/diff/serializers/html.ts | 72 ++++++++++ .../cli/src/commands/diff/serializers/json.ts | 5 + .../src/commands/diff/serializers/markdown.ts | 37 +++++ .../src/commands/diff/serializers/stylish.ts | 39 +++++ packages/cli/src/index.ts | 41 ++++++ tests/e2e/diff/breaking-changes/base.yaml | 33 +++++ .../diff/breaking-changes/json-snapshot.txt | 84 +++++++++++ tests/e2e/diff/breaking-changes/revision.yaml | 28 ++++ .../breaking-changes/stylish-snapshot.txt | 7 + tests/e2e/diff/diff.test.ts | 56 ++++++++ 44 files changed, 2396 insertions(+) create mode 100644 .changeset/diff-command.md create mode 100644 docs/@v2/commands/diff.md create mode 100644 packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts create mode 100644 packages/cli/src/commands/diff/__tests__/serializers.test.ts create mode 100644 packages/cli/src/commands/diff/engine/__tests__/classify.test.ts create mode 100644 packages/cli/src/commands/diff/engine/__tests__/collect.test.ts create mode 100644 packages/cli/src/commands/diff/engine/__tests__/compare.test.ts create mode 100644 packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts create mode 100644 packages/cli/src/commands/diff/engine/__tests__/node-identity.test.ts create mode 100644 packages/cli/src/commands/diff/engine/__tests__/polarity.test.ts create mode 100644 packages/cli/src/commands/diff/engine/__tests__/predicates.test.ts create mode 100644 packages/cli/src/commands/diff/engine/__tests__/rules-parameter-response.test.ts create mode 100644 packages/cli/src/commands/diff/engine/__tests__/rules-schema.test.ts create mode 100644 packages/cli/src/commands/diff/engine/__tests__/types.test.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/index.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/oas3.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/oas3_1.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/polarity.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/rules/operation-rules.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/rules/parameter-rules.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/rules/ref-rules.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/rules/response-rules.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/rules/schema-rules.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/usage.ts create mode 100644 packages/cli/src/commands/diff/engine/collect.ts create mode 100644 packages/cli/src/commands/diff/engine/compare.ts create mode 100644 packages/cli/src/commands/diff/engine/index.ts create mode 100644 packages/cli/src/commands/diff/engine/node-identity.ts create mode 100644 packages/cli/src/commands/diff/engine/output-schema.ts create mode 100644 packages/cli/src/commands/diff/engine/predicates.ts create mode 100644 packages/cli/src/commands/diff/engine/types.ts create mode 100644 packages/cli/src/commands/diff/index.ts create mode 100644 packages/cli/src/commands/diff/serializers/html.ts create mode 100644 packages/cli/src/commands/diff/serializers/json.ts create mode 100644 packages/cli/src/commands/diff/serializers/markdown.ts create mode 100644 packages/cli/src/commands/diff/serializers/stylish.ts create mode 100644 tests/e2e/diff/breaking-changes/base.yaml create mode 100644 tests/e2e/diff/breaking-changes/json-snapshot.txt create mode 100644 tests/e2e/diff/breaking-changes/revision.yaml create mode 100644 tests/e2e/diff/breaking-changes/stylish-snapshot.txt create mode 100644 tests/e2e/diff/diff.test.ts diff --git a/.changeset/diff-command.md b/.changeset/diff-command.md new file mode 100644 index 0000000000..9935dfe2a4 --- /dev/null +++ b/.changeset/diff-command.md @@ -0,0 +1,5 @@ +--- +'@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. diff --git a/docs/@v2/commands/diff.md b/docs/@v2/commands/diff.md new file mode 100644 index 0000000000..1a9d09facb --- /dev/null +++ b/docs/@v2/commands/diff.md @@ -0,0 +1,91 @@ +# `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 and reports what was added, removed, and changed. +For OpenAPI 3.x, changes are also classified as breaking, warning, or non-breaking, so you can catch breaking changes before they reach your consumers. + +## 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 | 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. | +| --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. + +{% admonition type="info" name="Limitations" %} +The `diff` command detects common breaking changes using a documented rule catalog; it is not an exhaustive detector. +Renaming a component (for example, a schema or parameter) is seen as a removal plus an addition, not a rename, so the new `$ref` target is reported as a `warning` rather than matched to its previous identity. +Comparing documents of different specification families (for example, OpenAPI 2.0 vs OpenAPI 3.1) is not supported. +Reordering subschemas inside `allOf`, `oneOf`, or `anyOf` (and other lists without a natural identity) is matched positionally and may be reported as changes. +`readOnly` and `writeOnly` do not refine request/response polarity; a component used on both sides is judged under both, and the stricter verdict wins. +Reordering items that do have an identity (for example, `servers`, matched by URL) is not reported, even though `servers` order can be semantically meaningful. +Comparing across minor versions (OpenAPI 3.0 vs 3.1) can surface syntactic-only differences (for example, `nullable: true` vs `type: [..., "null"]`). +Changes under `callbacks` and `webhooks` receive structural diffing only, with no breaking-change classification, because their request/response direction is inverted. +{% /admonition %} + +## 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 + +Use the default `--fail-on=breaking` behavior to make `diff` exit with code `1` when it finds breaking changes, which is useful 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 + +Use `--format=html` together with `--output` to write a shareable HTML report instead of printing to the terminal: + +```bash +redocly diff v1.yaml v2.yaml --format=html -o diff-report.html +``` diff --git a/docs/@v2/commands/index.md b/docs/@v2/commands/index.md index fa93d04a3e..29283118d8 100644 --- a/docs/@v2/commands/index.md +++ b/docs/@v2/commands/index.md @@ -24,6 +24,7 @@ Docs linting commands: CI and delivery commands: - [`bundle`](bundle.md) Bundle an API description. +- [`diff`](diff.md) Compare two API descriptions and detect breaking changes [experimental feature]. - [`join`](join.md) Join API descriptions [experimental feature]. API testing and drift commands: diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index 534c941845..95dffca202 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/packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts b/packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts new file mode 100644 index 0000000000..3372183788 --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts @@ -0,0 +1,44 @@ +import type { DiffResult } from '../engine/types.js'; +import { htmlDiff } from '../serializers/html.js'; +import { markdownDiff } from '../serializers/markdown.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')} + +`; +} 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..b441f51242 --- /dev/null +++ b/packages/cli/src/commands/diff/serializers/markdown.ts @@ -0,0 +1,37 @@ +import type { Change, DiffResult } from '../engine/types.js'; + +const IMPACT_LABEL: Record = { + breaking: '🔴 breaking', + warning: '🟠 warning', + 'non-breaking': '🟢 non-breaking', +}; + +function escapeCell(value: string): string { + return value.replace(/\|/g, '\\|').replace(/\n/g, ' '); +} + +export function markdownDiff(result: DiffResult): string { + const { breaking, warning, nonBreaking } = result.summary; + const lines = [ + '## API diff', + '', + `**${breaking}** breaking · **${warning}** warning · **${nonBreaking}** non-breaking`, + '', + '| Impact | Change | Location | Details |', + '| --- | --- | --- | --- |', + ]; + + for (const change of result.changes) { + const location = change.property ? `${change.pointer} · ${change.property}` : change.pointer; + const details = [change.message, change.ruleIds?.map((id) => `\`${id}\``).join(', ')] + .filter(Boolean) + .join(' '); + lines.push( + `| ${IMPACT_LABEL[change.compat]} | ${change.kind} | \`${escapeCell(location)}\` | ${escapeCell( + details + )} |` + ); + } + + return lines.join('\n'); +} 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..590c61e6ad --- /dev/null +++ b/packages/cli/src/commands/diff/serializers/stylish.ts @@ -0,0 +1,39 @@ +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'); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 309e596b2e..1812cf9d9d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,6 +13,7 @@ import yargs, { type Arguments } from 'yargs'; import { hideBin } from 'yargs/helpers'; import type { BuildDocsArgv } from './commands/build-docs/types.js'; +import type { DiffArgv } 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'; @@ -134,6 +135,46 @@ yargs(hideBin(process.argv)) commandWrapper(handleInspectNodeTypes)(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'] 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, + }, + }), + async (argv) => { + const { handleDiff } = await import('./commands/diff/index.js'); + commandWrapper(handleDiff)(argv as Arguments); + } + ) .command( 'score [api]', 'Score an API description for integration simplicity and agent readiness.', diff --git a/tests/e2e/diff/breaking-changes/base.yaml b/tests/e2e/diff/breaking-changes/base.yaml new file mode 100644 index 0000000000..2187217b99 --- /dev/null +++ b/tests/e2e/diff/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/breaking-changes/json-snapshot.txt b/tests/e2e/diff/breaking-changes/json-snapshot.txt new file mode 100644 index 0000000000..b18d7a0ccf --- /dev/null +++ b/tests/e2e/diff/breaking-changes/json-snapshot.txt @@ -0,0 +1,84 @@ +{ + "version": "", + "specVersions": { + "base": "oas3_1", + "revision": "oas3_1" + }, + "summary": { + "breaking": 3, + "warning": 0, + "nonBreaking": 1 + }, + "changes": [ + { + "pointer": "#/components/schemas/Pet/properties/tag", + "kind": "removed", + "typeName": "Schema", + "base": { + "pointer": "#/components/schemas/Pet/properties/tag", + "value": { + "type": "string" + } + }, + "compat": "breaking", + "ruleIds": [ + "property-removed-from-response" + ], + "message": "Schema property was removed." + }, + { + "pointer": "#/info", + "property": "version", + "kind": "changed", + "typeName": "Info", + "base": { + "pointer": "#/info/version", + "value": "1.0" + }, + "revision": { + "pointer": "#/info/version", + "value": "2.0" + }, + "compat": "non-breaking" + }, + { + "pointer": "#/paths/~1pets/delete", + "kind": "removed", + "typeName": "Operation", + "base": { + "pointer": "#/paths/~1pets/delete", + "value": { + "responses": { + "204": { + "description": "Deleted" + } + } + } + }, + "compat": "breaking", + "ruleIds": [ + "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", + "ruleIds": [ + "parameter-became-required" + ], + "message": "Parameter became required." + } + ] +} + diff --git a/tests/e2e/diff/breaking-changes/revision.yaml b/tests/e2e/diff/breaking-changes/revision.yaml new file mode 100644 index 0000000000..cf3b37628b --- /dev/null +++ b/tests/e2e/diff/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/breaking-changes/stylish-snapshot.txt b/tests/e2e/diff/breaking-changes/stylish-snapshot.txt new file mode 100644 index 0000000000..142c42be82 --- /dev/null +++ b/tests/e2e/diff/breaking-changes/stylish-snapshot.txt @@ -0,0 +1,7 @@ +✖ breaking removed #/components/schemas/Pet/properties/tag — Schema property was removed. (property-removed-from-response) +✖ 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) +✔ non-breaking changed #/info · version + +3 breaking, 0 warning, 1 non-breaking. + diff --git a/tests/e2e/diff/diff.test.ts b/tests/e2e/diff/diff.test.ts new file mode 100644 index 0000000000..0ecf7de46e --- /dev/null +++ b/tests/e2e/diff/diff.test.ts @@ -0,0 +1,56 @@ +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); + }); +}); From af8bdfaed6a72d33c0a6053cb1b3bb0b37b2d7b1 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 19:45:20 +0300 Subject: [PATCH 05/57] docs: add diff command improvements plan Co-Authored-By: Claude Fable 5 --- .../2026-07-07-diff-command-improvements.md | 1629 +++++++++++++++++ 1 file changed, 1629 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-diff-command-improvements.md 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 " +``` From 4335855e1a568d3227a459d29794b32cb8750604 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 19:50:20 +0300 Subject: [PATCH 06/57] refactor(cli): simplify diff compat model to breaking/non-breaking Co-Authored-By: Claude Fable 5 --- .../diff/__tests__/serializers-rich.test.ts | 2 +- .../diff/__tests__/serializers.test.ts | 12 ++-- .../engine/__tests__/diff-documents.test.ts | 19 +------ .../engine/__tests__/rules-schema.test.ts | 2 +- .../diff/engine/__tests__/types.test.ts | 14 +---- .../diff/engine/classify/rules/ref-rules.ts | 8 +-- .../cli/src/commands/diff/engine/index.ts | 3 +- .../src/commands/diff/engine/output-schema.ts | 55 ------------------- .../cli/src/commands/diff/engine/types.ts | 13 +---- packages/cli/src/commands/diff/index.ts | 14 +---- .../cli/src/commands/diff/serializers/html.ts | 5 +- .../src/commands/diff/serializers/markdown.ts | 5 +- .../src/commands/diff/serializers/stylish.ts | 14 ++--- packages/cli/src/index.ts | 4 +- 14 files changed, 28 insertions(+), 142 deletions(-) delete mode 100644 packages/cli/src/commands/diff/engine/output-schema.ts diff --git a/packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts b/packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts index 3372183788..8d3d40ce21 100644 --- a/packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts +++ b/packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts @@ -5,7 +5,7 @@ import { markdownDiff } from '../serializers/markdown.js'; const RESULT: DiffResult = { version: '1', specVersions: { base: 'oas3_1', revision: 'oas3_1' }, - summary: { breaking: 1, warning: 0, nonBreaking: 0 }, + summary: { breaking: 1, nonBreaking: 0 }, changes: [ { pointer: '#/paths/~1pets/get', diff --git a/packages/cli/src/commands/diff/__tests__/serializers.test.ts b/packages/cli/src/commands/diff/__tests__/serializers.test.ts index 7e45fac164..7cd0d8611a 100644 --- a/packages/cli/src/commands/diff/__tests__/serializers.test.ts +++ b/packages/cli/src/commands/diff/__tests__/serializers.test.ts @@ -5,7 +5,7 @@ import { stylishDiff } from '../serializers/stylish.js'; const RESULT: DiffResult = { version: '1', specVersions: { base: 'oas3_1', revision: 'oas3_1' }, - summary: { breaking: 1, warning: 1, nonBreaking: 1 }, + summary: { breaking: 2, nonBreaking: 1 }, changes: [ { pointer: '#/paths/~1pets/get/responses/200', @@ -40,7 +40,7 @@ const RESULT: DiffResult = { pointer: '#/paths/~1pets/get/requestBody/content/application~1json/schema', value: '#/components/schemas/B', }, - compat: 'warning', + compat: 'breaking', ruleIds: ['ref-target-changed'], message: 'Reference target changed.', }, @@ -51,13 +51,9 @@ 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(breakingIndex).toBeLessThan(nonBreakingIndex); + expect(output).toContain('2 breaking'); expect(output).toContain('1 non-breaking'); }); }); diff --git a/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts b/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts index 8803a73c58..9ca2cdc5be 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts +++ b/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts @@ -1,9 +1,7 @@ -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 @@ -77,22 +75,7 @@ describe('diffDocuments', () => { 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, - }); - - // Ajv's default export has no construct signatures under this repo's TS - // config — mirror core's cast in packages/core/src/rules/ajv.ts. - const ajv = new (Ajv as any)({ strict: false }); - const validate = ajv.compile(DIFF_OUTPUT_SCHEMA); - expect(validate(result)).toBe(true); + expect(result.summary).toEqual({ breaking: 1, nonBreaking: 2 }); }); it('throws DiffError for different spec families', async () => { diff --git a/packages/cli/src/commands/diff/engine/__tests__/rules-schema.test.ts b/packages/cli/src/commands/diff/engine/__tests__/rules-schema.test.ts index a08eea675e..c7ff314c20 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/rules-schema.test.ts +++ b/packages/cli/src/commands/diff/engine/__tests__/rules-schema.test.ts @@ -108,7 +108,7 @@ describe('ref-target-changed', () => { 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'); + expect(refTargetChanged.visit(change, ctx('response', { base }))?.compat).toBe('breaking'); }); it('stays silent for ordinary string property changes', () => { diff --git a/packages/cli/src/commands/diff/engine/__tests__/types.test.ts b/packages/cli/src/commands/diff/engine/__tests__/types.test.ts index 0fedbcf0f1..610a56d7ea 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/types.test.ts +++ b/packages/cli/src/commands/diff/engine/__tests__/types.test.ts @@ -1,19 +1,11 @@ -import { worstOf, compatRank, breaking, warning } from '../types.js'; +import { compatRank, breaking } from '../types.js'; describe('diff types helpers', () => { it('ranks compat levels', () => { - expect(compatRank('breaking')).toBeGreaterThan(compatRank('warning')); - expect(compatRank('warning')).toBeGreaterThan(compatRank('non-breaking')); + expect(compatRank('breaking')).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', () => { + it('builds breaking verdicts', () => { expect(breaking('boom')).toEqual({ compat: 'breaking', message: 'boom' }); - expect(warning('hmm')).toEqual({ compat: 'warning', message: 'hmm' }); }); }); diff --git a/packages/cli/src/commands/diff/engine/classify/rules/ref-rules.ts b/packages/cli/src/commands/diff/engine/classify/rules/ref-rules.ts index 3c37e21e6b..ac50265ee5 100644 --- a/packages/cli/src/commands/diff/engine/classify/rules/ref-rules.ts +++ b/packages/cli/src/commands/diff/engine/classify/rules/ref-rules.ts @@ -1,7 +1,7 @@ -import { warning, type DiffRule } from '../../types.js'; +import { breaking, 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. +// content-equivalent (spec §7.3, §13) — the conservative verdict is breaking. export const refTargetChanged: DiffRule = { id: 'ref-target-changed', description: @@ -11,8 +11,8 @@ export const refTargetChanged: DiffRule = { 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.` + return breaking( + `Reference target changed from '${change.base?.value}' to '${change.revision?.value}' — content equivalence cannot be verified.` ); }, }; diff --git a/packages/cli/src/commands/diff/engine/index.ts b/packages/cli/src/commands/diff/engine/index.ts index 193805d645..6655434328 100644 --- a/packages/cli/src/commands/diff/engine/index.ts +++ b/packages/cli/src/commands/diff/engine/index.ts @@ -57,11 +57,10 @@ export function diffDocuments(opts: { 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 } + { breaking: 0, nonBreaking: 0 } ); return { diff --git a/packages/cli/src/commands/diff/engine/output-schema.ts b/packages/cli/src/commands/diff/engine/output-schema.ts deleted file mode 100644 index 61741bc5ec..0000000000 --- a/packages/cli/src/commands/diff/engine/output-schema.ts +++ /dev/null @@ -1,55 +0,0 @@ -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; diff --git a/packages/cli/src/commands/diff/engine/types.ts b/packages/cli/src/commands/diff/engine/types.ts index e74514181a..d42b2710f6 100644 --- a/packages/cli/src/commands/diff/engine/types.ts +++ b/packages/cli/src/commands/diff/engine/types.ts @@ -1,6 +1,6 @@ import type { SpecVersion } from '@redocly/openapi-core'; -export type Compat = 'breaking' | 'warning' | 'non-breaking'; +export type Compat = 'breaking' | 'non-breaking'; export type ChangeKind = 'added' | 'removed' | 'changed'; @@ -36,7 +36,6 @@ export type RawChange = Omit; export interface DiffSummary { breaking: number; - warning: number; nonBreaking: number; } @@ -69,20 +68,12 @@ export interface DiffRule { export type DiffRuleRegistry = Record; -const COMPAT_RANK: Record = { breaking: 2, warning: 1, 'non-breaking': 0 }; +const COMPAT_RANK: Record = { breaking: 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 }; -} diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts index 450648dd21..95cbb5ee0a 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -13,7 +13,7 @@ import { markdownDiff } from './serializers/markdown.js'; import { stylishDiff } from './serializers/stylish.js'; export type DiffOutputFormat = 'stylish' | 'json' | 'markdown' | 'html'; -export type DiffFailOn = 'breaking' | 'warning' | 'none'; +export type DiffFailOn = 'breaking' | 'none'; export type DiffArgv = { base: string; @@ -55,16 +55,8 @@ export async function handleDiff({ argv, config, collectSpecData }: CommandArgs< 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; + const failed = argv['fail-on'] === 'breaking' && result.summary.breaking > 0; if (failed) { - throw new AbortFlowError( - `Diff failed: ${result.summary.breaking} breaking, ${result.summary.warning} warning change(s) found.` - ); + throw new AbortFlowError('Diff failed.'); } } diff --git a/packages/cli/src/commands/diff/serializers/html.ts b/packages/cli/src/commands/diff/serializers/html.ts index 313fb93cc2..56b0f201a6 100644 --- a/packages/cli/src/commands/diff/serializers/html.ts +++ b/packages/cli/src/commands/diff/serializers/html.ts @@ -10,7 +10,6 @@ function escapeHtml(value: unknown): string { const IMPACT_CLASS: Record = { breaking: 'breaking', - warning: 'warning', 'non-breaking': 'ok', }; @@ -34,7 +33,7 @@ function renderChange(change: Change): string { } export function htmlDiff(result: DiffResult): string { - const { breaking, warning, nonBreaking } = result.summary; + const { breaking, nonBreaking } = result.summary; return ` @@ -48,7 +47,6 @@ export function htmlDiff(result: DiffResult): string { .change summary { cursor: pointer; display: flex; gap: .6rem; align-items: baseline; flex-wrap: wrap; } .badge { border-radius: 4px; padding: 0 .5rem; font-size: .8rem; color: #fff; } .breaking .badge { background: #c0392b; } - .warning .badge { background: #d68910; } .ok .badge { background: #1e8449; } .msg { color: #52606d; } .rules { color: #9aa5b1; font-size: .85rem; } @@ -60,7 +58,6 @@ export function htmlDiff(result: DiffResult): string {

API diff

${breaking} breaking - ${warning} warning ${nonBreaking} non-breaking ${escapeHtml(result.specVersions.base)} → ${escapeHtml( result.specVersions.revision diff --git a/packages/cli/src/commands/diff/serializers/markdown.ts b/packages/cli/src/commands/diff/serializers/markdown.ts index b441f51242..fa488ad2fa 100644 --- a/packages/cli/src/commands/diff/serializers/markdown.ts +++ b/packages/cli/src/commands/diff/serializers/markdown.ts @@ -2,7 +2,6 @@ import type { Change, DiffResult } from '../engine/types.js'; const IMPACT_LABEL: Record = { breaking: '🔴 breaking', - warning: '🟠 warning', 'non-breaking': '🟢 non-breaking', }; @@ -11,11 +10,11 @@ function escapeCell(value: string): string { } export function markdownDiff(result: DiffResult): string { - const { breaking, warning, nonBreaking } = result.summary; + const { breaking, nonBreaking } = result.summary; const lines = [ '## API diff', '', - `**${breaking}** breaking · **${warning}** warning · **${nonBreaking}** non-breaking`, + `**${breaking}** breaking · **${nonBreaking}** non-breaking`, '', '| Impact | Change | Location | Details |', '| --- | --- | --- | --- |', diff --git a/packages/cli/src/commands/diff/serializers/stylish.ts b/packages/cli/src/commands/diff/serializers/stylish.ts index 590c61e6ad..2012905cc6 100644 --- a/packages/cli/src/commands/diff/serializers/stylish.ts +++ b/packages/cli/src/commands/diff/serializers/stylish.ts @@ -1,12 +1,11 @@ -import { bold, gray, green, red, yellow } from 'colorette'; +import { bold, gray, green, red } from 'colorette'; import type { Change, Compat, DiffResult } from '../engine/types.js'; -const SEVERITY_ORDER: Compat[] = ['breaking', 'warning', 'non-breaking']; +const SEVERITY_ORDER: Compat[] = ['breaking', 'non-breaking']; const ICONS: Record = { breaking: red('✖ breaking '), - warning: yellow('⚠ warning '), 'non-breaking': green('✔ non-breaking'), }; @@ -28,12 +27,7 @@ export function stylishDiff(result: DiffResult): string { 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` - )}.` - ); + 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 1812cf9d9d..51bf7c7310 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -164,9 +164,7 @@ yargs(hideBin(process.argv)) }, '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' - >, + choices: ['breaking', 'none'] as ReadonlyArray<'breaking' | 'none'>, default: 'breaking' as const, }, }), From f4fb9daccf38f8158b0e944ad2c0958bb97a1bb4 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 19:57:40 +0300 Subject: [PATCH 07/57] feat(cli): keep every rule verdict on diff changes Co-Authored-By: Claude Fable 5 --- .../diff/__tests__/serializers-rich.test.ts | 5 +- .../diff/__tests__/serializers.test.ts | 14 ++++-- .../diff/engine/__tests__/classify.test.ts | 46 +++++++++++++++++-- .../engine/__tests__/diff-documents.test.ts | 8 +++- .../commands/diff/engine/classify/index.ts | 20 ++++---- .../cli/src/commands/diff/engine/types.ts | 11 +++-- .../cli/src/commands/diff/serializers/html.ts | 10 +++- .../src/commands/diff/serializers/markdown.ts | 6 +-- .../src/commands/diff/serializers/stylish.ts | 8 ++-- 9 files changed, 96 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts b/packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts index 8d3d40ce21..3300aae766 100644 --- a/packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts +++ b/packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts @@ -13,8 +13,9 @@ const RESULT: DiffResult = { typeName: 'Operation', base: { pointer: '#/paths/~1pets/get', value: { summary: '' } }, compat: 'breaking', - ruleIds: ['operation-removed'], - message: 'Operation was removed.', + verdicts: [ + { ruleId: 'operation-removed', compat: 'breaking', message: 'Operation was removed.' }, + ], }, ], }; diff --git a/packages/cli/src/commands/diff/__tests__/serializers.test.ts b/packages/cli/src/commands/diff/__tests__/serializers.test.ts index 7cd0d8611a..3c48a409c2 100644 --- a/packages/cli/src/commands/diff/__tests__/serializers.test.ts +++ b/packages/cli/src/commands/diff/__tests__/serializers.test.ts @@ -24,8 +24,13 @@ const RESULT: DiffResult = { 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.', + verdicts: [ + { + ruleId: 'parameter-became-required', + compat: 'breaking', + message: 'Parameter became required.', + }, + ], }, { pointer: '#/paths/~1pets/get/requestBody/content/application~1json', @@ -41,8 +46,9 @@ const RESULT: DiffResult = { value: '#/components/schemas/B', }, compat: 'breaking', - ruleIds: ['ref-target-changed'], - message: 'Reference target changed.', + verdicts: [ + { ruleId: 'ref-target-changed', compat: 'breaking', message: 'Reference target changed.' }, + ], }, ], }; diff --git a/packages/cli/src/commands/diff/engine/__tests__/classify.test.ts b/packages/cli/src/commands/diff/engine/__tests__/classify.test.ts index 400924afc4..292accd021 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/classify.test.ts +++ b/packages/cli/src/commands/diff/engine/__tests__/classify.test.ts @@ -20,8 +20,9 @@ describe('classifyChanges', () => { ]; const [change] = classifyChanges({ changes, specVersion: 'oas3_1', ...emptyMaps }); expect(change.compat).toBe('breaking'); - expect(change.ruleIds).toEqual(['operation-removed']); - expect(change.message).toBeDefined(); + expect(change.verdicts).toEqual([ + { ruleId: 'operation-removed', compat: 'breaking', message: 'Operation was removed.' }, + ]); }); it('classifies path removal as breaking', () => { @@ -35,7 +36,9 @@ describe('classifyChanges', () => { ]; const [change] = classifyChanges({ changes, specVersion: 'oas3_0', ...emptyMaps }); expect(change.compat).toBe('breaking'); - expect(change.ruleIds).toEqual(['path-removed']); + expect(change.verdicts).toEqual([ + { ruleId: 'path-removed', compat: 'breaking', message: 'Path was removed.' }, + ]); }); it('defaults to non-breaking when no rule judges the change', () => { @@ -51,7 +54,7 @@ describe('classifyChanges', () => { ]; const [change] = classifyChanges({ changes, specVersion: 'oas3_1', ...emptyMaps }); expect(change.compat).toBe('non-breaking'); - expect(change.ruleIds).toBeUndefined(); + expect(change.verdicts).toBeUndefined(); }); it('returns structural-only (non-breaking) for specs without a registry', () => { @@ -79,4 +82,39 @@ describe('classifyChanges', () => { const [change] = classifyChanges({ changes, specVersion: 'oas3_1', ...emptyMaps }); expect(change.compat).toBe('non-breaking'); }); + + 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', + ]); + }); }); diff --git a/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts b/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts index 9ca2cdc5be..0dfd89efab 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts +++ b/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts @@ -64,7 +64,13 @@ describe('diffDocuments', () => { const becameRequired = result.changes.find((c) => c.property === 'required')!; expect(becameRequired.compat).toBe('breaking'); - expect(becameRequired.ruleIds).toEqual(['parameter-became-required']); + expect(becameRequired.verdicts).toEqual([ + { + ruleId: 'parameter-became-required', + compat: 'breaking', + message: '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'); diff --git a/packages/cli/src/commands/diff/engine/classify/index.ts b/packages/cli/src/commands/diff/engine/classify/index.ts index ce62431c7f..d7603dcf34 100644 --- a/packages/cli/src/commands/diff/engine/classify/index.ts +++ b/packages/cli/src/commands/diff/engine/classify/index.ts @@ -3,11 +3,11 @@ import type { SpecVersion } from '@redocly/openapi-core'; import { compatRank, type Change, + type ChangeVerdict, type DiffRuleRegistry, type NodeEntry, type Polarity, type RawChange, - type Verdict, } from '../types.js'; import { oas3Rules } from './oas3.js'; import { oas3_1Rules } from './oas3_1.js'; @@ -36,8 +36,7 @@ export function classifyChanges(opts: { return changes.map((change) => { const rules = registry[change.typeName] ?? []; - const ruleIds: string[] = []; - let winner: Verdict | undefined; + const verdicts: ChangeVerdict[] = []; for (const polarity of expandPolarity(getPolarity(change.pointer, usage))) { const ctx = { @@ -49,18 +48,21 @@ export function classifyChanges(opts: { 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 + // 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: winner?.compat ?? 'non-breaking', - ...(ruleIds.length ? { ruleIds: ruleIds.sort() } : {}), - ...(winner ? { message: winner.message } : {}), + compat: verdicts[0]?.compat ?? 'non-breaking', + ...(verdicts.length ? { verdicts } : {}), }; }); } diff --git a/packages/cli/src/commands/diff/engine/types.ts b/packages/cli/src/commands/diff/engine/types.ts index d42b2710f6..49b53308ce 100644 --- a/packages/cli/src/commands/diff/engine/types.ts +++ b/packages/cli/src/commands/diff/engine/types.ts @@ -26,13 +26,12 @@ export interface Change { 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 + 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 type RawChange = Omit; export interface DiffSummary { breaking: number; @@ -51,6 +50,10 @@ export interface Verdict { message: string; } +export interface ChangeVerdict extends Verdict { + ruleId: string; +} + export type Polarity = 'request' | 'response' | 'both' | 'neutral'; export interface RuleContext { diff --git a/packages/cli/src/commands/diff/serializers/html.ts b/packages/cli/src/commands/diff/serializers/html.ts index 56b0f201a6..5830dfd796 100644 --- a/packages/cli/src/commands/diff/serializers/html.ts +++ b/packages/cli/src/commands/diff/serializers/html.ts @@ -25,8 +25,14 @@ function renderChange(change: Change): string { ${escapeHtml(change.compat)} ${escapeHtml(change.kind)} ${escapeHtml(location)} - ${change.message ? `${escapeHtml(change.message)}` : ''} - ${change.ruleIds ? `${escapeHtml(change.ruleIds.join(', '))}` : ''} + ${(change.verdicts ?? []) + .map( + (v) => + `${escapeHtml(v.message)} ${escapeHtml( + v.ruleId + )}` + ) + .join(' ')}

${escapeHtml(JSON.stringify(payload, null, 2))}
`; diff --git a/packages/cli/src/commands/diff/serializers/markdown.ts b/packages/cli/src/commands/diff/serializers/markdown.ts index fa488ad2fa..149e36b95a 100644 --- a/packages/cli/src/commands/diff/serializers/markdown.ts +++ b/packages/cli/src/commands/diff/serializers/markdown.ts @@ -22,9 +22,9 @@ export function markdownDiff(result: DiffResult): string { for (const change of result.changes) { const location = change.property ? `${change.pointer} · ${change.property}` : change.pointer; - const details = [change.message, change.ruleIds?.map((id) => `\`${id}\``).join(', ')] - .filter(Boolean) - .join(' '); + const details = (change.verdicts ?? []) + .map((v) => `${escapeCell(v.message)} \`${v.ruleId}\``) + .join('
'); lines.push( `| ${IMPACT_LABEL[change.compat]} | ${change.kind} | \`${escapeCell(location)}\` | ${escapeCell( details diff --git a/packages/cli/src/commands/diff/serializers/stylish.ts b/packages/cli/src/commands/diff/serializers/stylish.ts index 2012905cc6..079efc83fe 100644 --- a/packages/cli/src/commands/diff/serializers/stylish.ts +++ b/packages/cli/src/commands/diff/serializers/stylish.ts @@ -22,9 +22,11 @@ export function stylishDiff(result: DiffResult): string { ); 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 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}`); } const { breaking, nonBreaking } = result.summary; From 006855e24b8c0ee9418540a04988b8c587cb2138 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:03:08 +0300 Subject: [PATCH 08/57] fix(cli): report diff --fail-on failure visibly and print execution time Co-Authored-By: Claude Fable 5 --- .../commands/diff/__tests__/fail-on.test.ts | 20 +++++++++++++++++++ packages/cli/src/commands/diff/fail-on.ts | 15 ++++++++++++++ packages/cli/src/commands/diff/index.ts | 14 +++++++++---- 3 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/commands/diff/__tests__/fail-on.test.ts create mode 100644 packages/cli/src/commands/diff/fail-on.ts diff --git a/packages/cli/src/commands/diff/__tests__/fail-on.test.ts b/packages/cli/src/commands/diff/__tests__/fail-on.test.ts new file mode 100644 index 0000000000..88bc46cb36 --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/fail-on.test.ts @@ -0,0 +1,20 @@ +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(); + }); +}); 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 index 95cbb5ee0a..d72f2a8950 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -3,17 +3,18 @@ import { writeFileSync } from 'node:fs'; import type { VerifyConfigOptions } from '../../types.js'; import { AbortFlowError, exitWithError } from '../../utils/error.js'; -import { getFallbackApisOrExit } from '../../utils/miscellaneous.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 { stylishDiff } from './serializers/stylish.js'; export type DiffOutputFormat = 'stylish' | 'json' | 'markdown' | 'html'; -export type DiffFailOn = 'breaking' | 'none'; +export type { DiffFailOn }; export type DiffArgv = { base: string; @@ -31,6 +32,7 @@ const SERIALIZERS: Record string> = { }; 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); @@ -51,12 +53,16 @@ export async function handleDiff({ argv, config, collectSpecData }: CommandArgs< 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'); } - const failed = argv['fail-on'] === 'breaking' && result.summary.breaking > 0; - if (failed) { + 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.'); } } From 5b884c3ede4c7cae8ae1fb6b0ebfa7ee82316f6a Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:07:07 +0300 Subject: [PATCH 09/57] refactor(cli): derive diff output format from core OutputFormat Co-Authored-By: Claude Fable 5 --- packages/cli/src/commands/diff/index.ts | 4 ++-- packages/cli/src/index.ts | 6 ++---- packages/core/src/format/format.ts | 3 ++- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts index d72f2a8950..f10abfcca9 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -1,4 +1,4 @@ -import { bundle, logger } from '@redocly/openapi-core'; +import { bundle, logger, type OutputFormat } from '@redocly/openapi-core'; import { writeFileSync } from 'node:fs'; import type { VerifyConfigOptions } from '../../types.js'; @@ -13,7 +13,7 @@ import { jsonDiff } from './serializers/json.js'; import { markdownDiff } from './serializers/markdown.js'; import { stylishDiff } from './serializers/stylish.js'; -export type DiffOutputFormat = 'stylish' | 'json' | 'markdown' | 'html'; +export type DiffOutputFormat = Extract; export type { DiffFailOn }; export type DiffArgv = { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 51bf7c7310..820f8f480f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,7 +13,7 @@ import yargs, { type Arguments } from 'yargs'; import { hideBin } from 'yargs/helpers'; import type { BuildDocsArgv } from './commands/build-docs/types.js'; -import type { DiffArgv } from './commands/diff/index.js'; +import type { DiffArgv, 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'; @@ -152,9 +152,7 @@ yargs(hideBin(process.argv)) }, format: { description: 'Use a specific output format.', - choices: ['stylish', 'json', 'markdown', 'html'] as ReadonlyArray< - 'stylish' | 'json' | 'markdown' | 'html' - >, + choices: ['stylish', 'json', 'markdown', 'html'] as ReadonlyArray, default: 'stylish' as const, }, output: { diff --git a/packages/core/src/format/format.ts b/packages/core/src/format/format.ts index 5ccd349f39..d173bfc1cb 100644 --- a/packages/core/src/format/format.ts +++ b/packages/core/src/format/format.ts @@ -56,7 +56,8 @@ export type OutputFormat = | 'summary' | 'github-actions' | 'markdown' - | 'junit'; + | 'junit' + | 'html'; export function getTotals(problems: (NormalizedProblem & { ignored?: boolean })[]): Totals { let errors = 0; From c17cdade59d8f3eb21138f4a1834d94a854a875e Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:11:37 +0300 Subject: [PATCH 10/57] feat(cli): attach file/line/col locations to diff change sides Co-Authored-By: Claude Fable 5 --- .../engine/__tests__/diff-documents.test.ts | 7 ++- .../diff/engine/__tests__/locate.test.ts | 57 +++++++++++++++++++ .../cli/src/commands/diff/engine/index.ts | 19 ++++--- .../cli/src/commands/diff/engine/locate.ts | 22 +++++++ .../cli/src/commands/diff/engine/types.ts | 3 + 5 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/commands/diff/engine/__tests__/locate.test.ts create mode 100644 packages/cli/src/commands/diff/engine/locate.ts diff --git a/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts b/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts index 0dfd89efab..f86764cb51 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts +++ b/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts @@ -42,8 +42,8 @@ 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, ''), + base: makeDocumentFromString(BASE, 'base.yaml'), + revision: makeDocumentFromString(REVISION, 'rev.yaml'), config, }); @@ -73,6 +73,9 @@ describe('diffDocuments', () => { ]); expect(becameRequired.base?.pointer).toBe('#/paths/~1pets/get/parameters/0/required'); expect(becameRequired.revision?.pointer).toBe('#/paths/~1pets/get/parameters/1/required'); + expect(becameRequired.base).toMatchObject({ file: 'base.yaml' }); + expect(becameRequired.revision).toMatchObject({ file: 'rev.yaml' }); + expect(becameRequired.revision?.line).toBeGreaterThan(1); // integer → number in request is a widening — non-breaking const typeChanged = result.changes.find((c) => c.property === 'type')!; diff --git a/packages/cli/src/commands/diff/engine/__tests__/locate.test.ts b/packages/cli/src/commands/diff/engine/__tests__/locate.test.ts new file mode 100644 index 0000000000..a27eb04250 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/__tests__/locate.test.ts @@ -0,0 +1,57 @@ +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 }); + }); +}); diff --git a/packages/cli/src/commands/diff/engine/index.ts b/packages/cli/src/commands/diff/engine/index.ts index 6655434328..ea4029f877 100644 --- a/packages/cli/src/commands/diff/engine/index.ts +++ b/packages/cli/src/commands/diff/engine/index.ts @@ -12,6 +12,7 @@ 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 } from './types.js'; export class DiffError extends Error {} @@ -46,13 +47,17 @@ export function diffDocuments(opts: { 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 changes = locateChanges( + classifyChanges({ + changes: rawChanges, + specVersion: revisionVersion, + base: baseCollected.entries, + revision: revisionCollected.entries, + usage, + }), + base.source, + revision.source + ); const summary = changes.reduce( (acc, change) => { 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/types.ts b/packages/cli/src/commands/diff/engine/types.ts index 49b53308ce..8008f412ad 100644 --- a/packages/cli/src/commands/diff/engine/types.ts +++ b/packages/cli/src/commands/diff/engine/types.ts @@ -16,6 +16,9 @@ export interface NodeEntry { 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; } From 74012ce1c1aa4cfdad26465fb3b7e53be4e2b1a9 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:22:30 +0300 Subject: [PATCH 11/57] feat(cli): group diff stylish output per operation with locations and verdicts Co-Authored-By: Claude Fable 5 --- .../diff/__tests__/serializers.test.ts | 93 ++++++++++++----- .../src/commands/diff/serializers/stylish.ts | 99 +++++++++++++++---- 2 files changed, 152 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/commands/diff/__tests__/serializers.test.ts b/packages/cli/src/commands/diff/__tests__/serializers.test.ts index 3c48a409c2..47dbe7bcab 100644 --- a/packages/cli/src/commands/diff/__tests__/serializers.test.ts +++ b/packages/cli/src/commands/diff/__tests__/serializers.test.ts @@ -1,3 +1,4 @@ +import { cleanColors } from '../../../utils/miscellaneous.js'; import type { DiffResult } from '../engine/types.js'; import { jsonDiff } from '../serializers/json.js'; import { stylishDiff } from '../serializers/stylish.js'; @@ -5,24 +6,27 @@ import { stylishDiff } from '../serializers/stylish.js'; const RESULT: DiffResult = { version: '1', specVersions: { base: 'oas3_1', revision: 'oas3_1' }, - summary: { breaking: 2, nonBreaking: 1 }, + summary: { breaking: 3, 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 }, + 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: [ { @@ -33,16 +37,22 @@ const RESULT: DiffResult = { ], }, { - pointer: '#/paths/~1pets/get/requestBody/content/application~1json', + pointer: '#/paths/~1pets/get/requestBody', property: 'schema', kind: 'changed', - typeName: 'MediaType', + typeName: 'RequestBody', base: { - pointer: '#/paths/~1pets/get/requestBody/content/application~1json/schema', + pointer: '#/paths/~1pets/get/requestBody/schema', + file: '/abs/base.yaml', + line: 14, + col: 9, value: '#/components/schemas/A', }, revision: { - pointer: '#/paths/~1pets/get/requestBody/content/application~1json/schema', + pointer: '#/paths/~1pets/get/requestBody/schema', + file: '/abs/rev.yaml', + line: 14, + col: 9, value: '#/components/schemas/B', }, compat: 'breaking', @@ -50,17 +60,54 @@ const RESULT: DiffResult = { { 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', + }, ], }; describe('stylishDiff', () => { - it('orders by severity and renders a summary', () => { - const output = stylishDiff(RESULT); - const breakingIndex = output.indexOf('parameter-became-required'); - const nonBreakingIndex = output.indexOf('description'); - expect(breakingIndex).toBeLessThan(nonBreakingIndex); - expect(output).toContain('2 breaking'); - expect(output).toContain('1 non-breaking'); + it('groups stylish output per operation with locations and all verdicts', () => { + // vitest.config.ts forces FORCE_COLOR=1, so strip ANSI codes from the real output: + const output = cleanColors(stylishDiff(RESULT)); + + expect(output).toContain('✖ breaking'); + 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.'); }); }); diff --git a/packages/cli/src/commands/diff/serializers/stylish.ts b/packages/cli/src/commands/diff/serializers/stylish.ts index 079efc83fe..0431b5cff4 100644 --- a/packages/cli/src/commands/diff/serializers/stylish.ts +++ b/packages/cli/src/commands/diff/serializers/stylish.ts @@ -1,6 +1,8 @@ -import { bold, gray, green, red } from 'colorette'; +import { unescapePointerFragment } from '@redocly/openapi-core'; +import { blue, bold, gray, green, red } from 'colorette'; +import * as path from 'node:path'; -import type { Change, Compat, DiffResult } from '../engine/types.js'; +import type { Change, ChangeSide, Compat, DiffResult } from '../engine/types.js'; const SEVERITY_ORDER: Compat[] = ['breaking', 'non-breaking']; @@ -9,27 +11,90 @@ const ICONS: Record = { 'non-breaking': green('✔ non-breaking'), }; -function label(change: Change): string { - return change.property ? `${change.pointer} · ${change.property}` : change.pointer; +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[] = []; - 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 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}`); + 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`)}.`); + lines.push(`${red(`${breaking} breaking`)}, ${green(`${nonBreaking} non-breaking`)}.`); return lines.join('\n'); } From c19e2bbe45bf2cdca671f60a6f6b14d4ae7f72d0 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:36:52 +0300 Subject: [PATCH 12/57] feat(cli): match renamed path parameters at the diff compare stage Co-Authored-By: Claude Fable 5 --- .../diff/engine/__tests__/align-paths.test.ts | 63 +++++++++ .../engine/__tests__/diff-documents.test.ts | 83 ++++++++++++ .../src/commands/diff/engine/align-paths.ts | 123 ++++++++++++++++++ .../cli/src/commands/diff/engine/index.ts | 30 ++++- .../src/commands/diff/engine/node-identity.ts | 10 +- 5 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/commands/diff/engine/__tests__/align-paths.test.ts create mode 100644 packages/cli/src/commands/diff/engine/align-paths.ts diff --git a/packages/cli/src/commands/diff/engine/__tests__/align-paths.test.ts b/packages/cli/src/commands/diff/engine/__tests__/align-paths.test.ts new file mode 100644 index 0000000000..7f05926bec --- /dev/null +++ b/packages/cli/src/commands/diff/engine/__tests__/align-paths.test.ts @@ -0,0 +1,63 @@ +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([]); + }); +}); diff --git a/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts b/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts index f86764cb51..e03b8c7582 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts +++ b/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts @@ -101,4 +101,87 @@ describe('diffDocuments', () => { 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, + }); + + 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(); + }); }); 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..407de51ce0 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/align-paths.ts @@ -0,0 +1,123 @@ +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 }; +} diff --git a/packages/cli/src/commands/diff/engine/index.ts b/packages/cli/src/commands/diff/engine/index.ts index ea4029f877..e7f99c00f0 100644 --- a/packages/cli/src/commands/diff/engine/index.ts +++ b/packages/cli/src/commands/diff/engine/index.ts @@ -8,15 +8,29 @@ import { 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 } from './types.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; @@ -44,7 +58,17 @@ export function diffDocuments(opts: { const baseCollected = collect(base, baseVersion); const revisionCollected = collect(revision, revisionVersion); - const rawChanges = compareMaps(baseCollected.entries, revisionCollected.entries); + 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( @@ -52,7 +76,7 @@ export function diffDocuments(opts: { changes: rawChanges, specVersion: revisionVersion, base: baseCollected.entries, - revision: revisionCollected.entries, + revision: alignedRevision, usage, }), base.source, diff --git a/packages/cli/src/commands/diff/engine/node-identity.ts b/packages/cli/src/commands/diff/engine/node-identity.ts index f42af77c99..9a2828ed14 100644 --- a/packages/cli/src/commands/diff/engine/node-identity.ts +++ b/packages/cli/src/commands/diff/engine/node-identity.ts @@ -1,7 +1,7 @@ import { isPlainObject } from '@redocly/openapi-core'; // JSON Pointer escaping for identity-key content: keys become pointer segments. -function esc(value: string): string { +export function escapeIdentityKeyPart(value: string): string { return value.replace(/~/g, '~0').replace(/\//g, '~1'); } @@ -12,11 +12,11 @@ type IdentityKeyFn = (value: Record) => string | undefined; const IDENTITY_KEYS: Record = { Parameter: (v) => typeof v.in === 'string' && typeof v.name === 'string' - ? `{${esc(v.in)}:${esc(v.name)}}` + ? `{${escapeIdentityKeyPart(v.in)}:${escapeIdentityKeyPart(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('+')}}`, + 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 { From c76251a849aaf289a35e09ee2217628c5aec8612 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:43:11 +0300 Subject: [PATCH 13/57] docs: update diff command docs for two-level compat, verdicts, locations, and path-param matching Co-Authored-By: Claude Fable 5 --- docs/@v2/commands/diff.md | 80 ++++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/docs/@v2/commands/diff.md b/docs/@v2/commands/diff.md index 1a9d09facb..a8870b3a53 100644 --- a/docs/@v2/commands/diff.md +++ b/docs/@v2/commands/diff.md @@ -8,7 +8,7 @@ This means it's still a work in progress and may go through major changes, inclu {% /admonition %} The `diff` command compares two API descriptions and reports what was added, removed, and changed. -For OpenAPI 3.x, changes are also classified as breaking, warning, or non-breaking, so you can catch breaking changes before they reach your consumers. +For OpenAPI 3.x, changes are also classified as breaking or non-breaking, so you can catch breaking changes before they reach your consumers. ## Usage @@ -16,34 +16,34 @@ For OpenAPI 3.x, changes are also classified as breaking, warning, or non-breaki 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 +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`, `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 | 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. | -| --version | boolean | Show version number. | +| 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`. 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. | +| --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`. +- Changes the tool detects but cannot judge automatically (for example, a `$ref` that now points to a different target) are conservatively reported as `breaking`. - Structural comparison works for all supported specification types; breaking-change classification applies to OpenAPI 3.x. {% admonition type="info" name="Limitations" %} The `diff` command detects common breaking changes using a documented rule catalog; it is not an exhaustive detector. -Renaming a component (for example, a schema or parameter) is seen as a removal plus an addition, not a rename, so the new `$ref` target is reported as a `warning` rather than matched to its previous identity. +Renaming a component (for example, a schema or parameter) is seen as a removal plus an addition, not a rename, so the new `$ref` target is reported as `breaking` (`ref-target-changed`) rather than matched to its previous identity. Comparing documents of different specification families (for example, OpenAPI 2.0 vs OpenAPI 3.1) is not supported. Reordering subschemas inside `allOf`, `oneOf`, or `anyOf` (and other lists without a natural identity) is matched positionally and may be reported as changes. `readOnly` and `writeOnly` do not refine request/response polarity; a component used on both sides is judged under both, and the stricter verdict wins. @@ -54,22 +54,40 @@ Changes under `callbacks` and `webhooks` receive structural diffing only, with n ## 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`). | +| 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. This is reported as `breaking`. | + +## Verdicts + +Each change carries ALL triggered rule verdicts in a `verdicts` array. Each verdict includes: + +- `ruleId`: The rule identifier (for example, `parameter-became-required`) +- `compat`: The compatibility classification (`breaking` or `non-breaking`) +- `message`: A human-readable description of the violation + +The change's own `compat` field, in every output format, is the most severe verdict across all triggered rules. + +### 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. ## Examples From 344d66af30d1c6c4317809e82de19f2dd7ba50d9 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:55:49 +0300 Subject: [PATCH 14/57] fix(cli): polish diff stylish labels and document callback rename edge case Co-Authored-By: Claude Fable 5 --- docs/@v2/commands/diff.md | 2 +- .../diff/__tests__/serializers.test.ts | 32 +++++++++++++++++-- .../src/commands/diff/serializers/stylish.ts | 6 +++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/docs/@v2/commands/diff.md b/docs/@v2/commands/diff.md index a8870b3a53..a5351b7b8f 100644 --- a/docs/@v2/commands/diff.md +++ b/docs/@v2/commands/diff.md @@ -87,7 +87,7 @@ Each change reports the source file, line, and column of the affected node on bo ### 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. +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. If a renamed path's operations define `callbacks` whose own path items declare a parameter with the same name as the renamed one, that callback parameter may be reported as removed and added; this is a cosmetic structural change, never a breaking verdict, because callbacks are not classified as request or response. ## Examples diff --git a/packages/cli/src/commands/diff/__tests__/serializers.test.ts b/packages/cli/src/commands/diff/__tests__/serializers.test.ts index 47dbe7bcab..ae6197c424 100644 --- a/packages/cli/src/commands/diff/__tests__/serializers.test.ts +++ b/packages/cli/src/commands/diff/__tests__/serializers.test.ts @@ -6,7 +6,7 @@ import { stylishDiff } from '../serializers/stylish.js'; const RESULT: DiffResult = { version: '1', specVersions: { base: 'oas3_1', revision: 'oas3_1' }, - summary: { breaking: 3, nonBreaking: 1 }, + summary: { breaking: 3, nonBreaking: 2 }, changes: [ { pointer: '#/paths/~1pets/get/parameters/{query:limit}', @@ -89,6 +89,27 @@ const RESULT: DiffResult = { }, compat: 'non-breaking', }, + { + pointer: '#/paths/~1pet~1{id}', + property: 'path', + kind: 'changed', + typeName: 'PathItem', + base: { + pointer: '#/paths/~1pet~1{id}', + file: '/abs/base.yaml', + line: 4, + col: 3, + value: '/pet/{id}', + }, + revision: { + pointer: '#/paths/~1pet~1{petId}', + file: '/abs/rev.yaml', + line: 4, + col: 3, + value: '/pet/{petId}', + }, + compat: 'non-breaking', + }, ], }; @@ -107,7 +128,14 @@ describe('stylishDiff', () => { // 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.'); + expect(output).toContain('3 breaking, 2 non-breaking.'); + + // synthetic path-rename change: grouped under the revision's real path, + // no method segment, and no leaked JSON-pointer escapes in the label. + expect(output).toContain('/pet/{petId}'); + expect(output).not.toContain('~1'); + expect(output).not.toContain('~0'); + expect(output).toMatch(/at .*rev\.yaml:4:3/); }); }); diff --git a/packages/cli/src/commands/diff/serializers/stylish.ts b/packages/cli/src/commands/diff/serializers/stylish.ts index 0431b5cff4..243b27779e 100644 --- a/packages/cli/src/commands/diff/serializers/stylish.ts +++ b/packages/cli/src/commands/diff/serializers/stylish.ts @@ -54,7 +54,11 @@ function labelOf(change: Change): string { ? segments.slice(3) : segments.slice(2) : segments; - const label = rest.join('/') || segments.join('/'); + // When there's nothing left after stripping the path/method prefix (e.g. a + // removed operation, or the synthetic path-rename change), fall back to the + // full pointer — but unescape each segment first so JSON-pointer escapes + // like `~1` never leak into the rendered label. + const label = rest.length ? rest.join('/') : segments.map(unescapePointerFragment).join(' · '); return change.property ? `${label} · ${change.property}` : label; } From be8f74dacf1fd20828294bf5263a23afd26f7716 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:34:50 +0300 Subject: [PATCH 15/57] fix(cli): align diff command with main and refresh e2e snapshots Adapt to the new collectSpecData signature (it now takes the document, not its parsed value) and regenerate the e2e snapshots, which still held the original flat output: they predate the two-level compat model, the per-operation grouping, the location and verdict lines, and the fail-on summary. Co-Authored-By: Claude Opus 5 --- packages/cli/src/commands/diff/index.ts | 2 +- .../diff/breaking-changes/json-snapshot.txt | 68 +++++++++++++------ .../breaking-changes/stylish-snapshot.txt | 28 ++++++-- 3 files changed, 73 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts index f10abfcca9..8a9411f84d 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -38,7 +38,7 @@ export async function handleDiff({ argv, config, collectSpecData }: CommandArgs< const { bundle: baseDocument } = await bundle({ config, ref: basePath }); const { bundle: revisionDocument } = await bundle({ config, ref: revisionPath }); - collectSpecData?.(revisionDocument.parsed); + collectSpecData?.(revisionDocument); let result: DiffResult; try { diff --git a/tests/e2e/diff/breaking-changes/json-snapshot.txt b/tests/e2e/diff/breaking-changes/json-snapshot.txt index b18d7a0ccf..b454ac2733 100644 --- a/tests/e2e/diff/breaking-changes/json-snapshot.txt +++ b/tests/e2e/diff/breaking-changes/json-snapshot.txt @@ -6,7 +6,6 @@ }, "summary": { "breaking": 3, - "warning": 0, "nonBreaking": 1 }, "changes": [ @@ -18,13 +17,19 @@ "pointer": "#/components/schemas/Pet/properties/tag", "value": { "type": "string" - } + }, + "file": "./tests/e2e/diff/breaking-changes/base.yaml", + "line": 33, + "col": 11 }, "compat": "breaking", - "ruleIds": [ - "property-removed-from-response" - ], - "message": "Schema property was removed." + "verdicts": [ + { + "ruleId": "property-removed-from-response", + "compat": "breaking", + "message": "Schema property was removed." + } + ] }, { "pointer": "#/info", @@ -33,11 +38,17 @@ "typeName": "Info", "base": { "pointer": "#/info/version", - "value": "1.0" + "value": "1.0", + "file": "./tests/e2e/diff/breaking-changes/base.yaml", + "line": 4, + "col": 12 }, "revision": { "pointer": "#/info/version", - "value": "2.0" + "value": "2.0", + "file": "./tests/e2e/diff/breaking-changes/revision.yaml", + "line": 4, + "col": 12 }, "compat": "non-breaking" }, @@ -53,13 +64,19 @@ "description": "Deleted" } } - } + }, + "file": "./tests/e2e/diff/breaking-changes/base.yaml", + "line": 21, + "col": 7 }, "compat": "breaking", - "ruleIds": [ - "operation-removed" - ], - "message": "Operation was removed." + "verdicts": [ + { + "ruleId": "operation-removed", + "compat": "breaking", + "message": "Operation was removed." + } + ] }, { "pointer": "#/paths/~1pets/get/parameters/{query:limit}", @@ -67,18 +84,31 @@ "kind": "changed", "typeName": "Parameter", "base": { - "pointer": "#/paths/~1pets/get/parameters/0/required" + "pointer": "#/paths/~1pets/get/parameters/0/required", + "file": "./tests/e2e/diff/breaking-changes/base.yaml", + "line": 9, + "col": 11 }, "revision": { "pointer": "#/paths/~1pets/get/parameters/0/required", - "value": true + "value": true, + "file": "./tests/e2e/diff/breaking-changes/revision.yaml", + "line": 11, + "col": 21 }, "compat": "breaking", - "ruleIds": [ - "parameter-became-required" - ], - "message": "Parameter became required." + "verdicts": [ + { + "ruleId": "parameter-became-required", + "compat": "breaking", + "message": "Parameter became required." + } + ] } ] } + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 3 breaking changes. diff --git a/tests/e2e/diff/breaking-changes/stylish-snapshot.txt b/tests/e2e/diff/breaking-changes/stylish-snapshot.txt index 142c42be82..e6e12ab774 100644 --- a/tests/e2e/diff/breaking-changes/stylish-snapshot.txt +++ b/tests/e2e/diff/breaking-changes/stylish-snapshot.txt @@ -1,7 +1,25 @@ -✖ breaking removed #/components/schemas/Pet/properties/tag — Schema property was removed. (property-removed-from-response) -✖ 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) -✔ non-breaking changed #/info · version +components + ✖ breaking removed components/schemas/Pet/properties/tag + Schema property was removed. (property-removed-from-response) + at base.yaml:33:11 -3 breaking, 0 warning, 1 non-breaking. +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. From 68f250c1cc187dfec49a5f30792a5359262f3a86 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:44:01 +0300 Subject: [PATCH 16/57] feat(cli): reuse the lint output formats in diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lint formatters in core already cover the formats CI tools expect, so map breaking changes onto lint problems and hand them to formatProblems instead of writing six more serializers. This adds codeframe, checkstyle, codeclimate, summary, github-actions, and junit to the diff command; with github-actions, every breaking change becomes an inline pull request annotation. A lint problem always carries a severity, so these formats describe breaking changes only — the full change list stays in the json format. They print to stdout, and --output now reports that clearly instead of writing nothing. Co-Authored-By: Claude Opus 5 --- docs/@v2/commands/diff.md | 35 +++++--- .../commands/diff/__tests__/problems.test.ts | 83 +++++++++++++++++++ packages/cli/src/commands/diff/index.ts | 57 +++++++++++-- .../commands/diff/serializers/change-side.ts | 9 ++ .../src/commands/diff/serializers/problems.ts | 38 +++++++++ .../src/commands/diff/serializers/stylish.ts | 11 +-- packages/cli/src/index.ts | 13 ++- .../github-actions-snapshot.txt | 8 ++ tests/e2e/diff/diff.test.ts | 23 +++++ 9 files changed, 248 insertions(+), 29 deletions(-) create mode 100644 packages/cli/src/commands/diff/__tests__/problems.test.ts create mode 100644 packages/cli/src/commands/diff/serializers/change-side.ts create mode 100644 packages/cli/src/commands/diff/serializers/problems.ts create mode 100644 tests/e2e/diff/breaking-changes/github-actions-snapshot.txt diff --git a/docs/@v2/commands/diff.md b/docs/@v2/commands/diff.md index a5351b7b8f..cb978831ad 100644 --- a/docs/@v2/commands/diff.md +++ b/docs/@v2/commands/diff.md @@ -21,17 +21,17 @@ 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`. 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. | -| --version | boolean | Show version number. | +| 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 @@ -107,3 +107,16 @@ Use `--format=html` together with `--output` to write a shareable HTML report in ```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 are shared with the `lint` command, so existing CI integrations accept the diff report as-is. +With `github-actions`, every breaking change becomes an inline annotation on the pull request: + +```bash +redocly diff main-openapi.yaml pr-openapi.yaml --format=github-actions +``` + +These formats describe **breaking changes only**, because each entry carries a severity. +Use `json` when you need the complete list of changes, including the non-breaking ones. +They print to stdout and don't support `--output`, and the `junit` report names its test suite `redocly lint`. 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..815ce3a257 --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/problems.test.ts @@ -0,0 +1,83 @@ +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', () => { + const problems = breakingChangesToProblems(result, baseSource, revisionSource); + + it('keeps only breaking changes', () => { + expect(problems).toHaveLength(2); + expect(problems.every((problem) => problem.severity === 'error')).toBe(true); + }); + + it('takes message and ruleId from the worst verdict', () => { + expect(problems[0]).toMatchObject({ + message: 'Operation was removed.', + ruleId: 'operation-removed', + }); + }); + + it('points a removal at the base document and a change at the revision', () => { + expect(problems[0].location[0]).toMatchObject({ + source: baseSource, + pointer: '#/paths/~1pets/delete', + }); + expect(problems[1].location[0]).toMatchObject({ + source: revisionSource, + pointer: '#/paths/~1pets/get/parameters/0/required', + }); + }); + + it('adds the counterpart side as `from` when the change has both sides', () => { + expect(problems[1].from).toMatchObject({ source: baseSource }); + // A removal is already shown at its base location, so it needs no `from`. + expect(problems[0].from).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts index 8a9411f84d..98cabbdc91 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -1,4 +1,10 @@ -import { bundle, logger, type OutputFormat } from '@redocly/openapi-core'; +import { + bundle, + formatProblems, + getTotals, + logger, + type OutputFormat, +} from '@redocly/openapi-core'; import { writeFileSync } from 'node:fs'; import type { VerifyConfigOptions } from '../../types.js'; @@ -11,9 +17,22 @@ 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'; -export type DiffOutputFormat = Extract; +/** Formats rendered by this command from the full DiffResult. */ +export type DiffOwnFormat = Extract; + +/** + * 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 = { @@ -24,13 +43,17 @@ export type DiffArgv = { 'fail-on': DiffFailOn; } & VerifyConfigOptions; -const SERIALIZERS: Record string> = { +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) { const startedAt = performance.now(); const [{ path: basePath }] = await getFallbackApisOrExit([argv.base], config); @@ -50,12 +73,30 @@ export async function handleDiff({ argv, config, collectSpecData }: CommandArgs< throw error; } - const output = SERIALIZERS[argv.format](result); - if (argv.output) { - writeFileSync(argv.output, output); - logger.info(`Diff report written to ${argv.output}.\n`); + 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 { - logger.output(output + '\n'); + if (argv.output) { + return exitWithError( + `The ${argv.format} format prints to stdout and cannot be written with --output. Use one of: ${Object.keys(SERIALIZERS).join(', ')}.` + ); + } + 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}`); 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/problems.ts b/packages/cli/src/commands/diff/serializers/problems.ts new file mode 100644 index 0000000000..a53468bfa6 --- /dev/null +++ b/packages/cli/src/commands/diff/serializers/problems.ts @@ -0,0 +1,38 @@ +import type { NormalizedProblem, Source } from '@redocly/openapi-core'; + +import type { Change, 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 sourceOf = (changeSide: Change['base']) => + changeSide === 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: sourceOf(side), 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 index 243b27779e..960f8f14fc 100644 --- a/packages/cli/src/commands/diff/serializers/stylish.ts +++ b/packages/cli/src/commands/diff/serializers/stylish.ts @@ -2,7 +2,8 @@ import { unescapePointerFragment } from '@redocly/openapi-core'; import { blue, bold, gray, green, red } from 'colorette'; import * as path from 'node:path'; -import type { Change, ChangeSide, Compat, DiffResult } from '../engine/types.js'; +import type { Change, Compat, DiffResult } from '../engine/types.js'; +import { displaySide } from './change-side.js'; const SEVERITY_ORDER: Compat[] = ['breaking', 'non-breaking']; @@ -23,14 +24,6 @@ const HTTP_METHODS = new Set([ '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('/'); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 820f8f480f..cd2697b5ba 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -152,7 +152,18 @@ yargs(hideBin(process.argv)) }, format: { description: 'Use a specific output format.', - choices: ['stylish', 'json', 'markdown', 'html'] as ReadonlyArray, + choices: [ + 'stylish', + 'json', + 'markdown', + 'html', + 'codeframe', + 'checkstyle', + 'codeclimate', + 'summary', + 'github-actions', + 'junit', + ] as ReadonlyArray, default: 'stylish' as const, }, output: { diff --git a/tests/e2e/diff/breaking-changes/github-actions-snapshot.txt b/tests/e2e/diff/breaking-changes/github-actions-snapshot.txt new file mode 100644 index 0000000000..2ca39e6143 --- /dev/null +++ b/tests/e2e/diff/breaking-changes/github-actions-snapshot.txt @@ -0,0 +1,8 @@ +::error title=property-removed-from-response,file=base.yaml,line=33,col=11,endLine=33,endColumn=23::Schema property was removed. +::error title=operation-removed,file=base.yaml,line=21,col=7,endLine=23,endColumn=31::Operation was removed. +::error title=parameter-became-required,file=revision.yaml,line=11,col=21,endLine=11,endColumn=25::Parameter became required. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 3 breaking changes. diff --git a/tests/e2e/diff/diff.test.ts b/tests/e2e/diff/diff.test.ts index 0ecf7de46e..f70d499feb 100644 --- a/tests/e2e/diff/diff.test.ts +++ b/tests/e2e/diff/diff.test.ts @@ -27,6 +27,29 @@ describe('diff', () => { await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'json-snapshot.txt')); }); + test('github-actions output annotates breaking changes only', async () => { + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + '--format=github-actions', + ]); + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, 'github-actions-snapshot.txt') + ); + }); + + test('rejects --output for formats that only print to stdout', () => { + const result = spawnSync( + 'node', + [indexEntryPoint, 'diff', 'base.yaml', 'revision.yaml', '--format=summary', '-o', 'out.txt'], + { encoding: 'utf-8', cwd: testPath, env: { ...process.env, NO_COLOR: 'TRUE' } } + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain('cannot be written with --output'); + }); + test('exits 1 on breaking changes with default fail-on', () => { const result = spawnSync('node', [indexEntryPoint, 'diff', 'base.yaml', 'revision.yaml'], { encoding: 'utf-8', From 4114d1251a45a7199d0a7f03e60c60d0f91a3cea Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:52:41 +0300 Subject: [PATCH 17/57] test(cli): add failing e2e tests for the diff rules still missing One test per rule, each with its own minimal base/revision pair, asserting the rule id the command should attribute the change to. Thirteen of them fail today and describe the intended contract: request body required and removed, string and numeric constraint tightening, additionalProperties, oneOf narrowing, format, the three security cases, response headers, and parameter serialization. Two of the failures are false positives rather than gaps: widening a request type is reported as breaking, and 3.0 `nullable: true` compared against 3.1 `type: [.., 'null']` reports a change although both describe the same schema. Co-Authored-By: Claude Opus 5 --- tests/e2e/diff/rules.test.ts | 173 ++++++++++++++++++ .../additional-properties-changed/base.yaml | 19 ++ .../revision.yaml | 19 ++ .../diff/rules/enum-values-removed/base.yaml | 18 ++ .../rules/enum-values-removed/revision.yaml | 17 ++ .../diff/rules/nullability-changed/base.yaml | 20 ++ .../rules/nullability-changed/revision.yaml | 18 ++ .../base.yaml | 19 ++ .../revision.yaml | 20 ++ .../rules/numeric-range-changed/base.yaml | 19 ++ .../rules/numeric-range-changed/revision.yaml | 19 ++ .../diff/rules/operation-removed/base.yaml | 14 ++ .../rules/operation-removed/revision.yaml | 10 + .../rules/parameter-became-required/base.yaml | 15 ++ .../parameter-became-required/revision.yaml | 16 ++ .../parameter-serialization-changed/base.yaml | 18 ++ .../revision.yaml | 18 ++ .../property-removed-from-response/base.yaml | 19 ++ .../revision.yaml | 17 ++ .../request-body-became-required/base.yaml | 18 ++ .../revision.yaml | 19 ++ .../diff/rules/request-body-removed/base.yaml | 18 ++ .../rules/request-body-removed/revision.yaml | 10 + .../rules/response-header-removed/base.yaml | 14 ++ .../response-header-removed/revision.yaml | 10 + .../rules/schema-combinator-changed/base.yaml | 17 ++ .../schema-combinator-changed/revision.yaml | 16 ++ .../rules/schema-format-changed/base.yaml | 18 ++ .../rules/schema-format-changed/revision.yaml | 19 ++ .../schema-type-widened-in-request/base.yaml | 18 ++ .../revision.yaml | 20 ++ .../security-requirement-added/base.yaml | 16 ++ .../security-requirement-added/revision.yaml | 18 ++ .../rules/security-scheme-changed/base.yaml | 18 ++ .../security-scheme-changed/revision.yaml | 17 ++ .../rules/string-length-changed/base.yaml | 19 ++ .../rules/string-length-changed/revision.yaml | 19 ++ 37 files changed, 792 insertions(+) create mode 100644 tests/e2e/diff/rules.test.ts create mode 100644 tests/e2e/diff/rules/additional-properties-changed/base.yaml create mode 100644 tests/e2e/diff/rules/additional-properties-changed/revision.yaml create mode 100644 tests/e2e/diff/rules/enum-values-removed/base.yaml create mode 100644 tests/e2e/diff/rules/enum-values-removed/revision.yaml create mode 100644 tests/e2e/diff/rules/nullability-changed/base.yaml create mode 100644 tests/e2e/diff/rules/nullability-changed/revision.yaml create mode 100644 tests/e2e/diff/rules/nullable-equivalence-across-versions/base.yaml create mode 100644 tests/e2e/diff/rules/nullable-equivalence-across-versions/revision.yaml create mode 100644 tests/e2e/diff/rules/numeric-range-changed/base.yaml create mode 100644 tests/e2e/diff/rules/numeric-range-changed/revision.yaml create mode 100644 tests/e2e/diff/rules/operation-removed/base.yaml create mode 100644 tests/e2e/diff/rules/operation-removed/revision.yaml create mode 100644 tests/e2e/diff/rules/parameter-became-required/base.yaml create mode 100644 tests/e2e/diff/rules/parameter-became-required/revision.yaml create mode 100644 tests/e2e/diff/rules/parameter-serialization-changed/base.yaml create mode 100644 tests/e2e/diff/rules/parameter-serialization-changed/revision.yaml create mode 100644 tests/e2e/diff/rules/property-removed-from-response/base.yaml create mode 100644 tests/e2e/diff/rules/property-removed-from-response/revision.yaml create mode 100644 tests/e2e/diff/rules/request-body-became-required/base.yaml create mode 100644 tests/e2e/diff/rules/request-body-became-required/revision.yaml create mode 100644 tests/e2e/diff/rules/request-body-removed/base.yaml create mode 100644 tests/e2e/diff/rules/request-body-removed/revision.yaml create mode 100644 tests/e2e/diff/rules/response-header-removed/base.yaml create mode 100644 tests/e2e/diff/rules/response-header-removed/revision.yaml create mode 100644 tests/e2e/diff/rules/schema-combinator-changed/base.yaml create mode 100644 tests/e2e/diff/rules/schema-combinator-changed/revision.yaml create mode 100644 tests/e2e/diff/rules/schema-format-changed/base.yaml create mode 100644 tests/e2e/diff/rules/schema-format-changed/revision.yaml create mode 100644 tests/e2e/diff/rules/schema-type-widened-in-request/base.yaml create mode 100644 tests/e2e/diff/rules/schema-type-widened-in-request/revision.yaml create mode 100644 tests/e2e/diff/rules/security-requirement-added/base.yaml create mode 100644 tests/e2e/diff/rules/security-requirement-added/revision.yaml create mode 100644 tests/e2e/diff/rules/security-scheme-changed/base.yaml create mode 100644 tests/e2e/diff/rules/security-scheme-changed/revision.yaml create mode 100644 tests/e2e/diff/rules/string-length-changed/base.yaml create mode 100644 tests/e2e/diff/rules/string-length-changed/revision.yaml diff --git a/tests/e2e/diff/rules.test.ts b/tests/e2e/diff/rules.test.ts new file mode 100644 index 0000000000..c1291e32e4 --- /dev/null +++ b/tests/e2e/diff/rules.test.ts @@ -0,0 +1,173 @@ +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const indexEntryPoint = join(process.cwd(), 'packages/cli/lib/index.js'); +const rulesPath = join(__dirname, 'rules'); + +interface DiffChange { + pointer: string; + property?: string; + kind: string; + compat: 'breaking' | 'non-breaking'; + verdicts?: { ruleId: string; message: string; compat: string }[]; +} + +interface DiffJson { + summary: { breaking: number; nonBreaking: number }; + changes: DiffChange[]; +} + +// The command prints the report on stdout and everything else on stderr, +// so stdout parses as JSON on its own. +function runDiff(fixture: string): DiffJson { + const result = spawnSync( + 'node', + [indexEntryPoint, 'diff', 'base.yaml', 'revision.yaml', '--format=json', '--fail-on=none'], + { encoding: 'utf-8', cwd: join(rulesPath, fixture), env: { ...process.env, NO_COLOR: 'TRUE' } } + ); + if (result.status !== 0) { + throw new Error(`diff failed for ${fixture}:\n${result.stderr}`); + } + return JSON.parse(result.stdout); +} + +function firedRuleIds(diff: DiffJson): string[] { + return [ + ...new Set(diff.changes.flatMap((change) => change.verdicts?.map((v) => v.ruleId) ?? [])), + ]; +} + +/** A change the command must report as breaking, attributed to `ruleId`. */ +const BREAKING: { fixture: string; ruleId: string; describes: string }[] = [ + // ── already covered + { + fixture: 'operation-removed', + ruleId: 'operation-removed', + describes: 'an operation disappears', + }, + { + fixture: 'parameter-became-required', + ruleId: 'parameter-became-required', + describes: 'an optional query parameter becomes required', + }, + { + fixture: 'property-removed-from-response', + ruleId: 'property-removed-from-response', + describes: 'a response property disappears', + }, + { + fixture: 'enum-values-removed', + ruleId: 'enum-values-removed', + describes: 'a request enum drops an accepted value', + }, + { + fixture: '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', + }, + + // ── not implemented yet + { + fixture: 'request-body-became-required', + ruleId: 'request-body-became-required', + describes: 'an optional request body becomes required', + }, + { + fixture: 'request-body-removed', + ruleId: 'request-body-removed', + describes: 'the request body disappears', + }, + { + fixture: 'string-length-changed', + ruleId: 'string-length-changed', + describes: 'maxLength shrinks on a request property', + }, + { + fixture: 'numeric-range-changed', + ruleId: 'numeric-range-changed', + describes: 'minimum rises on a request property', + }, + { + fixture: 'additional-properties-changed', + ruleId: 'additional-properties-changed', + describes: 'a request object stops accepting extra properties', + }, + { + fixture: 'schema-combinator-changed', + ruleId: 'schema-combinator-changed', + describes: 'a request oneOf drops an accepted subschema', + }, + { + fixture: 'schema-format-changed', + ruleId: 'schema-format-changed', + describes: 'a request property gains a format constraint', + }, + { + fixture: 'security-requirement-added', + ruleId: 'security-requirement-added', + describes: 'an open operation starts requiring authentication', + }, + { + fixture: 'security-scheme-changed', + ruleId: 'security-scheme-changed', + describes: 'a security scheme switches from apiKey to bearer', + }, + { + fixture: 'response-header-removed', + ruleId: 'response-header-removed', + describes: 'a response header disappears', + }, + { + fixture: 'parameter-serialization-changed', + ruleId: 'parameter-serialization-changed', + describes: 'an array parameter changes its serialization style', + }, +]; + +/** A change the command must NOT report as breaking. */ +const SAFE: { fixture: string; describes: string }[] = [ + { + fixture: 'schema-type-widened-in-request', + describes: 'a request property accepts more types than before', + }, + { + fixture: 'nullable-equivalence-across-versions', + describes: "3.0 `nullable: true` and 3.1 `type: [.., 'null']` describe the same schema", + }, +]; + +describe('diff rules', () => { + for (const { fixture, ruleId, describes } of BREAKING) { + test(`${ruleId}: reports breaking when ${describes}`, () => { + const diff = runDiff(fixture); + const change = diff.changes.find( + (candidate) => + candidate.compat === 'breaking' && + candidate.verdicts?.some((verdict) => verdict.ruleId === ruleId) + ); + + expect( + change, + `expected a breaking change from '${ruleId}', got ${ + firedRuleIds(diff).join(', ') || 'no rule verdicts' + } (breaking: ${diff.summary.breaking}, non-breaking: ${diff.summary.nonBreaking})` + ).toBeDefined(); + }); + } + + for (const { fixture, describes } of SAFE) { + test(`reports no breaking change when ${describes}`, () => { + const diff = runDiff(fixture); + + expect( + diff.summary.breaking, + `expected no breaking changes, got ${diff.summary.breaking} from ${ + firedRuleIds(diff).join(', ') || 'no rule verdicts' + }` + ).toBe(0); + }); + } +}); diff --git a/tests/e2e/diff/rules/additional-properties-changed/base.yaml b/tests/e2e/diff/rules/additional-properties-changed/base.yaml new file mode 100644 index 0000000000..2da090dc58 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/additional-properties-changed/revision.yaml b/tests/e2e/diff/rules/additional-properties-changed/revision.yaml new file mode 100644 index 0000000000..aba2088e72 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/enum-values-removed/base.yaml b/tests/e2e/diff/rules/enum-values-removed/base.yaml new file mode 100644 index 0000000000..15fd71566a --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/enum-values-removed/revision.yaml b/tests/e2e/diff/rules/enum-values-removed/revision.yaml new file mode 100644 index 0000000000..4d00a7e145 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/nullability-changed/base.yaml b/tests/e2e/diff/rules/nullability-changed/base.yaml new file mode 100644 index 0000000000..adaeb2f2b6 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/nullability-changed/revision.yaml b/tests/e2e/diff/rules/nullability-changed/revision.yaml new file mode 100644 index 0000000000..2cedf3ad32 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/nullable-equivalence-across-versions/base.yaml b/tests/e2e/diff/rules/nullable-equivalence-across-versions/base.yaml new file mode 100644 index 0000000000..b4719daabf --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/nullable-equivalence-across-versions/revision.yaml b/tests/e2e/diff/rules/nullable-equivalence-across-versions/revision.yaml new file mode 100644 index 0000000000..1af0bee554 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/numeric-range-changed/base.yaml b/tests/e2e/diff/rules/numeric-range-changed/base.yaml new file mode 100644 index 0000000000..61532db626 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/numeric-range-changed/revision.yaml b/tests/e2e/diff/rules/numeric-range-changed/revision.yaml new file mode 100644 index 0000000000..0aba616219 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/operation-removed/base.yaml b/tests/e2e/diff/rules/operation-removed/base.yaml new file mode 100644 index 0000000000..3f103f844f --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/operation-removed/revision.yaml b/tests/e2e/diff/rules/operation-removed/revision.yaml new file mode 100644 index 0000000000..5d6b1398ef --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/parameter-became-required/base.yaml b/tests/e2e/diff/rules/parameter-became-required/base.yaml new file mode 100644 index 0000000000..07037e92bd --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/parameter-became-required/revision.yaml b/tests/e2e/diff/rules/parameter-became-required/revision.yaml new file mode 100644 index 0000000000..3ed33a78f9 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/parameter-serialization-changed/base.yaml b/tests/e2e/diff/rules/parameter-serialization-changed/base.yaml new file mode 100644 index 0000000000..481c022a8e --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/parameter-serialization-changed/revision.yaml b/tests/e2e/diff/rules/parameter-serialization-changed/revision.yaml new file mode 100644 index 0000000000..261635dc07 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/property-removed-from-response/base.yaml b/tests/e2e/diff/rules/property-removed-from-response/base.yaml new file mode 100644 index 0000000000..425629b615 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/property-removed-from-response/revision.yaml b/tests/e2e/diff/rules/property-removed-from-response/revision.yaml new file mode 100644 index 0000000000..3fd7e36afd --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/request-body-became-required/base.yaml b/tests/e2e/diff/rules/request-body-became-required/base.yaml new file mode 100644 index 0000000000..00e85c0e6a --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/request-body-became-required/revision.yaml b/tests/e2e/diff/rules/request-body-became-required/revision.yaml new file mode 100644 index 0000000000..e671eb370e --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/request-body-removed/base.yaml b/tests/e2e/diff/rules/request-body-removed/base.yaml new file mode 100644 index 0000000000..ca860ce0d4 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/request-body-removed/revision.yaml b/tests/e2e/diff/rules/request-body-removed/revision.yaml new file mode 100644 index 0000000000..ada55feb27 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/response-header-removed/base.yaml b/tests/e2e/diff/rules/response-header-removed/base.yaml new file mode 100644 index 0000000000..3140ddd050 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/response-header-removed/revision.yaml b/tests/e2e/diff/rules/response-header-removed/revision.yaml new file mode 100644 index 0000000000..44a3476a70 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/schema-combinator-changed/base.yaml b/tests/e2e/diff/rules/schema-combinator-changed/base.yaml new file mode 100644 index 0000000000..36a186a9a9 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/schema-combinator-changed/revision.yaml b/tests/e2e/diff/rules/schema-combinator-changed/revision.yaml new file mode 100644 index 0000000000..83abd72a60 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/schema-format-changed/base.yaml b/tests/e2e/diff/rules/schema-format-changed/base.yaml new file mode 100644 index 0000000000..4684285afa --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/schema-format-changed/revision.yaml b/tests/e2e/diff/rules/schema-format-changed/revision.yaml new file mode 100644 index 0000000000..66899dba44 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/schema-type-widened-in-request/base.yaml b/tests/e2e/diff/rules/schema-type-widened-in-request/base.yaml new file mode 100644 index 0000000000..ad7df78e04 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/schema-type-widened-in-request/revision.yaml b/tests/e2e/diff/rules/schema-type-widened-in-request/revision.yaml new file mode 100644 index 0000000000..b2a87f4e0f --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/security-requirement-added/base.yaml b/tests/e2e/diff/rules/security-requirement-added/base.yaml new file mode 100644 index 0000000000..34665c03b2 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/security-requirement-added/revision.yaml b/tests/e2e/diff/rules/security-requirement-added/revision.yaml new file mode 100644 index 0000000000..13eee83ffb --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/security-scheme-changed/base.yaml b/tests/e2e/diff/rules/security-scheme-changed/base.yaml new file mode 100644 index 0000000000..7f277d9e1a --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/security-scheme-changed/revision.yaml b/tests/e2e/diff/rules/security-scheme-changed/revision.yaml new file mode 100644 index 0000000000..f8d4842e61 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/string-length-changed/base.yaml b/tests/e2e/diff/rules/string-length-changed/base.yaml new file mode 100644 index 0000000000..06b43c8c71 --- /dev/null +++ b/tests/e2e/diff/rules/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/rules/string-length-changed/revision.yaml b/tests/e2e/diff/rules/string-length-changed/revision.yaml new file mode 100644 index 0000000000..463e03674f --- /dev/null +++ b/tests/e2e/diff/rules/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 From 68bd9ce355df636411419a41d5a23063dc490452 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:45:48 +0300 Subject: [PATCH 18/57] feat(cli): read diff direction from node types and add twelve rules Polarity was inferred from the pointer text, which mistook a schema property named `responses` for the context of the same name and had to give up entirely under `callbacks` and `webhooks`. It now walks the ancestors the walker recorded and reads their node types, so the direction below a callback or a webhook is flipped rather than skipped, and a property can no longer pose as a context. That also uncovered a real defect: a usage edge named the `$ref` path, which is not a node and so could never be looked up; it now names the node holding the reference. The new rules cover request bodies becoming required or disappearing, numeric and string constraints, `format`, `additionalProperties`, `oneOf`/`allOf` membership, response headers, parameter serialization, and security schemes and requirements. They share one vocabulary: a constraint moves `tighter` or `looser`, and the engine's polarity decides which of the two breaks. Two false positives are gone with them. A type is now compared as the set of values it accepts, so widening a request type is no longer breaking, and 3.0's `nullable: true` folds into 3.1's `type: [..., 'null']` so the two spellings compare as equal. Co-Authored-By: Claude Opus 5 --- docs/@v2/commands/diff.md | 4 +- .../diff/engine/__tests__/align-paths.test.ts | 11 +- .../diff/engine/__tests__/classify.test.ts | 50 +++-- .../diff/engine/__tests__/collect.test.ts | 4 +- .../diff/engine/__tests__/compare.test.ts | 1 + .../diff/engine/__tests__/polarity.test.ts | 184 +++++++++++++----- .../diff/engine/__tests__/predicates.test.ts | 66 +++++-- .../rules-parameter-response.test.ts | 8 +- .../engine/__tests__/rules-schema.test.ts | 26 ++- .../commands/diff/engine/__tests__/tree.ts | 31 +++ .../commands/diff/engine/classify/chain.ts | 20 ++ .../commands/diff/engine/classify/index.ts | 6 +- .../src/commands/diff/engine/classify/oas3.ts | 37 +++- .../commands/diff/engine/classify/polarity.ts | 52 +++-- .../engine/classify/rules/parameter-rules.ts | 16 ++ .../classify/rules/request-body-rules.ts | 23 +++ .../engine/classify/rules/response-rules.ts | 15 ++ .../engine/classify/rules/schema-rules.ts | 167 +++++++++++++++- .../engine/classify/rules/security-rules.ts | 52 +++++ .../commands/diff/engine/classify/usage.ts | 26 ++- .../cli/src/commands/diff/engine/collect.ts | 5 +- .../cli/src/commands/diff/engine/index.ts | 11 +- .../src/commands/diff/engine/predicates.ts | 92 ++++++++- .../cli/src/commands/diff/engine/types.ts | 3 + .../src/commands/diff/serializers/stylish.ts | 2 + 25 files changed, 776 insertions(+), 136 deletions(-) create mode 100644 packages/cli/src/commands/diff/engine/__tests__/tree.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/chain.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/rules/request-body-rules.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/rules/security-rules.ts diff --git a/docs/@v2/commands/diff.md b/docs/@v2/commands/diff.md index cb978831ad..d74916c14b 100644 --- a/docs/@v2/commands/diff.md +++ b/docs/@v2/commands/diff.md @@ -40,6 +40,7 @@ redocly diff main@v1 main@v2 --fail-on=breaking - 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 conservatively reported as `breaking`. - Structural comparison works for all supported specification types; breaking-change classification applies to OpenAPI 3.x. +- Under `callbacks` and `webhooks` the direction is flipped, because the API sends those: their request body is judged the way a response is, and their responses the way a request is. {% admonition type="info" name="Limitations" %} The `diff` command detects common breaking changes using a documented rule catalog; it is not an exhaustive detector. @@ -49,7 +50,6 @@ Reordering subschemas inside `allOf`, `oneOf`, or `anyOf` (and other lists witho `readOnly` and `writeOnly` do not refine request/response polarity; a component used on both sides is judged under both, and the stricter verdict wins. Reordering items that do have an identity (for example, `servers`, matched by URL) is not reported, even though `servers` order can be semantically meaningful. Comparing across minor versions (OpenAPI 3.0 vs 3.1) can surface syntactic-only differences (for example, `nullable: true` vs `type: [..., "null"]`). -Changes under `callbacks` and `webhooks` receive structural diffing only, with no breaking-change classification, because their request/response direction is inverted. {% /admonition %} ## Breaking change rules @@ -87,7 +87,7 @@ Each change reports the source file, line, and column of the affected node on bo ### 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. If a renamed path's operations define `callbacks` whose own path items declare a parameter with the same name as the renamed one, that callback parameter may be reported as removed and added; this is a cosmetic structural change, never a breaking verdict, because callbacks are not classified as request or response. +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. If a renamed path's operations define `callbacks` whose own path items declare a parameter with the same name as the renamed one, that callback parameter may be reported as removed and added; this is a cosmetic structural change rather than a real one. ## Examples diff --git a/packages/cli/src/commands/diff/engine/__tests__/align-paths.test.ts b/packages/cli/src/commands/diff/engine/__tests__/align-paths.test.ts index 7f05926bec..5e4f771ef3 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/align-paths.test.ts +++ b/packages/cli/src/commands/diff/engine/__tests__/align-paths.test.ts @@ -2,7 +2,16 @@ 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: {} }; + return { + pointer, + realPointer: pointer, + parentPointer, + keyInParent: '', + typeName, + scalars: {}, + refs: {}, + raw: {}, + }; } function side(template: string, paramName: string): Map { diff --git a/packages/cli/src/commands/diff/engine/__tests__/classify.test.ts b/packages/cli/src/commands/diff/engine/__tests__/classify.test.ts index 292accd021..03c7a9c127 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/classify.test.ts +++ b/packages/cli/src/commands/diff/engine/__tests__/classify.test.ts @@ -1,11 +1,12 @@ import { classifyChanges } from '../classify/index.js'; import { UsageIndex } from '../classify/usage.js'; import type { NodeEntry, RawChange } from '../types.js'; +import { treeOf } from './tree.js'; const emptyMaps = { base: new Map(), revision: new Map(), - usage: new UsageIndex([]), + usage: new UsageIndex([], () => undefined), }; describe('classifyChanges', () => { @@ -84,16 +85,39 @@ describe('classifyChanges', () => { }); 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', - }, - ]); + // 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', @@ -107,8 +131,8 @@ describe('classifyChanges', () => { const [change] = classifyChanges({ changes, specVersion: 'oas3_1', - base: new Map(), - revision: new Map(), + base: entries, + revision: entries, usage, }); expect(change.compat).toBe('breaking'); diff --git a/packages/cli/src/commands/diff/engine/__tests__/collect.test.ts b/packages/cli/src/commands/diff/engine/__tests__/collect.test.ts index acb7b12b5f..589868f25e 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/collect.test.ts +++ b/packages/cli/src/commands/diff/engine/__tests__/collect.test.ts @@ -84,8 +84,10 @@ describe('collectDocumentMap', () => { 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/schema', + site: '#/paths/~1pets/get/responses/200/content/application~1json', target: '#/components/schemas/Pet', }); }); diff --git a/packages/cli/src/commands/diff/engine/__tests__/compare.test.ts b/packages/cli/src/commands/diff/engine/__tests__/compare.test.ts index 28297fd940..5f7fd7e224 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/compare.test.ts +++ b/packages/cli/src/commands/diff/engine/__tests__/compare.test.ts @@ -5,6 +5,7 @@ function entry(partial: Partial & { pointer: string }): NodeEntry { return { realPointer: partial.pointer, parentPointer: null, + keyInParent: '', typeName: 'Schema', scalars: {}, refs: {}, diff --git a/packages/cli/src/commands/diff/engine/__tests__/polarity.test.ts b/packages/cli/src/commands/diff/engine/__tests__/polarity.test.ts index 639d7c188b..7b0e162fab 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/polarity.test.ts +++ b/packages/cli/src/commands/diff/engine/__tests__/polarity.test.ts @@ -1,12 +1,68 @@ +import type { NodeLookup } from '../classify/chain.js'; import { getPolarity } from '../classify/polarity.js'; -import { UsageIndex, getComponentRoot, mergePolarity } from '../classify/usage.js'; +import { getComponentRoot, mergePolarity, UsageIndex } from '../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('extracts the component root', () => { - expect(getComponentRoot('#/components/schemas/Pet/properties/name')).toBe( + it('finds the component a node belongs to', () => { + expect(getComponentRoot('#/components/schemas/Pet/properties/name', tree)).toBe( '#/components/schemas/Pet' ); - expect(getComponentRoot('#/paths/~1pets/get')).toBeUndefined(); + expect(getComponentRoot('#/components/schemas/Pet', tree)).toBe('#/components/schemas/Pet'); + }); + + it('returns undefined outside components', () => { + expect(getComponentRoot('#/paths/~1p/get', tree)).toBeUndefined(); }); }); @@ -20,68 +76,96 @@ describe('mergePolarity', () => { }); 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( + it('reads the direction off the node types on the way down', () => { + expect(getPolarity('#/paths/~1p/get/responses/200', emptyUsage, tree)).toBe('response'); + expect(getPolarity('#/paths/~1p/get/parameters/{query:limit}/schema', emptyUsage, tree)).toBe( 'request' ); - expect(getPolarity('#/info/title', emptyUsage)).toBe('neutral'); - expect(getPolarity('#/tags/{pets}', emptyUsage)).toBe('neutral'); + expect( + getPolarity('#/paths/~1p/post/requestBody/content/application~1json', emptyUsage, tree) + ).toBe('request'); + expect(getPolarity('#/info/title', emptyUsage, tree)).toBe('neutral'); + expect(getPolarity('#/tags/{pets}', emptyUsage, tree)).toBe('neutral'); }); - it('treats callbacks and webhooks as neutral (inverted direction, not judged in v1)', () => { + it('flips the direction under callbacks and webhooks', () => { + // The API sends these, so their request body reaches the consumer like a response. expect( - getPolarity('#/paths/~1p/post/callbacks/onEvent/~1cb/post/requestBody', emptyUsage) - ).toBe('neutral'); - expect(getPolarity('#/webhooks/newPet/post/parameters/{query:x}', emptyUsage)).toBe('neutral'); + getPolarity('#/paths/~1p/post/callbacks/onEvent/~1cb/post/requestBody', emptyUsage, tree) + ).toBe('response'); + expect(getPolarity('#/webhooks/newPet/post/requestBody', emptyUsage, tree)).toBe('response'); + // ...and what the consumer answers with is a request. + expect(getPolarity('#/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( + getPolarity( + '#/paths/~1p/post/requestBody/content/application~1json/schema/properties/responses', + emptyUsage, + tree + ) + ).toBe('request'); + expect( + getPolarity( + '#/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/~1pets/get/responses/200/content/application~1json/schema', - target: '#/components/schemas/Pet', - }, - ]); - expect(getPolarity('#/components/schemas/Pet/properties/name', usage)).toBe('response'); + const usage = new UsageIndex( + [ + { + site: '#/paths/~1p/get/responses/200/content/application~1json/schema', + target: '#/components/schemas/Pet', + }, + ], + tree + ); + expect(getPolarity('#/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/~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'); + 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(getPolarity('#/components/schemas/Pet', usage, tree)).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'); + 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(getPolarity('#/components/schemas/Address', usage, tree)).toBe('response'); }); it('returns neutral for unused components', () => { - expect(getPolarity('#/components/schemas/Orphan', emptyUsage)).toBe('neutral'); + expect(getPolarity('#/components/schemas/Orphan', emptyUsage, tree)).toBe('neutral'); }); }); diff --git a/packages/cli/src/commands/diff/engine/__tests__/predicates.test.ts b/packages/cli/src/commands/diff/engine/__tests__/predicates.test.ts index 1eba2a40a8..f972672be0 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/predicates.test.ts +++ b/packages/cli/src/commands/diff/engine/__tests__/predicates.test.ts @@ -5,8 +5,10 @@ import { missingItems, addedItems, becameTrue, - isTypeNarrowed, - isTypeWidened, + constraintDirection, + effectiveTypes, + isTypeSetNarrowed, + isTypeSetWidened, } from '../predicates.js'; describe('diff predicates', () => { @@ -43,18 +45,60 @@ describe('diff predicates', () => { expect(becameTrue(true, false)).toBe(false); }); - it('classifies type narrowing and widening', () => { + 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(isTypeNarrowed('integer', 'number')).toBe(false); - expect(isTypeWidened('integer', 'number')).toBe(true); + expect(narrowed('integer', 'number')).toBe(false); + expect(widened('integer', 'number')).toBe(true); // number → integer narrows it - expect(isTypeNarrowed('number', 'integer')).toBe(true); - expect(isTypeWidened('number', 'integer')).toBe(false); + expect(narrowed('number', 'integer')).toBe(true); + expect(widened('number', 'integer')).toBe(false); // string → number is incompatible both ways - expect(isTypeNarrowed('string', 'number')).toBe(true); - expect(isTypeWidened('string', 'number')).toBe(true); + expect(narrowed('string', 'number')).toBe(true); + expect(widened('string', 'number')).toBe(true); // same type — neither - expect(isTypeNarrowed('string', 'string')).toBe(false); - expect(isTypeWidened('string', 'string')).toBe(false); + 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/engine/__tests__/rules-parameter-response.test.ts b/packages/cli/src/commands/diff/engine/__tests__/rules-parameter-response.test.ts index 0af35b83b4..0692e7e52a 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/rules-parameter-response.test.ts +++ b/packages/cli/src/commands/diff/engine/__tests__/rules-parameter-response.test.ts @@ -7,7 +7,13 @@ import { mediaTypeRemoved, responseRemoved } from '../classify/rules/response-ru import type { RawChange, RuleContext } from '../types.js'; function ctx(polarity: RuleContext['polarity']): RuleContext { - return { polarity, specVersion: 'oas3_1', base: () => undefined, revision: () => undefined }; + return { + polarity, + specVersion: 'oas3_1', + base: () => undefined, + revision: () => undefined, + nodeAt: () => undefined, + }; } describe('parameter rules', () => { diff --git a/packages/cli/src/commands/diff/engine/__tests__/rules-schema.test.ts b/packages/cli/src/commands/diff/engine/__tests__/rules-schema.test.ts index c7ff314c20..3039da567c 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/rules-schema.test.ts +++ b/packages/cli/src/commands/diff/engine/__tests__/rules-schema.test.ts @@ -8,6 +8,7 @@ import { schemaTypeChanged, } from '../classify/rules/schema-rules.js'; import type { NodeEntry, RawChange, RuleContext } from '../types.js'; +import { treeOf } from './tree.js'; function ctx( polarity: RuleContext['polarity'], @@ -18,6 +19,7 @@ function ctx( specVersion: 'oas3_1', base: (p) => maps.base?.get(p), revision: (p) => maps.revision?.get(p), + nodeAt: (p) => maps.revision?.get(p) ?? maps.base?.get(p), }; } @@ -64,22 +66,39 @@ describe('schema rules', () => { }); it('property-removed-from-response fires only for schema-property nodes in response', () => { + // The rule tells a property from a subschema by its parent's node type, + // so the removed nodes need their real place in the tree. + const base = treeOf(` + #/components Components + #/components/schemas NamedSchemas + #/components/schemas/Pet Schema + #/components/schemas/Pet/properties SchemaProperties + #/components/schemas/Pet/properties/name Schema + #/components/schemas/Pet/oneOf SchemaList + #/components/schemas/Pet/oneOf/0 Schema + `); + 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(); + expect(propertyRemovedFromResponse.visit(change, ctx('response', { base }))?.compat).toBe( + 'breaking' + ); + expect(propertyRemovedFromResponse.visit(change, ctx('request', { base }))).toBeUndefined(); + // A member of `oneOf` is a subschema, not a property, so the rule stays quiet. 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(); + expect( + propertyRemovedFromResponse.visit(notAProperty, ctx('response', { base })) + ).toBeUndefined(); }); }); @@ -93,6 +112,7 @@ describe('ref-target-changed', () => { pointer, realPointer: pointer, parentPointer: null, + keyInParent: '', typeName: 'MediaType', scalars: {}, refs: { schema: '#/components/schemas/Pet' }, diff --git a/packages/cli/src/commands/diff/engine/__tests__/tree.ts b/packages/cli/src/commands/diff/engine/__tests__/tree.ts new file mode 100644 index 0000000000..b83a24953d --- /dev/null +++ b/packages/cli/src/commands/diff/engine/__tests__/tree.ts @@ -0,0 +1,31 @@ +import type { NodeEntry } from '../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`, 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] = 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: {}, + refs: {}, + raw: {}, + }); + } + + return entries; +} 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 index d7603dcf34..1fba8636b4 100644 --- a/packages/cli/src/commands/diff/engine/classify/index.ts +++ b/packages/cli/src/commands/diff/engine/classify/index.ts @@ -9,6 +9,7 @@ import { type Polarity, type RawChange, } from '../types.js'; +import type { NodeLookup } from './chain.js'; import { oas3Rules } from './oas3.js'; import { oas3_1Rules } from './oas3_1.js'; import { getPolarity } from './polarity.js'; @@ -33,17 +34,20 @@ export function classifyChanges(opts: { }): Change[] { const { changes, specVersion, base, revision, usage } = opts; const registry = REGISTRIES[specVersion] ?? {}; + // 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 = registry[change.typeName] ?? []; const verdicts: ChangeVerdict[] = []; - for (const polarity of expandPolarity(getPolarity(change.pointer, usage))) { + for (const polarity of expandPolarity(getPolarity(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); diff --git a/packages/cli/src/commands/diff/engine/classify/oas3.ts b/packages/cli/src/commands/diff/engine/classify/oas3.ts index e8f124d7b9..d1edae76aa 100644 --- a/packages/cli/src/commands/diff/engine/classify/oas3.ts +++ b/packages/cli/src/commands/diff/engine/classify/oas3.ts @@ -4,25 +4,51 @@ import { parameterAddedRequired, parameterBecameRequired, parameterRemoved, + parameterSerializationChanged, } from './rules/parameter-rules.js'; import { refTargetChanged } from './rules/ref-rules.js'; -import { mediaTypeRemoved, responseRemoved } from './rules/response-rules.js'; +import { requestBodyBecameRequired, requestBodyRemoved } from './rules/request-body-rules.js'; import { + mediaTypeRemoved, + responseHeaderRemoved, + responseRemoved, +} from './rules/response-rules.js'; +import { + additionalPropertiesChanged, enumValuesAdded, enumValuesRemoved, + numericRangeChanged, propertyRemovedFromResponse, requiredPropertiesAdded, requiredPropertiesRemoved, + schemaCombinatorChanged, + schemaFormatChanged, schemaTypeChanged, + stringLengthChanged, } from './rules/schema-rules.js'; +import { + securityRequirementAdded, + securitySchemeChanged, + securitySchemeRemoved, +} from './rules/security-rules.js'; export const oas3Rules: DiffRuleRegistry = { Operation: [operationRemoved], PathItem: [pathRemoved, refTargetChanged], - Parameter: [parameterRemoved, parameterAddedRequired, parameterBecameRequired, refTargetChanged], + Parameter: [ + parameterRemoved, + parameterAddedRequired, + parameterBecameRequired, + parameterSerializationChanged, + refTargetChanged, + ], Response: [responseRemoved, refTargetChanged], + Header: [responseHeaderRemoved, refTargetChanged], + HeadersMap: [responseHeaderRemoved], MediaType: [mediaTypeRemoved, refTargetChanged], - RequestBody: [refTargetChanged], + RequestBody: [requestBodyRemoved, requestBodyBecameRequired, refTargetChanged], + SecurityRequirementList: [securityRequirementAdded], + SecurityScheme: [securitySchemeChanged, securitySchemeRemoved, refTargetChanged], Schema: [ schemaTypeChanged, enumValuesRemoved, @@ -30,6 +56,11 @@ export const oas3Rules: DiffRuleRegistry = { requiredPropertiesAdded, requiredPropertiesRemoved, propertyRemovedFromResponse, + numericRangeChanged, + stringLengthChanged, + schemaFormatChanged, + additionalPropertiesChanged, + schemaCombinatorChanged, refTargetChanged, ], }; diff --git a/packages/cli/src/commands/diff/engine/classify/polarity.ts b/packages/cli/src/commands/diff/engine/classify/polarity.ts index 71c9876be9..2f9031eee1 100644 --- a/packages/cli/src/commands/diff/engine/classify/polarity.ts +++ b/packages/cli/src/commands/diff/engine/classify/polarity.ts @@ -1,27 +1,41 @@ import type { Polarity } from '../types.js'; +import { ancestorChain, type NodeLookup } from './chain.js'; import { getComponentRoot, type UsageIndex } from './usage.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('/'); -} +// 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. +const INVERTING_TYPES = new Set(['Callback', 'CallbacksMap', 'WebhooksMap']); -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); +function getSitePolarity(pointer: string, lookup: NodeLookup): Polarity { + let inverted = false; + + for (const { typeName } of ancestorChain(pointer, lookup)) { + if (INVERTING_TYPES.has(typeName)) { + inverted = true; + } else if (RESPONSE_TYPES.has(typeName)) { + return inverted ? 'request' : 'response'; + } else if (REQUEST_TYPES.has(typeName)) { + return inverted ? 'response' : 'request'; + } } - 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'; } + +export function getPolarity(pointer: string, usage: UsageIndex, lookup: NodeLookup): Polarity { + // 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) => getSitePolarity(site, lookup)); + } + + return getSitePolarity(pointer, lookup); +} diff --git a/packages/cli/src/commands/diff/engine/classify/rules/parameter-rules.ts b/packages/cli/src/commands/diff/engine/classify/rules/parameter-rules.ts index d5e5f37017..2715624490 100644 --- a/packages/cli/src/commands/diff/engine/classify/rules/parameter-rules.ts +++ b/packages/cli/src/commands/diff/engine/classify/rules/parameter-rules.ts @@ -36,3 +36,19 @@ export const parameterBecameRequired: DiffRule = { 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/request-body-rules.ts b/packages/cli/src/commands/diff/engine/classify/rules/request-body-rules.ts new file mode 100644 index 0000000000..b0adc198f3 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/rules/request-body-rules.ts @@ -0,0 +1,23 @@ +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: 'Dropping the request body means the data clients send is no longer read.', + 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-rules.ts b/packages/cli/src/commands/diff/engine/classify/rules/response-rules.ts index 84ad98eb4e..a3ae7eeb03 100644 --- a/packages/cli/src/commands/diff/engine/classify/rules/response-rules.ts +++ b/packages/cli/src/commands/diff/engine/classify/rules/response-rules.ts @@ -17,3 +17,18 @@ export const mediaTypeRemoved: DiffRule = { 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-rules.ts b/packages/cli/src/commands/diff/engine/classify/rules/schema-rules.ts index 3ae364cf76..a936c570e8 100644 --- a/packages/cli/src/commands/diff/engine/classify/rules/schema-rules.ts +++ b/packages/cli/src/commands/diff/engine/classify/rules/schema-rules.ts @@ -1,5 +1,28 @@ -import { addedItems, isTypeNarrowed, isTypeWidened, missingItems } from '../../predicates.js'; -import { breaking, type DiffRule } from '../../types.js'; +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', @@ -7,13 +30,20 @@ export const schemaTypeChanged: DiffRule = { '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}'.`); + // `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' && isTypeWidened(before, after)) { - return breaking(`Schema type changed from '${before}' to '${after}'.`); + if (ctx.polarity === 'response' && isTypeSetWidened(before, after)) { + return breaking(`Schema type widened ${described}.`); } return undefined; }, @@ -76,8 +106,125 @@ export const propertyRemovedFromResponse: DiffRule = { 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; + // 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.'); }, }; + +const NUMERIC_CONSTRAINTS = new Set([ + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + 'multipleOf', +]); + +export const numericRangeChanged: DiffRule = { + id: 'numeric-range-changed', + description: 'Moving a numeric bound changes which values the API accepts or returns.', + visit(change, ctx) { + if (!change.property || !NUMERIC_CONSTRAINTS.has(change.property)) return; + const direction = constraintDirection( + change.property, + change.base?.value, + change.revision?.value + ); + return verdictFor( + direction, + ctx.polarity, + `\`${change.property}\` changed from '${change.base?.value}' to '${change.revision?.value}'.` + ); + }, +}; + +const STRING_CONSTRAINTS = new Set(['minLength', 'maxLength', 'pattern']); + +export const stringLengthChanged: DiffRule = { + id: 'string-length-changed', + description: 'Changing a string constraint changes which values the API accepts or returns.', + visit(change, ctx) { + if (!change.property || !STRING_CONSTRAINTS.has(change.property)) return; + const direction = constraintDirection( + change.property, + change.base?.value, + change.revision?.value + ); + return verdictFor( + direction, + ctx.polarity, + `\`${change.property}\` changed from '${change.base?.value}' to '${change.revision?.value}'.` + ); + }, +}; + +export const schemaFormatChanged: DiffRule = { + id: 'schema-format-changed', + description: 'A format constrains the accepted values beyond the type itself.', + visit(change, ctx) { + if (change.property !== 'format') return; + const direction = constraintDirection('format', change.base?.value, change.revision?.value); + return verdictFor( + direction, + ctx.polarity, + change.base?.value === undefined + ? `Format '${change.revision?.value}' was added.` + : `Format changed from '${change.base?.value}' to '${change.revision?.value}'.` + ); + }, +}; + +export const additionalPropertiesChanged: DiffRule = { + id: 'additional-properties-changed', + description: 'Whether extra properties are allowed decides what an object may carry.', + visit(change, ctx) { + if (change.property !== 'additionalProperties') return; + const direction = constraintDirection( + 'additionalProperties', + change.base?.value, + change.revision?.value + ); + return verdictFor( + direction, + ctx.polarity, + `\`additionalProperties\` changed from '${change.base?.value}' to '${change.revision?.value}'.` + ); + }, +}; + +// `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); + const isAlternative = ALTERNATIVE_COMBINATORS.has(combinator); + if (!isAlternative && combinator !== 'allOf') return; + + const removed = change.kind === 'removed'; + const direction: ConstraintDirection = isAlternative + ? removed + ? 'tighter' + : 'looser' + : removed + ? 'looser' + : 'tighter'; + + return verdictFor( + direction, + ctx.polarity, + `A \`${combinator}\` subschema was ${removed ? 'removed' : 'added'}.` + ); + }, +}; diff --git a/packages/cli/src/commands/diff/engine/classify/rules/security-rules.ts b/packages/cli/src/commands/diff/engine/classify/rules/security-rules.ts new file mode 100644 index 0000000000..74ce892a43 --- /dev/null +++ b/packages/cli/src/commands/diff/engine/classify/rules/security-rules.ts @@ -0,0 +1,52 @@ +import { breaking, type DiffRule } 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. + +export const securityRequirementAdded: DiffRule = { + id: 'security-requirement-added', + description: 'Requiring authentication where there was none breaks every existing client.', + visit(change) { + // The whole `security` list appearing means authentication was introduced. + // A new entry inside an existing list is one more accepted alternative, which + // arrives as a change on the entry rather than on the list, and is not breaking. + if (change.kind !== 'added') return; + return breaking('The operation 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.'); + }, +}; diff --git a/packages/cli/src/commands/diff/engine/classify/usage.ts b/packages/cli/src/commands/diff/engine/classify/usage.ts index 98b339502d..707240367e 100644 --- a/packages/cli/src/commands/diff/engine/classify/usage.ts +++ b/packages/cli/src/commands/diff/engine/classify/usage.ts @@ -1,8 +1,16 @@ import type { Polarity } from '../types.js'; +import { ancestorChain, type NodeLookup } from './chain.js'; -export function getComponentRoot(pointer: string): string | undefined { - const match = pointer.match(/^(#\/components\/[^/]+\/[^/]+)/); - return match?.[1]; +/** + * 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 { @@ -15,14 +23,18 @@ export function mergePolarity(a: Polarity, b: Polarity): Polarity { export class UsageIndex { private sitesByTarget = new Map>(); - constructor(edges: Array<{ site: string; target: string }>) { + constructor( + edges: Array<{ site: string; target: string }>, + private lookup: NodeLookup + ) { for (const { site, target } of edges) { - const root = getComponentRoot(target) ?? target; + 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 => { @@ -31,7 +43,7 @@ export class UsageIndex { 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 siteComponentRoot = getComponentRoot(site, this.lookup); const sitePolarity = siteComponentRoot ? visit(siteComponentRoot) : resolveSitePolarity(site); @@ -40,6 +52,6 @@ export class UsageIndex { } return result; }; - return visit(getComponentRoot(componentPointer) ?? componentPointer); + 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 index 0d3836334d..2ad0dd59ab 100644 --- a/packages/cli/src/commands/diff/engine/collect.ts +++ b/packages/cli/src/commands/diff/engine/collect.ts @@ -69,7 +69,9 @@ export function collectDocumentMap(opts: { for (const [prop, value] of Object.entries(node)) { if (isRef(value)) { refs[prop] = value.$ref; - usageEdges.push({ site: `${pointer}/${prop}`, target: 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; } @@ -80,6 +82,7 @@ export function collectDocumentMap(opts: { pointer, realPointer, parentPointer: stableParent, + keyInParent: ctx.key, typeName: ctx.type.name, scalars, refs, diff --git a/packages/cli/src/commands/diff/engine/index.ts b/packages/cli/src/commands/diff/engine/index.ts index e7f99c00f0..4091c0a159 100644 --- a/packages/cli/src/commands/diff/engine/index.ts +++ b/packages/cli/src/commands/diff/engine/index.ts @@ -67,9 +67,14 @@ export function diffDocuments(opts: { ...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]); + // 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({ diff --git a/packages/cli/src/commands/diff/engine/predicates.ts b/packages/cli/src/commands/diff/engine/predicates.ts index 95961f560f..3d3e689207 100644 --- a/packages/cli/src/commands/diff/engine/predicates.ts +++ b/packages/cli/src/commands/diff/engine/predicates.ts @@ -32,17 +32,89 @@ 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'] }; +// `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' }; -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); +/** + * 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; } -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); +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 index 8008f412ad..59006e847a 100644 --- a/packages/cli/src/commands/diff/engine/types.ts +++ b/packages/cli/src/commands/diff/engine/types.ts @@ -8,6 +8,7 @@ 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) @@ -64,6 +65,8 @@ export interface RuleContext { 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 { diff --git a/packages/cli/src/commands/diff/serializers/stylish.ts b/packages/cli/src/commands/diff/serializers/stylish.ts index 960f8f14fc..666a3af717 100644 --- a/packages/cli/src/commands/diff/serializers/stylish.ts +++ b/packages/cli/src/commands/diff/serializers/stylish.ts @@ -52,6 +52,8 @@ function labelOf(change: Change): string { // full pointer — but unescape each segment first so JSON-pointer escapes // like `~1` never leak into the rendered label. const label = rest.length ? rest.join('/') : segments.map(unescapePointerFragment).join(' · '); + // A change on the document root has nothing left to name but the property itself. + if (!label) return change.property ?? change.pointer; return change.property ? `${label} · ${change.property}` : label; } From b29cce2ec3cbe66a56499d36fbbbe8a096d8b880 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:46:25 +0300 Subject: [PATCH 19/57] test(cli): snapshot the stylish report for every diff rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-rule fixtures now sit beside breaking-changes and follow the same shape — base.yaml, revision.yaml and a stylish snapshot — so each rule's report is reviewable as the output a reader actually sees. Every test still names the rule id it exercises, so a regenerated snapshot cannot quietly stop covering it. Reviewing the snapshots turned up a stray label: a change on the document root rendered with an empty name before the property. Co-Authored-By: Claude Opus 5 --- .../additional-properties-changed/base.yaml | 0 .../revision.yaml | 0 .../stylish-snapshot.txt | 11 ++ .../{rules => }/enum-values-removed/base.yaml | 0 .../enum-values-removed/revision.yaml | 0 .../enum-values-removed/stylish-snapshot.txt | 11 ++ .../{rules => }/nullability-changed/base.yaml | 0 .../nullability-changed/revision.yaml | 0 .../nullability-changed/stylish-snapshot.txt | 11 ++ .../base.yaml | 0 .../revision.yaml | 0 .../stylish-snapshot.txt | 15 +++ .../numeric-range-changed/base.yaml | 0 .../numeric-range-changed/revision.yaml | 0 .../stylish-snapshot.txt | 11 ++ .../{rules => }/operation-removed/base.yaml | 0 .../operation-removed/revision.yaml | 0 .../operation-removed/stylish-snapshot.txt | 11 ++ .../parameter-became-required/base.yaml | 0 .../parameter-became-required/revision.yaml | 0 .../stylish-snapshot.txt | 11 ++ .../parameter-serialization-changed/base.yaml | 0 .../revision.yaml | 0 .../stylish-snapshot.txt | 11 ++ .../property-removed-from-response/base.yaml | 0 .../revision.yaml | 0 .../stylish-snapshot.txt | 11 ++ .../request-body-became-required/base.yaml | 0 .../revision.yaml | 0 .../stylish-snapshot.txt | 11 ++ .../request-body-removed/base.yaml | 0 .../request-body-removed/revision.yaml | 0 .../request-body-removed/stylish-snapshot.txt | 11 ++ .../response-header-removed/base.yaml | 0 .../response-header-removed/revision.yaml | 0 .../stylish-snapshot.txt | 11 ++ tests/e2e/diff/rules.test.ts | 113 ++++++------------ .../schema-combinator-changed/base.yaml | 0 .../schema-combinator-changed/revision.yaml | 0 .../stylish-snapshot.txt | 11 ++ .../schema-format-changed/base.yaml | 0 .../schema-format-changed/revision.yaml | 0 .../stylish-snapshot.txt | 11 ++ .../schema-type-widened-in-request/base.yaml | 0 .../revision.yaml | 0 .../stylish-snapshot.txt | 9 ++ .../security-requirement-added/base.yaml | 0 .../security-requirement-added/revision.yaml | 0 .../stylish-snapshot.txt | 11 ++ .../security-scheme-changed/base.yaml | 0 .../security-scheme-changed/revision.yaml | 0 .../stylish-snapshot.txt | 17 +++ .../string-length-changed/base.yaml | 0 .../string-length-changed/revision.yaml | 0 .../stylish-snapshot.txt | 11 ++ .../base.yaml | 21 ++++ .../revision.yaml | 19 +++ .../stylish-snapshot.txt | 11 ++ 58 files changed, 294 insertions(+), 76 deletions(-) rename tests/e2e/diff/{rules => }/additional-properties-changed/base.yaml (100%) rename tests/e2e/diff/{rules => }/additional-properties-changed/revision.yaml (100%) create mode 100644 tests/e2e/diff/additional-properties-changed/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/enum-values-removed/base.yaml (100%) rename tests/e2e/diff/{rules => }/enum-values-removed/revision.yaml (100%) create mode 100644 tests/e2e/diff/enum-values-removed/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/nullability-changed/base.yaml (100%) rename tests/e2e/diff/{rules => }/nullability-changed/revision.yaml (100%) create mode 100644 tests/e2e/diff/nullability-changed/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/nullable-equivalence-across-versions/base.yaml (100%) rename tests/e2e/diff/{rules => }/nullable-equivalence-across-versions/revision.yaml (100%) create mode 100644 tests/e2e/diff/nullable-equivalence-across-versions/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/numeric-range-changed/base.yaml (100%) rename tests/e2e/diff/{rules => }/numeric-range-changed/revision.yaml (100%) create mode 100644 tests/e2e/diff/numeric-range-changed/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/operation-removed/base.yaml (100%) rename tests/e2e/diff/{rules => }/operation-removed/revision.yaml (100%) create mode 100644 tests/e2e/diff/operation-removed/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/parameter-became-required/base.yaml (100%) rename tests/e2e/diff/{rules => }/parameter-became-required/revision.yaml (100%) create mode 100644 tests/e2e/diff/parameter-became-required/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/parameter-serialization-changed/base.yaml (100%) rename tests/e2e/diff/{rules => }/parameter-serialization-changed/revision.yaml (100%) create mode 100644 tests/e2e/diff/parameter-serialization-changed/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/property-removed-from-response/base.yaml (100%) rename tests/e2e/diff/{rules => }/property-removed-from-response/revision.yaml (100%) create mode 100644 tests/e2e/diff/property-removed-from-response/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/request-body-became-required/base.yaml (100%) rename tests/e2e/diff/{rules => }/request-body-became-required/revision.yaml (100%) create mode 100644 tests/e2e/diff/request-body-became-required/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/request-body-removed/base.yaml (100%) rename tests/e2e/diff/{rules => }/request-body-removed/revision.yaml (100%) create mode 100644 tests/e2e/diff/request-body-removed/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/response-header-removed/base.yaml (100%) rename tests/e2e/diff/{rules => }/response-header-removed/revision.yaml (100%) create mode 100644 tests/e2e/diff/response-header-removed/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/schema-combinator-changed/base.yaml (100%) rename tests/e2e/diff/{rules => }/schema-combinator-changed/revision.yaml (100%) create mode 100644 tests/e2e/diff/schema-combinator-changed/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/schema-format-changed/base.yaml (100%) rename tests/e2e/diff/{rules => }/schema-format-changed/revision.yaml (100%) create mode 100644 tests/e2e/diff/schema-format-changed/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/schema-type-widened-in-request/base.yaml (100%) rename tests/e2e/diff/{rules => }/schema-type-widened-in-request/revision.yaml (100%) create mode 100644 tests/e2e/diff/schema-type-widened-in-request/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/security-requirement-added/base.yaml (100%) rename tests/e2e/diff/{rules => }/security-requirement-added/revision.yaml (100%) create mode 100644 tests/e2e/diff/security-requirement-added/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/security-scheme-changed/base.yaml (100%) rename tests/e2e/diff/{rules => }/security-scheme-changed/revision.yaml (100%) create mode 100644 tests/e2e/diff/security-scheme-changed/stylish-snapshot.txt rename tests/e2e/diff/{rules => }/string-length-changed/base.yaml (100%) rename tests/e2e/diff/{rules => }/string-length-changed/revision.yaml (100%) create mode 100644 tests/e2e/diff/string-length-changed/stylish-snapshot.txt create mode 100644 tests/e2e/diff/webhook-payload-property-removed/base.yaml create mode 100644 tests/e2e/diff/webhook-payload-property-removed/revision.yaml create mode 100644 tests/e2e/diff/webhook-payload-property-removed/stylish-snapshot.txt diff --git a/tests/e2e/diff/rules/additional-properties-changed/base.yaml b/tests/e2e/diff/additional-properties-changed/base.yaml similarity index 100% rename from tests/e2e/diff/rules/additional-properties-changed/base.yaml rename to tests/e2e/diff/additional-properties-changed/base.yaml diff --git a/tests/e2e/diff/rules/additional-properties-changed/revision.yaml b/tests/e2e/diff/additional-properties-changed/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/additional-properties-changed/revision.yaml rename to tests/e2e/diff/additional-properties-changed/revision.yaml diff --git a/tests/e2e/diff/additional-properties-changed/stylish-snapshot.txt b/tests/e2e/diff/additional-properties-changed/stylish-snapshot.txt new file mode 100644 index 0000000000..e4060dbf09 --- /dev/null +++ b/tests/e2e/diff/additional-properties-changed/stylish-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/rules/enum-values-removed/base.yaml b/tests/e2e/diff/enum-values-removed/base.yaml similarity index 100% rename from tests/e2e/diff/rules/enum-values-removed/base.yaml rename to tests/e2e/diff/enum-values-removed/base.yaml diff --git a/tests/e2e/diff/rules/enum-values-removed/revision.yaml b/tests/e2e/diff/enum-values-removed/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/enum-values-removed/revision.yaml rename to tests/e2e/diff/enum-values-removed/revision.yaml diff --git a/tests/e2e/diff/enum-values-removed/stylish-snapshot.txt b/tests/e2e/diff/enum-values-removed/stylish-snapshot.txt new file mode 100644 index 0000000000..ec95a3dfc3 --- /dev/null +++ b/tests/e2e/diff/enum-values-removed/stylish-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/rules/nullability-changed/base.yaml b/tests/e2e/diff/nullability-changed/base.yaml similarity index 100% rename from tests/e2e/diff/rules/nullability-changed/base.yaml rename to tests/e2e/diff/nullability-changed/base.yaml diff --git a/tests/e2e/diff/rules/nullability-changed/revision.yaml b/tests/e2e/diff/nullability-changed/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/nullability-changed/revision.yaml rename to tests/e2e/diff/nullability-changed/revision.yaml diff --git a/tests/e2e/diff/nullability-changed/stylish-snapshot.txt b/tests/e2e/diff/nullability-changed/stylish-snapshot.txt new file mode 100644 index 0000000000..06275817d4 --- /dev/null +++ b/tests/e2e/diff/nullability-changed/stylish-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/rules/nullable-equivalence-across-versions/base.yaml b/tests/e2e/diff/nullable-equivalence-across-versions/base.yaml similarity index 100% rename from tests/e2e/diff/rules/nullable-equivalence-across-versions/base.yaml rename to tests/e2e/diff/nullable-equivalence-across-versions/base.yaml diff --git a/tests/e2e/diff/rules/nullable-equivalence-across-versions/revision.yaml b/tests/e2e/diff/nullable-equivalence-across-versions/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/nullable-equivalence-across-versions/revision.yaml rename to tests/e2e/diff/nullable-equivalence-across-versions/revision.yaml diff --git a/tests/e2e/diff/nullable-equivalence-across-versions/stylish-snapshot.txt b/tests/e2e/diff/nullable-equivalence-across-versions/stylish-snapshot.txt new file mode 100644 index 0000000000..4520ba5a16 --- /dev/null +++ b/tests/e2e/diff/nullable-equivalence-across-versions/stylish-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/rules/numeric-range-changed/base.yaml b/tests/e2e/diff/numeric-range-changed/base.yaml similarity index 100% rename from tests/e2e/diff/rules/numeric-range-changed/base.yaml rename to tests/e2e/diff/numeric-range-changed/base.yaml diff --git a/tests/e2e/diff/rules/numeric-range-changed/revision.yaml b/tests/e2e/diff/numeric-range-changed/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/numeric-range-changed/revision.yaml rename to tests/e2e/diff/numeric-range-changed/revision.yaml diff --git a/tests/e2e/diff/numeric-range-changed/stylish-snapshot.txt b/tests/e2e/diff/numeric-range-changed/stylish-snapshot.txt new file mode 100644 index 0000000000..865e3f27a1 --- /dev/null +++ b/tests/e2e/diff/numeric-range-changed/stylish-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/rules/operation-removed/base.yaml b/tests/e2e/diff/operation-removed/base.yaml similarity index 100% rename from tests/e2e/diff/rules/operation-removed/base.yaml rename to tests/e2e/diff/operation-removed/base.yaml diff --git a/tests/e2e/diff/rules/operation-removed/revision.yaml b/tests/e2e/diff/operation-removed/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/operation-removed/revision.yaml rename to tests/e2e/diff/operation-removed/revision.yaml diff --git a/tests/e2e/diff/operation-removed/stylish-snapshot.txt b/tests/e2e/diff/operation-removed/stylish-snapshot.txt new file mode 100644 index 0000000000..4da15359f1 --- /dev/null +++ b/tests/e2e/diff/operation-removed/stylish-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/rules/parameter-became-required/base.yaml b/tests/e2e/diff/parameter-became-required/base.yaml similarity index 100% rename from tests/e2e/diff/rules/parameter-became-required/base.yaml rename to tests/e2e/diff/parameter-became-required/base.yaml diff --git a/tests/e2e/diff/rules/parameter-became-required/revision.yaml b/tests/e2e/diff/parameter-became-required/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/parameter-became-required/revision.yaml rename to tests/e2e/diff/parameter-became-required/revision.yaml diff --git a/tests/e2e/diff/parameter-became-required/stylish-snapshot.txt b/tests/e2e/diff/parameter-became-required/stylish-snapshot.txt new file mode 100644 index 0000000000..7d6169c90b --- /dev/null +++ b/tests/e2e/diff/parameter-became-required/stylish-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/rules/parameter-serialization-changed/base.yaml b/tests/e2e/diff/parameter-serialization-changed/base.yaml similarity index 100% rename from tests/e2e/diff/rules/parameter-serialization-changed/base.yaml rename to tests/e2e/diff/parameter-serialization-changed/base.yaml diff --git a/tests/e2e/diff/rules/parameter-serialization-changed/revision.yaml b/tests/e2e/diff/parameter-serialization-changed/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/parameter-serialization-changed/revision.yaml rename to tests/e2e/diff/parameter-serialization-changed/revision.yaml diff --git a/tests/e2e/diff/parameter-serialization-changed/stylish-snapshot.txt b/tests/e2e/diff/parameter-serialization-changed/stylish-snapshot.txt new file mode 100644 index 0000000000..8bdf94a51e --- /dev/null +++ b/tests/e2e/diff/parameter-serialization-changed/stylish-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/rules/property-removed-from-response/base.yaml b/tests/e2e/diff/property-removed-from-response/base.yaml similarity index 100% rename from tests/e2e/diff/rules/property-removed-from-response/base.yaml rename to tests/e2e/diff/property-removed-from-response/base.yaml diff --git a/tests/e2e/diff/rules/property-removed-from-response/revision.yaml b/tests/e2e/diff/property-removed-from-response/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/property-removed-from-response/revision.yaml rename to tests/e2e/diff/property-removed-from-response/revision.yaml diff --git a/tests/e2e/diff/property-removed-from-response/stylish-snapshot.txt b/tests/e2e/diff/property-removed-from-response/stylish-snapshot.txt new file mode 100644 index 0000000000..65960b1c20 --- /dev/null +++ b/tests/e2e/diff/property-removed-from-response/stylish-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/rules/request-body-became-required/base.yaml b/tests/e2e/diff/request-body-became-required/base.yaml similarity index 100% rename from tests/e2e/diff/rules/request-body-became-required/base.yaml rename to tests/e2e/diff/request-body-became-required/base.yaml diff --git a/tests/e2e/diff/rules/request-body-became-required/revision.yaml b/tests/e2e/diff/request-body-became-required/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/request-body-became-required/revision.yaml rename to tests/e2e/diff/request-body-became-required/revision.yaml diff --git a/tests/e2e/diff/request-body-became-required/stylish-snapshot.txt b/tests/e2e/diff/request-body-became-required/stylish-snapshot.txt new file mode 100644 index 0000000000..dca9842584 --- /dev/null +++ b/tests/e2e/diff/request-body-became-required/stylish-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/rules/request-body-removed/base.yaml b/tests/e2e/diff/request-body-removed/base.yaml similarity index 100% rename from tests/e2e/diff/rules/request-body-removed/base.yaml rename to tests/e2e/diff/request-body-removed/base.yaml diff --git a/tests/e2e/diff/rules/request-body-removed/revision.yaml b/tests/e2e/diff/request-body-removed/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/request-body-removed/revision.yaml rename to tests/e2e/diff/request-body-removed/revision.yaml diff --git a/tests/e2e/diff/request-body-removed/stylish-snapshot.txt b/tests/e2e/diff/request-body-removed/stylish-snapshot.txt new file mode 100644 index 0000000000..390a6396b5 --- /dev/null +++ b/tests/e2e/diff/request-body-removed/stylish-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/rules/response-header-removed/base.yaml b/tests/e2e/diff/response-header-removed/base.yaml similarity index 100% rename from tests/e2e/diff/rules/response-header-removed/base.yaml rename to tests/e2e/diff/response-header-removed/base.yaml diff --git a/tests/e2e/diff/rules/response-header-removed/revision.yaml b/tests/e2e/diff/response-header-removed/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/response-header-removed/revision.yaml rename to tests/e2e/diff/response-header-removed/revision.yaml diff --git a/tests/e2e/diff/response-header-removed/stylish-snapshot.txt b/tests/e2e/diff/response-header-removed/stylish-snapshot.txt new file mode 100644 index 0000000000..0c043103b6 --- /dev/null +++ b/tests/e2e/diff/response-header-removed/stylish-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/rules.test.ts b/tests/e2e/diff/rules.test.ts index c1291e32e4..54e62e9639 100644 --- a/tests/e2e/diff/rules.test.ts +++ b/tests/e2e/diff/rules.test.ts @@ -1,47 +1,13 @@ -import { spawnSync } from 'node:child_process'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { cleanupOutput, getCommandOutput, getParams } from '../helpers.js'; + const __dirname = dirname(fileURLToPath(import.meta.url)); const indexEntryPoint = join(process.cwd(), 'packages/cli/lib/index.js'); -const rulesPath = join(__dirname, 'rules'); - -interface DiffChange { - pointer: string; - property?: string; - kind: string; - compat: 'breaking' | 'non-breaking'; - verdicts?: { ruleId: string; message: string; compat: string }[]; -} -interface DiffJson { - summary: { breaking: number; nonBreaking: number }; - changes: DiffChange[]; -} - -// The command prints the report on stdout and everything else on stderr, -// so stdout parses as JSON on its own. -function runDiff(fixture: string): DiffJson { - const result = spawnSync( - 'node', - [indexEntryPoint, 'diff', 'base.yaml', 'revision.yaml', '--format=json', '--fail-on=none'], - { encoding: 'utf-8', cwd: join(rulesPath, fixture), env: { ...process.env, NO_COLOR: 'TRUE' } } - ); - if (result.status !== 0) { - throw new Error(`diff failed for ${fixture}:\n${result.stderr}`); - } - return JSON.parse(result.stdout); -} - -function firedRuleIds(diff: DiffJson): string[] { - return [ - ...new Set(diff.changes.flatMap((change) => change.verdicts?.map((v) => v.ruleId) ?? [])), - ]; -} - -/** A change the command must report as breaking, attributed to `ruleId`. */ +/** One fixture per rule, each a base/revision pair differing only in what it exercises. */ const BREAKING: { fixture: string; ruleId: string; describes: string }[] = [ - // ── already covered { fixture: 'operation-removed', ruleId: 'operation-removed', @@ -52,11 +18,22 @@ const BREAKING: { fixture: string; ruleId: string; describes: string }[] = [ ruleId: 'parameter-became-required', describes: 'an optional query parameter becomes required', }, + { + fixture: 'parameter-serialization-changed', + ruleId: 'parameter-serialization-changed', + describes: 'an array parameter changes its serialization style', + }, { fixture: 'property-removed-from-response', ruleId: 'property-removed-from-response', describes: 'a response property disappears', }, + { + fixture: '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: 'enum-values-removed', ruleId: 'enum-values-removed', @@ -68,8 +45,6 @@ const BREAKING: { fixture: string; ruleId: string; describes: string }[] = [ ruleId: 'schema-type-changed', describes: 'a request property stops accepting null', }, - - // ── not implemented yet { fixture: 'request-body-became-required', ruleId: 'request-body-became-required', @@ -90,6 +65,11 @@ const BREAKING: { fixture: string; ruleId: string; describes: string }[] = [ ruleId: 'numeric-range-changed', describes: 'minimum rises on a request property', }, + { + fixture: 'schema-format-changed', + ruleId: 'schema-format-changed', + describes: 'a request property gains a format constraint', + }, { fixture: 'additional-properties-changed', ruleId: 'additional-properties-changed', @@ -101,9 +81,9 @@ const BREAKING: { fixture: string; ruleId: string; describes: string }[] = [ describes: 'a request oneOf drops an accepted subschema', }, { - fixture: 'schema-format-changed', - ruleId: 'schema-format-changed', - describes: 'a request property gains a format constraint', + fixture: 'response-header-removed', + ruleId: 'response-header-removed', + describes: 'a response header disappears', }, { fixture: 'security-requirement-added', @@ -115,19 +95,9 @@ const BREAKING: { fixture: string; ruleId: string; describes: string }[] = [ ruleId: 'security-scheme-changed', describes: 'a security scheme switches from apiKey to bearer', }, - { - fixture: 'response-header-removed', - ruleId: 'response-header-removed', - describes: 'a response header disappears', - }, - { - fixture: 'parameter-serialization-changed', - ruleId: 'parameter-serialization-changed', - describes: 'an array parameter changes its serialization style', - }, ]; -/** A change the command must NOT report as breaking. */ +/** Changes that must not be reported as breaking. */ const SAFE: { fixture: string; describes: string }[] = [ { fixture: 'schema-type-widened-in-request', @@ -139,35 +109,26 @@ const SAFE: { fixture: string; describes: string }[] = [ }, ]; +function runDiff(fixture: string): string { + const args = getParams(indexEntryPoint, ['diff', 'base.yaml', 'revision.yaml']); + return cleanupOutput(getCommandOutput(args, { testPath: join(__dirname, fixture) })); +} + describe('diff rules', () => { for (const { fixture, ruleId, describes } of BREAKING) { - test(`${ruleId}: reports breaking when ${describes}`, () => { - const diff = runDiff(fixture); - const change = diff.changes.find( - (candidate) => - candidate.compat === 'breaking' && - candidate.verdicts?.some((verdict) => verdict.ruleId === ruleId) - ); - - expect( - change, - `expected a breaking change from '${ruleId}', got ${ - firedRuleIds(diff).join(', ') || 'no rule verdicts' - } (breaking: ${diff.summary.breaking}, non-breaking: ${diff.summary.nonBreaking})` - ).toBeDefined(); + test(`${ruleId}: ${describes}`, async () => { + const output = runDiff(fixture); + // Named explicitly so a regenerated snapshot cannot quietly stop exercising the rule. + expect(output).toContain(ruleId); + await expect(output).toMatchFileSnapshot(join(__dirname, fixture, 'stylish-snapshot.txt')); }); } for (const { fixture, describes } of SAFE) { - test(`reports no breaking change when ${describes}`, () => { - const diff = runDiff(fixture); - - expect( - diff.summary.breaking, - `expected no breaking changes, got ${diff.summary.breaking} from ${ - firedRuleIds(diff).join(', ') || 'no rule verdicts' - }` - ).toBe(0); + test(`no breaking change when ${describes}`, async () => { + const output = runDiff(fixture); + expect(output).toContain('0 breaking'); + await expect(output).toMatchFileSnapshot(join(__dirname, fixture, 'stylish-snapshot.txt')); }); } }); diff --git a/tests/e2e/diff/rules/schema-combinator-changed/base.yaml b/tests/e2e/diff/schema-combinator-changed/base.yaml similarity index 100% rename from tests/e2e/diff/rules/schema-combinator-changed/base.yaml rename to tests/e2e/diff/schema-combinator-changed/base.yaml diff --git a/tests/e2e/diff/rules/schema-combinator-changed/revision.yaml b/tests/e2e/diff/schema-combinator-changed/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/schema-combinator-changed/revision.yaml rename to tests/e2e/diff/schema-combinator-changed/revision.yaml diff --git a/tests/e2e/diff/schema-combinator-changed/stylish-snapshot.txt b/tests/e2e/diff/schema-combinator-changed/stylish-snapshot.txt new file mode 100644 index 0000000000..c31e906f42 --- /dev/null +++ b/tests/e2e/diff/schema-combinator-changed/stylish-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/rules/schema-format-changed/base.yaml b/tests/e2e/diff/schema-format-changed/base.yaml similarity index 100% rename from tests/e2e/diff/rules/schema-format-changed/base.yaml rename to tests/e2e/diff/schema-format-changed/base.yaml diff --git a/tests/e2e/diff/rules/schema-format-changed/revision.yaml b/tests/e2e/diff/schema-format-changed/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/schema-format-changed/revision.yaml rename to tests/e2e/diff/schema-format-changed/revision.yaml diff --git a/tests/e2e/diff/schema-format-changed/stylish-snapshot.txt b/tests/e2e/diff/schema-format-changed/stylish-snapshot.txt new file mode 100644 index 0000000000..bebc20b889 --- /dev/null +++ b/tests/e2e/diff/schema-format-changed/stylish-snapshot.txt @@ -0,0 +1,11 @@ +POST /pets + ✖ breaking changed requestBody/content/application~1json/schema/properties/id · format + Format 'uuid' was added. (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/rules/schema-type-widened-in-request/base.yaml b/tests/e2e/diff/schema-type-widened-in-request/base.yaml similarity index 100% rename from tests/e2e/diff/rules/schema-type-widened-in-request/base.yaml rename to tests/e2e/diff/schema-type-widened-in-request/base.yaml diff --git a/tests/e2e/diff/rules/schema-type-widened-in-request/revision.yaml b/tests/e2e/diff/schema-type-widened-in-request/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/schema-type-widened-in-request/revision.yaml rename to tests/e2e/diff/schema-type-widened-in-request/revision.yaml diff --git a/tests/e2e/diff/schema-type-widened-in-request/stylish-snapshot.txt b/tests/e2e/diff/schema-type-widened-in-request/stylish-snapshot.txt new file mode 100644 index 0000000000..9279f6d5d8 --- /dev/null +++ b/tests/e2e/diff/schema-type-widened-in-request/stylish-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/rules/security-requirement-added/base.yaml b/tests/e2e/diff/security-requirement-added/base.yaml similarity index 100% rename from tests/e2e/diff/rules/security-requirement-added/base.yaml rename to tests/e2e/diff/security-requirement-added/base.yaml diff --git a/tests/e2e/diff/rules/security-requirement-added/revision.yaml b/tests/e2e/diff/security-requirement-added/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/security-requirement-added/revision.yaml rename to tests/e2e/diff/security-requirement-added/revision.yaml diff --git a/tests/e2e/diff/security-requirement-added/stylish-snapshot.txt b/tests/e2e/diff/security-requirement-added/stylish-snapshot.txt new file mode 100644 index 0000000000..face212e37 --- /dev/null +++ b/tests/e2e/diff/security-requirement-added/stylish-snapshot.txt @@ -0,0 +1,11 @@ +GET /pets + ✖ breaking added security + The operation 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/rules/security-scheme-changed/base.yaml b/tests/e2e/diff/security-scheme-changed/base.yaml similarity index 100% rename from tests/e2e/diff/rules/security-scheme-changed/base.yaml rename to tests/e2e/diff/security-scheme-changed/base.yaml diff --git a/tests/e2e/diff/rules/security-scheme-changed/revision.yaml b/tests/e2e/diff/security-scheme-changed/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/security-scheme-changed/revision.yaml rename to tests/e2e/diff/security-scheme-changed/revision.yaml diff --git a/tests/e2e/diff/security-scheme-changed/stylish-snapshot.txt b/tests/e2e/diff/security-scheme-changed/stylish-snapshot.txt new file mode 100644 index 0000000000..cb76fa7b2c --- /dev/null +++ b/tests/e2e/diff/security-scheme-changed/stylish-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/rules/string-length-changed/base.yaml b/tests/e2e/diff/string-length-changed/base.yaml similarity index 100% rename from tests/e2e/diff/rules/string-length-changed/base.yaml rename to tests/e2e/diff/string-length-changed/base.yaml diff --git a/tests/e2e/diff/rules/string-length-changed/revision.yaml b/tests/e2e/diff/string-length-changed/revision.yaml similarity index 100% rename from tests/e2e/diff/rules/string-length-changed/revision.yaml rename to tests/e2e/diff/string-length-changed/revision.yaml diff --git a/tests/e2e/diff/string-length-changed/stylish-snapshot.txt b/tests/e2e/diff/string-length-changed/stylish-snapshot.txt new file mode 100644 index 0000000000..4743ffd825 --- /dev/null +++ b/tests/e2e/diff/string-length-changed/stylish-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/webhook-payload-property-removed/base.yaml b/tests/e2e/diff/webhook-payload-property-removed/base.yaml new file mode 100644 index 0000000000..065c27d592 --- /dev/null +++ b/tests/e2e/diff/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/webhook-payload-property-removed/revision.yaml b/tests/e2e/diff/webhook-payload-property-removed/revision.yaml new file mode 100644 index 0000000000..93c17528e0 --- /dev/null +++ b/tests/e2e/diff/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/webhook-payload-property-removed/stylish-snapshot.txt b/tests/e2e/diff/webhook-payload-property-removed/stylish-snapshot.txt new file mode 100644 index 0000000000..747856e588 --- /dev/null +++ b/tests/e2e/diff/webhook-payload-property-removed/stylish-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. From 9b62c6056519e1d8e75ca1e897f3f5ef37f5ded4 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:45:23 +0300 Subject: [PATCH 20/57] refactor(cli): clean up the diff command, add AsyncAPI 3 rules Follows up the code review on the diff command. Rules and engine: - Revert the `html` addition to core's `OutputFormat`. `formatProblems` has no `case 'html'` and no `default`, so it typechecked and silently did nothing. The diff command spells out its own formats instead. - Report `security-requirement-added` when an explicitly empty `security: []` list gets its first entry, and add `security-scopes-added`. - Register `parameter-removed` and `parameter-added-required` for `ParameterList` as well: the last parameter of an operation leaves with the whole `parameters` list, and the first one arrives with it. - Invert the direction once per nesting level under `callbacks`, so a callback inside a callback points the right way. - Add six AsyncAPI 3 rules (`channel-removed`, `channel-address-changed`, `message-removed`, `message-content-type-changed`, `operation-action-changed`, `server-removed`) and reuse the schema rules for payloads. AsyncAPI declares the direction with `action`, so it gets its own resolver; a channel takes its direction from the operations that reference it. - Drop `isScalarArray([])`, which reported an empty list twice. - Use core's `dequal` and `isAbsoluteUrl` instead of local equivalents. - Fix double escaping in the markdown report, which broke the rule-id code span. - Reject `--output` for stdout-only formats before bundling both documents. Tests: - One `__tests__` folder, as every other command in the package has. - Snapshot the collected map, the emitted changes, the verdicts and the reports, so the whole output stays visible. - Drop tests that asserted a dependency's behaviour or a one-line helper. - Move per-rule coverage to e2e: one fixture per rule, prefixed `oas3-` or `async3-`, asserted on the machine-readable report. Docs: rewrite the command page in simpler English and stop leaking the internal word "polarity" into user-facing text. --- .changeset/diff-command.md | 5 - .changeset/purple-onions-shave.md | 5 + docs/@v2/commands/diff.md | 178 +++++++++---- .../__tests__/__snapshots__/html-report.html | 136 ++++++++++ .../{engine => }/__tests__/classify.test.ts | 73 ++---- .../{engine => }/__tests__/collect.test.ts | 66 ++++- .../commands/diff/__tests__/compare.test.ts | 163 ++++++++++++ .../__tests__/diff-documents.test.ts | 117 ++++----- .../diff/__tests__/documented-rules.test.ts | 49 ++++ .../commands/diff/__tests__/fail-on.test.ts | 20 -- .../{engine => }/__tests__/polarity.test.ts | 113 +++++++-- .../{engine => }/__tests__/predicates.test.ts | 43 +--- .../commands/diff/__tests__/problems.test.ts | 59 +++-- .../diff/__tests__/serializers-rich.test.ts | 45 ---- .../diff/__tests__/serializers.test.ts | 163 +++++++----- .../diff/{engine => }/__tests__/tree.ts | 13 +- .../diff/engine/__tests__/align-paths.test.ts | 72 ------ .../diff/engine/__tests__/compare.test.ts | 132 ---------- .../diff/engine/__tests__/locate.test.ts | 57 ----- .../engine/__tests__/node-identity.test.ts | 27 -- .../rules-parameter-response.test.ts | 88 ------- .../engine/__tests__/rules-schema.test.ts | 145 ----------- .../diff/engine/__tests__/types.test.ts | 11 - .../src/commands/diff/engine/align-paths.ts | 39 +-- .../commands/diff/engine/classify/async3.ts | 21 ++ .../commands/diff/engine/classify/index.ts | 32 ++- .../src/commands/diff/engine/classify/oas3.ts | 48 +--- .../commands/diff/engine/classify/oas3_1.ts | 5 - .../commands/diff/engine/classify/polarity.ts | 82 +++++- .../diff/engine/classify/rules/channel.ts | 30 +++ .../diff/engine/classify/rules/message.ts | 27 ++ .../{operation-rules.ts => operation.ts} | 13 + .../{parameter-rules.ts => parameter.ts} | 15 +- .../classify/rules/{ref-rules.ts => ref.ts} | 4 +- ...{request-body-rules.ts => request-body.ts} | 3 +- .../rules/{response-rules.ts => response.ts} | 0 .../rules/{schema-rules.ts => schema.ts} | 145 +++++------ .../rules/{security-rules.ts => security.ts} | 37 ++- .../diff/engine/classify/rules/server.ts | 14 + .../cli/src/commands/diff/engine/collect.ts | 22 +- .../cli/src/commands/diff/engine/compare.ts | 94 +++---- .../cli/src/commands/diff/engine/index.ts | 2 +- .../src/commands/diff/engine/predicates.ts | 11 +- packages/cli/src/commands/diff/index.ts | 18 +- .../src/commands/diff/serializers/markdown.ts | 16 +- .../src/commands/diff/serializers/problems.ts | 7 +- .../src/commands/diff/serializers/stylish.ts | 38 ++- packages/core/src/format/format.ts | 3 +- .../async3-channel-address-changed/base.yaml | 26 ++ .../revision.yaml | 26 ++ .../snapshot.txt | 11 + .../e2e/diff/async3-channel-removed/base.yaml | 36 +++ .../diff/async3-channel-removed/revision.yaml | 26 ++ .../diff/async3-channel-removed/snapshot.txt | 16 ++ .../base.yaml | 26 ++ .../revision.yaml | 26 ++ .../snapshot.txt | 11 + .../e2e/diff/async3-message-removed/base.yaml | 29 +++ .../diff/async3-message-removed/revision.yaml | 26 ++ .../diff/async3-message-removed/snapshot.txt | 11 + .../async3-operation-action-changed/base.yaml | 26 ++ .../revision.yaml | 26 ++ .../snapshot.txt | 11 + .../async3-payload-property-removed/base.yaml | 26 ++ .../revision.yaml | 24 ++ .../snapshot.txt | 11 + .../async3-payload-required-added/base.yaml | 26 ++ .../revision.yaml | 27 ++ .../snapshot.txt | 11 + .../base.yaml | 26 ++ .../revision.yaml | 27 ++ .../snapshot.txt | 9 + .../e2e/diff/async3-server-removed/base.yaml | 33 +++ .../diff/async3-server-removed/revision.yaml | 30 +++ .../diff/async3-server-removed/snapshot.txt | 11 + .../github-actions-snapshot.txt | 8 - .../diff/breaking-changes/json-snapshot.txt | 114 --------- tests/e2e/diff/cli.test.ts | 41 +++ tests/e2e/diff/cross-family/base.yaml | 5 + tests/e2e/diff/cross-family/revision.yaml | 5 + tests/e2e/diff/diff.test.ts | 79 ------ tests/e2e/diff/helpers.ts | 35 +++ .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../base.yaml | 15 ++ .../revision.yaml | 15 ++ .../snapshot.txt | 9 + .../e2e/diff/oas3-enum-values-added/base.yaml | 15 ++ .../diff/oas3-enum-values-added/revision.yaml | 15 ++ .../diff/oas3-enum-values-added/snapshot.txt | 11 + .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../diff/oas3-media-type-removed/base.yaml | 15 ++ .../oas3-media-type-removed/revision.yaml | 13 + .../diff/oas3-media-type-removed/snapshot.txt | 11 + .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../oas3-parameter-added-optional/base.yaml | 9 + .../revision.yaml | 13 + .../snapshot.txt | 9 + .../oas3-parameter-added-required/base.yaml | 9 + .../revision.yaml | 14 + .../snapshot.txt | 11 + .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../oas3-parameter-removed-last/base.yaml | 13 + .../oas3-parameter-removed-last/revision.yaml | 9 + .../oas3-parameter-removed-last/snapshot.txt | 11 + .../e2e/diff/oas3-parameter-removed/base.yaml | 16 ++ .../diff/oas3-parameter-removed/revision.yaml | 13 + .../diff/oas3-parameter-removed/snapshot.txt | 11 + .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../oas3-path-parameter-renamed/base.yaml | 14 + .../oas3-path-parameter-renamed/revision.yaml | 14 + .../oas3-path-parameter-renamed/snapshot.txt | 13 + tests/e2e/diff/oas3-path-removed/base.yaml | 18 ++ .../e2e/diff/oas3-path-removed/revision.yaml | 9 + tests/e2e/diff/oas3-path-removed/snapshot.txt | 11 + .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../diff/oas3-ref-target-changed/base.yaml | 24 ++ .../oas3-ref-target-changed/revision.yaml | 24 ++ .../diff/oas3-ref-target-changed/snapshot.txt | 11 + .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../oas3-required-properties-added/base.yaml | 18 ++ .../revision.yaml | 18 ++ .../snapshot.txt | 11 + .../base.yaml | 18 ++ .../revision.yaml | 18 ++ .../snapshot.txt | 11 + .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../e2e/diff/oas3-response-removed/base.yaml | 10 + .../diff/oas3-response-removed/revision.yaml | 9 + .../diff/oas3-response-removed/snapshot.txt | 11 + .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 2 +- .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../base.yaml | 17 ++ .../revision.yaml | 18 ++ .../snapshot.txt | 11 + .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 2 +- .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../oas3-security-scheme-removed/base.yaml | 18 ++ .../revision.yaml | 15 ++ .../oas3-security-scheme-removed/snapshot.txt | 11 + .../diff/oas3-security-scopes-added/base.yaml | 23 ++ .../oas3-security-scopes-added/revision.yaml | 24 ++ .../oas3-security-scopes-added/snapshot.txt | 11 + .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 .../base.yaml | 0 .../revision.yaml | 0 .../snapshot.txt} | 0 tests/e2e/diff/rules.test.ts | 240 ++++++++++++++---- 191 files changed, 2843 insertions(+), 1455 deletions(-) delete mode 100644 .changeset/diff-command.md create mode 100644 .changeset/purple-onions-shave.md create mode 100644 packages/cli/src/commands/diff/__tests__/__snapshots__/html-report.html rename packages/cli/src/commands/diff/{engine => }/__tests__/classify.test.ts (63%) rename packages/cli/src/commands/diff/{engine => }/__tests__/collect.test.ts (59%) create mode 100644 packages/cli/src/commands/diff/__tests__/compare.test.ts rename packages/cli/src/commands/diff/{engine => }/__tests__/diff-documents.test.ts (50%) create mode 100644 packages/cli/src/commands/diff/__tests__/documented-rules.test.ts delete mode 100644 packages/cli/src/commands/diff/__tests__/fail-on.test.ts rename packages/cli/src/commands/diff/{engine => }/__tests__/polarity.test.ts (57%) rename packages/cli/src/commands/diff/{engine => }/__tests__/predicates.test.ts (70%) delete mode 100644 packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts rename packages/cli/src/commands/diff/{engine => }/__tests__/tree.ts (64%) delete mode 100644 packages/cli/src/commands/diff/engine/__tests__/align-paths.test.ts delete mode 100644 packages/cli/src/commands/diff/engine/__tests__/compare.test.ts delete mode 100644 packages/cli/src/commands/diff/engine/__tests__/locate.test.ts delete mode 100644 packages/cli/src/commands/diff/engine/__tests__/node-identity.test.ts delete mode 100644 packages/cli/src/commands/diff/engine/__tests__/rules-parameter-response.test.ts delete mode 100644 packages/cli/src/commands/diff/engine/__tests__/rules-schema.test.ts delete mode 100644 packages/cli/src/commands/diff/engine/__tests__/types.test.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/async3.ts delete mode 100644 packages/cli/src/commands/diff/engine/classify/oas3_1.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/rules/channel.ts create mode 100644 packages/cli/src/commands/diff/engine/classify/rules/message.ts rename packages/cli/src/commands/diff/engine/classify/rules/{operation-rules.ts => operation.ts} (53%) rename packages/cli/src/commands/diff/engine/classify/rules/{parameter-rules.ts => parameter.ts} (75%) rename packages/cli/src/commands/diff/engine/classify/rules/{ref-rules.ts => ref.ts} (70%) rename packages/cli/src/commands/diff/engine/classify/rules/{request-body-rules.ts => request-body.ts} (87%) rename packages/cli/src/commands/diff/engine/classify/rules/{response-rules.ts => response.ts} (100%) rename packages/cli/src/commands/diff/engine/classify/rules/{schema-rules.ts => schema.ts} (67%) rename packages/cli/src/commands/diff/engine/classify/rules/{security-rules.ts => security.ts} (50%) create mode 100644 packages/cli/src/commands/diff/engine/classify/rules/server.ts create mode 100644 tests/e2e/diff/async3-channel-address-changed/base.yaml create mode 100644 tests/e2e/diff/async3-channel-address-changed/revision.yaml create mode 100644 tests/e2e/diff/async3-channel-address-changed/snapshot.txt create mode 100644 tests/e2e/diff/async3-channel-removed/base.yaml create mode 100644 tests/e2e/diff/async3-channel-removed/revision.yaml create mode 100644 tests/e2e/diff/async3-channel-removed/snapshot.txt create mode 100644 tests/e2e/diff/async3-message-content-type-changed/base.yaml create mode 100644 tests/e2e/diff/async3-message-content-type-changed/revision.yaml create mode 100644 tests/e2e/diff/async3-message-content-type-changed/snapshot.txt create mode 100644 tests/e2e/diff/async3-message-removed/base.yaml create mode 100644 tests/e2e/diff/async3-message-removed/revision.yaml create mode 100644 tests/e2e/diff/async3-message-removed/snapshot.txt create mode 100644 tests/e2e/diff/async3-operation-action-changed/base.yaml create mode 100644 tests/e2e/diff/async3-operation-action-changed/revision.yaml create mode 100644 tests/e2e/diff/async3-operation-action-changed/snapshot.txt create mode 100644 tests/e2e/diff/async3-payload-property-removed/base.yaml create mode 100644 tests/e2e/diff/async3-payload-property-removed/revision.yaml create mode 100644 tests/e2e/diff/async3-payload-property-removed/snapshot.txt create mode 100644 tests/e2e/diff/async3-payload-required-added/base.yaml create mode 100644 tests/e2e/diff/async3-payload-required-added/revision.yaml create mode 100644 tests/e2e/diff/async3-payload-required-added/snapshot.txt create mode 100644 tests/e2e/diff/async3-sent-payload-required-added/base.yaml create mode 100644 tests/e2e/diff/async3-sent-payload-required-added/revision.yaml create mode 100644 tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt create mode 100644 tests/e2e/diff/async3-server-removed/base.yaml create mode 100644 tests/e2e/diff/async3-server-removed/revision.yaml create mode 100644 tests/e2e/diff/async3-server-removed/snapshot.txt delete mode 100644 tests/e2e/diff/breaking-changes/github-actions-snapshot.txt delete mode 100644 tests/e2e/diff/breaking-changes/json-snapshot.txt create mode 100644 tests/e2e/diff/cli.test.ts create mode 100644 tests/e2e/diff/cross-family/base.yaml create mode 100644 tests/e2e/diff/cross-family/revision.yaml delete mode 100644 tests/e2e/diff/diff.test.ts create mode 100644 tests/e2e/diff/helpers.ts rename tests/e2e/diff/{additional-properties-changed => oas3-additional-properties-changed}/base.yaml (100%) rename tests/e2e/diff/{additional-properties-changed => oas3-additional-properties-changed}/revision.yaml (100%) rename tests/e2e/diff/{additional-properties-changed/stylish-snapshot.txt => oas3-additional-properties-changed/snapshot.txt} (100%) rename tests/e2e/diff/{breaking-changes => oas3-breaking-changes}/base.yaml (100%) rename tests/e2e/diff/{breaking-changes => oas3-breaking-changes}/revision.yaml (100%) rename tests/e2e/diff/{breaking-changes/stylish-snapshot.txt => oas3-breaking-changes/snapshot.txt} (100%) create mode 100644 tests/e2e/diff/oas3-enum-values-added-to-request/base.yaml create mode 100644 tests/e2e/diff/oas3-enum-values-added-to-request/revision.yaml create mode 100644 tests/e2e/diff/oas3-enum-values-added-to-request/snapshot.txt create mode 100644 tests/e2e/diff/oas3-enum-values-added/base.yaml create mode 100644 tests/e2e/diff/oas3-enum-values-added/revision.yaml create mode 100644 tests/e2e/diff/oas3-enum-values-added/snapshot.txt rename tests/e2e/diff/{enum-values-removed => oas3-enum-values-removed}/base.yaml (100%) rename tests/e2e/diff/{enum-values-removed => oas3-enum-values-removed}/revision.yaml (100%) rename tests/e2e/diff/{enum-values-removed/stylish-snapshot.txt => oas3-enum-values-removed/snapshot.txt} (100%) create mode 100644 tests/e2e/diff/oas3-media-type-removed/base.yaml create mode 100644 tests/e2e/diff/oas3-media-type-removed/revision.yaml create mode 100644 tests/e2e/diff/oas3-media-type-removed/snapshot.txt rename tests/e2e/diff/{nullability-changed => oas3-nullability-changed}/base.yaml (100%) rename tests/e2e/diff/{nullability-changed => oas3-nullability-changed}/revision.yaml (100%) rename tests/e2e/diff/{nullability-changed/stylish-snapshot.txt => oas3-nullability-changed/snapshot.txt} (100%) rename tests/e2e/diff/{nullable-equivalence-across-versions => oas3-nullable-equivalence-across-versions}/base.yaml (100%) rename tests/e2e/diff/{nullable-equivalence-across-versions => oas3-nullable-equivalence-across-versions}/revision.yaml (100%) rename tests/e2e/diff/{nullable-equivalence-across-versions/stylish-snapshot.txt => oas3-nullable-equivalence-across-versions/snapshot.txt} (100%) rename tests/e2e/diff/{numeric-range-changed => oas3-numeric-range-changed}/base.yaml (100%) rename tests/e2e/diff/{numeric-range-changed => oas3-numeric-range-changed}/revision.yaml (100%) rename tests/e2e/diff/{numeric-range-changed/stylish-snapshot.txt => oas3-numeric-range-changed/snapshot.txt} (100%) rename tests/e2e/diff/{operation-removed => oas3-operation-removed}/base.yaml (100%) rename tests/e2e/diff/{operation-removed => oas3-operation-removed}/revision.yaml (100%) rename tests/e2e/diff/{operation-removed/stylish-snapshot.txt => oas3-operation-removed/snapshot.txt} (100%) create mode 100644 tests/e2e/diff/oas3-parameter-added-optional/base.yaml create mode 100644 tests/e2e/diff/oas3-parameter-added-optional/revision.yaml create mode 100644 tests/e2e/diff/oas3-parameter-added-optional/snapshot.txt create mode 100644 tests/e2e/diff/oas3-parameter-added-required/base.yaml create mode 100644 tests/e2e/diff/oas3-parameter-added-required/revision.yaml create mode 100644 tests/e2e/diff/oas3-parameter-added-required/snapshot.txt rename tests/e2e/diff/{parameter-became-required => oas3-parameter-became-required}/base.yaml (100%) rename tests/e2e/diff/{parameter-became-required => oas3-parameter-became-required}/revision.yaml (100%) rename tests/e2e/diff/{parameter-became-required/stylish-snapshot.txt => oas3-parameter-became-required/snapshot.txt} (100%) create mode 100644 tests/e2e/diff/oas3-parameter-removed-last/base.yaml create mode 100644 tests/e2e/diff/oas3-parameter-removed-last/revision.yaml create mode 100644 tests/e2e/diff/oas3-parameter-removed-last/snapshot.txt create mode 100644 tests/e2e/diff/oas3-parameter-removed/base.yaml create mode 100644 tests/e2e/diff/oas3-parameter-removed/revision.yaml create mode 100644 tests/e2e/diff/oas3-parameter-removed/snapshot.txt rename tests/e2e/diff/{parameter-serialization-changed => oas3-parameter-serialization-changed}/base.yaml (100%) rename tests/e2e/diff/{parameter-serialization-changed => oas3-parameter-serialization-changed}/revision.yaml (100%) rename tests/e2e/diff/{parameter-serialization-changed/stylish-snapshot.txt => oas3-parameter-serialization-changed/snapshot.txt} (100%) create mode 100644 tests/e2e/diff/oas3-path-parameter-renamed/base.yaml create mode 100644 tests/e2e/diff/oas3-path-parameter-renamed/revision.yaml create mode 100644 tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt create mode 100644 tests/e2e/diff/oas3-path-removed/base.yaml create mode 100644 tests/e2e/diff/oas3-path-removed/revision.yaml create mode 100644 tests/e2e/diff/oas3-path-removed/snapshot.txt rename tests/e2e/diff/{property-removed-from-response => oas3-property-removed-from-response}/base.yaml (100%) rename tests/e2e/diff/{property-removed-from-response => oas3-property-removed-from-response}/revision.yaml (100%) rename tests/e2e/diff/{property-removed-from-response/stylish-snapshot.txt => oas3-property-removed-from-response/snapshot.txt} (100%) create mode 100644 tests/e2e/diff/oas3-ref-target-changed/base.yaml create mode 100644 tests/e2e/diff/oas3-ref-target-changed/revision.yaml create mode 100644 tests/e2e/diff/oas3-ref-target-changed/snapshot.txt rename tests/e2e/diff/{request-body-became-required => oas3-request-body-became-required}/base.yaml (100%) rename tests/e2e/diff/{request-body-became-required => oas3-request-body-became-required}/revision.yaml (100%) rename tests/e2e/diff/{request-body-became-required/stylish-snapshot.txt => oas3-request-body-became-required/snapshot.txt} (100%) rename tests/e2e/diff/{request-body-removed => oas3-request-body-removed}/base.yaml (100%) rename tests/e2e/diff/{request-body-removed => oas3-request-body-removed}/revision.yaml (100%) rename tests/e2e/diff/{request-body-removed/stylish-snapshot.txt => oas3-request-body-removed/snapshot.txt} (100%) create mode 100644 tests/e2e/diff/oas3-required-properties-added/base.yaml create mode 100644 tests/e2e/diff/oas3-required-properties-added/revision.yaml create mode 100644 tests/e2e/diff/oas3-required-properties-added/snapshot.txt create mode 100644 tests/e2e/diff/oas3-required-properties-removed/base.yaml create mode 100644 tests/e2e/diff/oas3-required-properties-removed/revision.yaml create mode 100644 tests/e2e/diff/oas3-required-properties-removed/snapshot.txt rename tests/e2e/diff/{response-header-removed => oas3-response-header-removed}/base.yaml (100%) rename tests/e2e/diff/{response-header-removed => oas3-response-header-removed}/revision.yaml (100%) rename tests/e2e/diff/{response-header-removed/stylish-snapshot.txt => oas3-response-header-removed/snapshot.txt} (100%) create mode 100644 tests/e2e/diff/oas3-response-removed/base.yaml create mode 100644 tests/e2e/diff/oas3-response-removed/revision.yaml create mode 100644 tests/e2e/diff/oas3-response-removed/snapshot.txt rename tests/e2e/diff/{schema-combinator-changed => oas3-schema-combinator-changed}/base.yaml (100%) rename tests/e2e/diff/{schema-combinator-changed => oas3-schema-combinator-changed}/revision.yaml (100%) rename tests/e2e/diff/{schema-combinator-changed/stylish-snapshot.txt => oas3-schema-combinator-changed/snapshot.txt} (100%) rename tests/e2e/diff/{schema-format-changed => oas3-schema-format-changed}/base.yaml (100%) rename tests/e2e/diff/{schema-format-changed => oas3-schema-format-changed}/revision.yaml (100%) rename tests/e2e/diff/{schema-format-changed/stylish-snapshot.txt => oas3-schema-format-changed/snapshot.txt} (79%) rename tests/e2e/diff/{schema-type-widened-in-request => oas3-schema-type-widened-in-request}/base.yaml (100%) rename tests/e2e/diff/{schema-type-widened-in-request => oas3-schema-type-widened-in-request}/revision.yaml (100%) rename tests/e2e/diff/{schema-type-widened-in-request/stylish-snapshot.txt => oas3-schema-type-widened-in-request/snapshot.txt} (100%) create mode 100644 tests/e2e/diff/oas3-security-requirement-added-to-empty-list/base.yaml create mode 100644 tests/e2e/diff/oas3-security-requirement-added-to-empty-list/revision.yaml create mode 100644 tests/e2e/diff/oas3-security-requirement-added-to-empty-list/snapshot.txt rename tests/e2e/diff/{security-requirement-added => oas3-security-requirement-added}/base.yaml (100%) rename tests/e2e/diff/{security-requirement-added => oas3-security-requirement-added}/revision.yaml (100%) rename tests/e2e/diff/{security-requirement-added/stylish-snapshot.txt => oas3-security-requirement-added/snapshot.txt} (71%) rename tests/e2e/diff/{security-scheme-changed => oas3-security-scheme-changed}/base.yaml (100%) rename tests/e2e/diff/{security-scheme-changed => oas3-security-scheme-changed}/revision.yaml (100%) rename tests/e2e/diff/{security-scheme-changed/stylish-snapshot.txt => oas3-security-scheme-changed/snapshot.txt} (100%) create mode 100644 tests/e2e/diff/oas3-security-scheme-removed/base.yaml create mode 100644 tests/e2e/diff/oas3-security-scheme-removed/revision.yaml create mode 100644 tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt create mode 100644 tests/e2e/diff/oas3-security-scopes-added/base.yaml create mode 100644 tests/e2e/diff/oas3-security-scopes-added/revision.yaml create mode 100644 tests/e2e/diff/oas3-security-scopes-added/snapshot.txt rename tests/e2e/diff/{string-length-changed => oas3-string-length-changed}/base.yaml (100%) rename tests/e2e/diff/{string-length-changed => oas3-string-length-changed}/revision.yaml (100%) rename tests/e2e/diff/{string-length-changed/stylish-snapshot.txt => oas3-string-length-changed/snapshot.txt} (100%) rename tests/e2e/diff/{webhook-payload-property-removed => oas3-webhook-payload-property-removed}/base.yaml (100%) rename tests/e2e/diff/{webhook-payload-property-removed => oas3-webhook-payload-property-removed}/revision.yaml (100%) rename tests/e2e/diff/{webhook-payload-property-removed/stylish-snapshot.txt => oas3-webhook-payload-property-removed/snapshot.txt} (100%) diff --git a/.changeset/diff-command.md b/.changeset/diff-command.md deleted file mode 100644 index 9935dfe2a4..0000000000 --- a/.changeset/diff-command.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@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. 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 index d74916c14b..d0abb1fcaf 100644 --- a/docs/@v2/commands/diff.md +++ b/docs/@v2/commands/diff.md @@ -7,8 +7,10 @@ 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 and reports what was added, removed, and changed. -For OpenAPI 3.x, changes are also classified as breaking or non-breaking, so you can catch breaking changes before they reach your consumers. +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 @@ -35,65 +37,149 @@ redocly diff main@v1 main@v2 --fail-on=breaking ## 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 conservatively reported as `breaking`. -- Structural comparison works for all supported specification types; breaking-change classification applies to OpenAPI 3.x. -- Under `callbacks` and `webhooks` the direction is flipped, because the API sends those: their request body is judged the way a response is, and their responses the way a request is. +- 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 `diff` command detects common breaking changes using a documented rule catalog; it is not an exhaustive detector. -Renaming a component (for example, a schema or parameter) is seen as a removal plus an addition, not a rename, so the new `$ref` target is reported as `breaking` (`ref-target-changed`) rather than matched to its previous identity. -Comparing documents of different specification families (for example, OpenAPI 2.0 vs OpenAPI 3.1) is not supported. -Reordering subschemas inside `allOf`, `oneOf`, or `anyOf` (and other lists without a natural identity) is matched positionally and may be reported as changes. -`readOnly` and `writeOnly` do not refine request/response polarity; a component used on both sides is judged under both, and the stricter verdict wins. -Reordering items that do have an identity (for example, `servers`, matched by URL) is not reported, even though `servers` order can be semantically meaningful. -Comparing across minor versions (OpenAPI 3.0 vs 3.1) can surface syntactic-only differences (for example, `nullable: true` vs `type: [..., "null"]`). +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 -| 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. This is reported as `breaking`. | +### 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 ALL triggered rule verdicts in a `verdicts` array. Each verdict includes: +Each change carries every verdict that a rule gave it, in a `verdicts` array. +A verdict has three fields: -- `ruleId`: The rule identifier (for example, `parameter-became-required`) -- `compat`: The compatibility classification (`breaking` or `non-breaking`) -- `message`: A human-readable description of the violation +- `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 -The change's own `compat` field, in every output format, is the most severe verdict across all triggered rules. +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 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. +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 -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. If a renamed path's operations define `callbacks` whose own path items declare a parameter with the same name as the renamed one, that callback parameter may be reported as removed and added; this is a cosmetic structural change rather than a real one. +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 -Use the default `--fail-on=breaking` behavior to make `diff` exit with code `1` when it finds breaking changes, which is useful as a pull request check: +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 @@ -102,7 +188,7 @@ redocly diff main-openapi.yaml pr-openapi.yaml ### Generate an HTML report -Use `--format=html` together with `--output` to write a shareable HTML report instead of printing to the terminal: +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 @@ -110,13 +196,15 @@ 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 are shared with the `lint` command, so existing CI integrations accept the diff report as-is. -With `github-actions`, every breaking change becomes an inline annotation on the 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 **breaking changes only**, because each entry carries a severity. -Use `json` when you need the complete list of changes, including the non-breaking ones. -They print to stdout and don't support `--output`, and the `junit` report names its test suite `redocly lint`. +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/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/engine/__tests__/classify.test.ts b/packages/cli/src/commands/diff/__tests__/classify.test.ts similarity index 63% rename from packages/cli/src/commands/diff/engine/__tests__/classify.test.ts rename to packages/cli/src/commands/diff/__tests__/classify.test.ts index 03c7a9c127..db561dd0af 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/classify.test.ts +++ b/packages/cli/src/commands/diff/__tests__/classify.test.ts @@ -1,6 +1,6 @@ -import { classifyChanges } from '../classify/index.js'; -import { UsageIndex } from '../classify/usage.js'; -import type { NodeEntry, RawChange } from '../types.js'; +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 = { @@ -9,39 +9,9 @@ const emptyMaps = { 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('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.verdicts).toEqual([ - { ruleId: 'operation-removed', compat: 'breaking', message: 'Operation was removed.' }, - ]); - }); - - 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.verdicts).toEqual([ - { ruleId: 'path-removed', compat: 'breaking', message: 'Path was removed.' }, - ]); - }); - it('defaults to non-breaking when no rule judges the change', () => { const changes: RawChange[] = [ { @@ -71,19 +41,6 @@ describe('classifyChanges', () => { 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'); - }); - 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. @@ -135,10 +92,22 @@ describe('classifyChanges', () => { 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?.map((v) => v.ruleId)).toEqual([ - 'enum-values-added', - 'enum-values-removed', - ]); + 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/engine/__tests__/collect.test.ts b/packages/cli/src/commands/diff/__tests__/collect.test.ts similarity index 59% rename from packages/cli/src/commands/diff/engine/__tests__/collect.test.ts rename to packages/cli/src/commands/diff/__tests__/collect.test.ts index 589868f25e..505e52e272 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/collect.test.ts +++ b/packages/cli/src/commands/diff/__tests__/collect.test.ts @@ -7,7 +7,8 @@ import { } from '@redocly/openapi-core'; import { outdent } from 'outdent'; -import { collectDocumentMap } from '../collect.js'; +import { collectDocumentMap } from '../engine/collect.js'; +import type { NodeEntry } from '../engine/types.js'; async function collect(yaml: string) { const document = makeDocumentFromString(yaml, ''); @@ -17,8 +18,15 @@ async function collect(yaml: string) { 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('collects nodes with identity-keyed stable pointers and real pointers', async () => { + 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' } @@ -37,18 +45,27 @@ describe('collectDocumentMap', () => { '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' }); + // 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 () => { @@ -113,6 +130,27 @@ describe('collectDocumentMap', () => { 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 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/engine/__tests__/diff-documents.test.ts b/packages/cli/src/commands/diff/__tests__/diff-documents.test.ts similarity index 50% rename from packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts rename to packages/cli/src/commands/diff/__tests__/diff-documents.test.ts index e03b8c7582..f268759e52 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/diff-documents.test.ts +++ b/packages/cli/src/commands/diff/__tests__/diff-documents.test.ts @@ -1,7 +1,8 @@ import { createConfig, makeDocumentFromString } from '@redocly/openapi-core'; import { outdent } from 'outdent'; -import { DiffError, diffDocuments } from '../index.js'; +import { DiffError, diffDocuments } from '../engine/index.js'; +import type { DiffResult } from '../engine/types.js'; const BASE = outdent` openapi: 3.1.0 @@ -38,8 +39,26 @@ const REVISION = outdent` '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('produces the running example from the design spec', async () => { + it('matches reordered parameters by identity and judges what actually changed', async () => { const config = await createConfig({}); const result = diffDocuments({ base: makeDocumentFromString(BASE, 'base.yaml'), @@ -47,43 +66,19 @@ describe('diffDocuments', () => { 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(); - // NOTE: '/' (0x2F) sorts before ':' (0x3A) in a plain string sort, so the - // "{query:limit}/schema::type" entry sorts before "{query:limit}::required". - expect(keys).toEqual([ - '#/paths/~1pets/get/parameters/{query:limit}/schema::type', - '#/paths/~1pets/get/parameters/{query:limit}::required', - '#/paths/~1pets/get/responses/200::description', - ]); - - const becameRequired = result.changes.find((c) => c.property === 'required')!; - expect(becameRequired.compat).toBe('breaking'); - expect(becameRequired.verdicts).toEqual([ - { - ruleId: 'parameter-became-required', - compat: 'breaking', - message: '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'); - expect(becameRequired.base).toMatchObject({ file: 'base.yaml' }); - expect(becameRequired.revision).toMatchObject({ file: 'rev.yaml' }); - expect(becameRequired.revision?.line).toBeGreaterThan(1); - - // 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'); - + // 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 }); }); @@ -124,28 +119,16 @@ describe('diffDocuments', () => { 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); - - // 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 () => { @@ -180,8 +163,16 @@ describe('diffDocuments', () => { ); 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(); + // 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__/fail-on.test.ts b/packages/cli/src/commands/diff/__tests__/fail-on.test.ts deleted file mode 100644 index 88bc46cb36..0000000000 --- a/packages/cli/src/commands/diff/__tests__/fail-on.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -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(); - }); -}); diff --git a/packages/cli/src/commands/diff/engine/__tests__/polarity.test.ts b/packages/cli/src/commands/diff/__tests__/polarity.test.ts similarity index 57% rename from packages/cli/src/commands/diff/engine/__tests__/polarity.test.ts rename to packages/cli/src/commands/diff/__tests__/polarity.test.ts index 7b0e162fab..901dd5a807 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/polarity.test.ts +++ b/packages/cli/src/commands/diff/__tests__/polarity.test.ts @@ -1,6 +1,6 @@ -import type { NodeLookup } from '../classify/chain.js'; -import { getPolarity } from '../classify/polarity.js'; -import { getComponentRoot, mergePolarity, UsageIndex } from '../classify/usage.js'; +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. @@ -75,40 +75,42 @@ describe('mergePolarity', () => { }); }); -describe('getPolarity', () => { +describe('getOas3Polarity', () => { it('reads the direction off the node types on the way down', () => { - expect(getPolarity('#/paths/~1p/get/responses/200', emptyUsage, tree)).toBe('response'); - expect(getPolarity('#/paths/~1p/get/parameters/{query:limit}/schema', emptyUsage, tree)).toBe( - 'request' - ); + 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( - getPolarity('#/paths/~1p/post/requestBody/content/application~1json', emptyUsage, tree) + getOas3Polarity('#/paths/~1p/post/requestBody/content/application~1json', emptyUsage, tree) ).toBe('request'); - expect(getPolarity('#/info/title', emptyUsage, tree)).toBe('neutral'); - expect(getPolarity('#/tags/{pets}', emptyUsage, tree)).toBe('neutral'); + 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( - getPolarity('#/paths/~1p/post/callbacks/onEvent/~1cb/post/requestBody', emptyUsage, tree) + getOas3Polarity('#/paths/~1p/post/callbacks/onEvent/~1cb/post/requestBody', emptyUsage, tree) ).toBe('response'); - expect(getPolarity('#/webhooks/newPet/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(getPolarity('#/webhooks/newPet/post/responses', emptyUsage, tree)).toBe('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( - getPolarity( + getOas3Polarity( '#/paths/~1p/post/requestBody/content/application~1json/schema/properties/responses', emptyUsage, tree ) ).toBe('request'); expect( - getPolarity( + getOas3Polarity( '#/paths/~1p/get/responses/200/content/application~1json/schema/properties/callbacks', emptyUsage, tree @@ -126,7 +128,9 @@ describe('getPolarity', () => { ], tree ); - expect(getPolarity('#/components/schemas/Pet/properties/name', usage, tree)).toBe('response'); + expect(getOas3Polarity('#/components/schemas/Pet/properties/name', usage, tree)).toBe( + 'response' + ); }); it('derives both when a component is used on both sides', () => { @@ -143,7 +147,7 @@ describe('getPolarity', () => { ], tree ); - expect(getPolarity('#/components/schemas/Pet', usage, tree)).toBe('both'); + expect(getOas3Polarity('#/components/schemas/Pet', usage, tree)).toBe('both'); }); it('resolves transitive usage through other components, cycle-safe', () => { @@ -162,10 +166,79 @@ describe('getPolarity', () => { ], tree ); - expect(getPolarity('#/components/schemas/Address', usage, tree)).toBe('response'); + expect(getOas3Polarity('#/components/schemas/Address', usage, tree)).toBe('response'); }); it('returns neutral for unused components', () => { - expect(getPolarity('#/components/schemas/Orphan', emptyUsage, tree)).toBe('neutral'); + 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/engine/__tests__/predicates.test.ts b/packages/cli/src/commands/diff/__tests__/predicates.test.ts similarity index 70% rename from packages/cli/src/commands/diff/engine/__tests__/predicates.test.ts rename to packages/cli/src/commands/diff/__tests__/predicates.test.ts index f972672be0..5ddaf2152e 100644 --- a/packages/cli/src/commands/diff/engine/__tests__/predicates.test.ts +++ b/packages/cli/src/commands/diff/__tests__/predicates.test.ts @@ -1,48 +1,17 @@ import { - isScalar, - isScalarArray, - scalarEquals, - missingItems, - addedItems, - becameTrue, constraintDirection, effectiveTypes, + isScalarArray, isTypeSetNarrowed, isTypeSetWidened, -} from '../predicates.js'; +} from '../engine/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); + 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); - 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 over the whole accepted set', () => { diff --git a/packages/cli/src/commands/diff/__tests__/problems.test.ts b/packages/cli/src/commands/diff/__tests__/problems.test.ts index 815ce3a257..46ca466eac 100644 --- a/packages/cli/src/commands/diff/__tests__/problems.test.ts +++ b/packages/cli/src/commands/diff/__tests__/problems.test.ts @@ -50,34 +50,37 @@ const result: DiffResult = { }; describe('breakingChangesToProblems', () => { - const problems = breakingChangesToProblems(result, baseSource, revisionSource); + it('describes each breaking change the way a lint problem is described', () => { + const problems = breakingChangesToProblems(result, baseSource, revisionSource); - it('keeps only breaking changes', () => { - expect(problems).toHaveLength(2); - expect(problems.every((problem) => problem.severity === 'error')).toBe(true); - }); - - it('takes message and ruleId from the worst verdict', () => { - expect(problems[0]).toMatchObject({ - message: 'Operation was removed.', - ruleId: 'operation-removed', - }); - }); - - it('points a removal at the base document and a change at the revision', () => { - expect(problems[0].location[0]).toMatchObject({ - source: baseSource, - pointer: '#/paths/~1pets/delete', - }); - expect(problems[1].location[0]).toMatchObject({ - source: revisionSource, - pointer: '#/paths/~1pets/get/parameters/0/required', - }); - }); - - it('adds the counterpart side as `from` when the change has both sides', () => { - expect(problems[1].from).toMatchObject({ source: baseSource }); - // A removal is already shown at its base location, so it needs no `from`. - expect(problems[0].from).toBeUndefined(); + // 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-rich.test.ts b/packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts deleted file mode 100644 index 3300aae766..0000000000 --- a/packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { DiffResult } from '../engine/types.js'; -import { htmlDiff } from '../serializers/html.js'; -import { markdownDiff } from '../serializers/markdown.js'; - -const RESULT: DiffResult = { - version: '1', - specVersions: { base: 'oas3_1', revision: 'oas3_1' }, - summary: { breaking: 1, nonBreaking: 0 }, - changes: [ - { - pointer: '#/paths/~1pets/get', - kind: 'removed', - typeName: 'Operation', - base: { pointer: '#/paths/~1pets/get', value: { summary: '' } }, - compat: 'breaking', - verdicts: [ - { ruleId: 'operation-removed', compat: 'breaking', 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 deleted file mode 100644 index 613c06512f..0000000000 --- a/docs/superpowers/specs/2026-07-07-diff-command-design.md +++ /dev/null @@ -1,306 +0,0 @@ -# `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. From 48992b52da83f7e60eef063705816c2f983fe729 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:52:17 +0300 Subject: [PATCH 22/57] refactor(core): move the diff engine and its formats from the CLI into core --- .changeset/purple-onions-shave.md | 1 + .../commands/diff/engine/classify/chain.ts | 20 --------- packages/cli/src/commands/diff/fail-on.ts | 4 +- packages/cli/src/commands/diff/index.ts | 42 ++++++------------ .../src}/diff/__tests__/compare.test.ts | 4 +- .../src/diff/__tests__/detect.test.ts} | 7 +-- .../diff/__tests__/diff-documents.test.ts | 7 +-- .../src/diff/__tests__/direction.test.ts} | 6 +-- .../diff/__tests__/documented-rules.test.ts | 8 ++-- .../src}/diff/__tests__/predicates.test.ts | 4 +- .../src}/diff/__tests__/tree.ts | 2 +- .../engine => core/src/diff}/align-paths.ts | 7 ++- .../diff/engine => core/src/diff}/compare.ts | 6 +-- .../index.ts => core/src/diff/detect.ts} | 15 +++---- .../src/diff/direction.ts} | 7 +-- .../__tests__/__snapshots__/html-report.html | 0 .../src/diff/format/__tests__/format.test.ts} | 17 ++++--- .../diff/format}/__tests__/problems.test.ts | 7 ++- .../src/diff/format}/change-side.ts | 2 +- .../src/diff/format}/html.ts | 2 +- packages/core/src/diff/format/index.ts | 14 ++++++ .../src/diff/format}/json.ts | 2 +- .../src/diff/format}/markdown.ts | 2 +- .../src/diff/format}/problems.ts | 6 +-- .../src/diff/format}/stylish.ts | 25 ++++++----- .../src/diff/identity.ts} | 2 +- .../diff/engine => core/src/diff}/index.ts | 26 +++++------ .../diff/engine => core/src/diff}/locate.ts | 4 +- .../engine => core/src/diff}/predicates.ts | 15 ------- .../src/diff/rules}/async3.ts | 12 ++--- .../src/diff}/rules/channel.ts | 2 +- .../src/diff}/rules/message.ts | 2 +- .../classify => core/src/diff/rules}/oas3.ts | 14 +++--- .../src/diff}/rules/operation.ts | 2 +- .../src/diff}/rules/parameter.ts | 7 ++- .../classify => core/src/diff}/rules/ref.ts | 2 +- .../src/diff}/rules/request-body.ts | 4 +- .../src/diff}/rules/response.ts | 2 +- .../src/diff}/rules/schema.ts | 4 +- .../src/diff}/rules/security.ts | 4 +- .../src/diff}/rules/server.ts | 2 +- .../diff/engine => core/src/diff}/types.ts | 14 +----- .../classify => core/src/diff}/usage.ts | 16 +------ packages/core/src/index.ts | 4 ++ .../src/node-map}/__tests__/collect.test.ts | 21 +++++---- packages/core/src/node-map/chain.ts | 32 ++++++++++++++ .../engine => core/src/node-map}/collect.ts | 44 +++++++------------ packages/core/src/node-map/types.ts | 20 +++++++++ packages/core/src/utils/is-scalar.ts | 14 ++++++ 49 files changed, 244 insertions(+), 242 deletions(-) delete mode 100644 packages/cli/src/commands/diff/engine/classify/chain.ts rename packages/{cli/src/commands => core/src}/diff/__tests__/compare.test.ts (97%) rename packages/{cli/src/commands/diff/__tests__/classify.test.ts => core/src/diff/__tests__/detect.test.ts} (95%) rename packages/{cli/src/commands => core/src}/diff/__tests__/diff-documents.test.ts (96%) rename packages/{cli/src/commands/diff/__tests__/polarity.test.ts => core/src/diff/__tests__/direction.test.ts} (97%) rename packages/{cli/src/commands => core/src}/diff/__tests__/documented-rules.test.ts (89%) rename packages/{cli/src/commands => core/src}/diff/__tests__/predicates.test.ts (97%) rename packages/{cli/src/commands => core/src}/diff/__tests__/tree.ts (94%) rename packages/{cli/src/commands/diff/engine => core/src/diff}/align-paths.ts (96%) rename packages/{cli/src/commands/diff/engine => core/src/diff}/compare.ts (95%) rename packages/{cli/src/commands/diff/engine/classify/index.ts => core/src/diff/detect.ts} (90%) rename packages/{cli/src/commands/diff/engine/classify/polarity.ts => core/src/diff/direction.ts} (94%) rename packages/{cli/src/commands/diff => core/src/diff/format}/__tests__/__snapshots__/html-report.html (100%) rename packages/{cli/src/commands/diff/__tests__/serializers.test.ts => core/src/diff/format/__tests__/format.test.ts} (92%) rename packages/{cli/src/commands/diff => core/src/diff/format}/__tests__/problems.test.ts (94%) rename packages/{cli/src/commands/diff/serializers => core/src/diff/format}/change-side.ts (83%) rename packages/{cli/src/commands/diff/serializers => core/src/diff/format}/html.ts (97%) create mode 100644 packages/core/src/diff/format/index.ts rename packages/{cli/src/commands/diff/serializers => core/src/diff/format}/json.ts (64%) rename packages/{cli/src/commands/diff/serializers => core/src/diff/format}/markdown.ts (95%) rename packages/{cli/src/commands/diff/serializers => core/src/diff/format}/problems.ts (90%) rename packages/{cli/src/commands/diff/serializers => core/src/diff/format}/stylish.ts (79%) rename packages/{cli/src/commands/diff/engine/node-identity.ts => core/src/diff/identity.ts} (95%) rename packages/{cli/src/commands/diff/engine => core/src/diff}/index.ts (84%) rename packages/{cli/src/commands/diff/engine => core/src/diff}/locate.ts (87%) rename packages/{cli/src/commands/diff/engine => core/src/diff}/predicates.ts (88%) rename packages/{cli/src/commands/diff/engine/classify => core/src/diff/rules}/async3.ts (66%) rename packages/{cli/src/commands/diff/engine/classify => core/src/diff}/rules/channel.ts (94%) rename packages/{cli/src/commands/diff/engine/classify => core/src/diff}/rules/message.ts (94%) rename packages/{cli/src/commands/diff/engine/classify => core/src/diff/rules}/oas3.ts (81%) rename packages/{cli/src/commands/diff/engine/classify => core/src/diff}/rules/operation.ts (94%) rename packages/{cli/src/commands/diff/engine/classify => core/src/diff}/rules/parameter.ts (93%) rename packages/{cli/src/commands/diff/engine/classify => core/src/diff}/rules/ref.ts (93%) rename packages/{cli/src/commands/diff/engine/classify => core/src/diff}/rules/request-body.ts (87%) rename packages/{cli/src/commands/diff/engine/classify => core/src/diff}/rules/response.ts (95%) rename packages/{cli/src/commands/diff/engine/classify => core/src/diff}/rules/schema.ts (99%) rename packages/{cli/src/commands/diff/engine/classify => core/src/diff}/rules/security.ts (97%) rename packages/{cli/src/commands/diff/engine/classify => core/src/diff}/rules/server.ts (89%) rename packages/{cli/src/commands/diff/engine => core/src/diff}/types.ts (74%) rename packages/{cli/src/commands/diff/engine/classify => core/src/diff}/usage.ts (68%) rename packages/{cli/src/commands/diff => core/src/node-map}/__tests__/collect.test.ts (91%) create mode 100644 packages/core/src/node-map/chain.ts rename packages/{cli/src/commands/diff/engine => core/src/node-map}/collect.ts (78%) create mode 100644 packages/core/src/node-map/types.ts create mode 100644 packages/core/src/utils/is-scalar.ts diff --git a/.changeset/purple-onions-shave.md b/.changeset/purple-onions-shave.md index 45c7315860..b6ec507e82 100644 --- a/.changeset/purple-onions-shave.md +++ b/.changeset/purple-onions-shave.md @@ -1,5 +1,6 @@ --- '@redocly/cli': minor +'@redocly/openapi-core': minor --- Added an experimental `diff` command that compares two API descriptions and reports what was added, removed, and changed. diff --git a/packages/cli/src/commands/diff/engine/classify/chain.ts b/packages/cli/src/commands/diff/engine/classify/chain.ts deleted file mode 100644 index 4630f061f8..0000000000 --- a/packages/cli/src/commands/diff/engine/classify/chain.ts +++ /dev/null @@ -1,20 +0,0 @@ -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/fail-on.ts b/packages/cli/src/commands/diff/fail-on.ts index 77cbc0f12e..4776880494 100644 --- a/packages/cli/src/commands/diff/fail-on.ts +++ b/packages/cli/src/commands/diff/fail-on.ts @@ -1,6 +1,4 @@ -import { pluralize } from '@redocly/openapi-core'; - -import type { DiffSummary } from './engine/types.js'; +import { pluralize, type DiffSummary } from '@redocly/openapi-core'; export type DiffFailOn = 'breaking' | 'none'; diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts index 20fd0193d5..b2fba646de 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -1,8 +1,14 @@ import { + breakingChangesToProblems, bundle, + DiffError, + diffDocuments, + diffReportFormats, formatProblems, getTotals, logger, + type DiffReportFormat, + type DiffResult, type OutputFormat, } from '@redocly/openapi-core'; import { writeFileSync } from 'node:fs'; @@ -11,31 +17,18 @@ 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). + * only, because a lint problem always carries a severity (see core diff/format/problems.ts). */ export type DiffProblemFormat = Extract< OutputFormat, 'codeframe' | 'checkstyle' | 'codeclimate' | 'summary' | 'github-actions' | 'junit' >; -export type DiffOutputFormat = DiffOwnFormat | DiffProblemFormat; +export type DiffOutputFormat = DiffReportFormat | DiffProblemFormat; export type { DiffFailOn }; export type DiffArgv = { @@ -46,21 +39,14 @@ export type DiffArgv = { '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; +function isReportFormat(format: DiffOutputFormat): format is DiffReportFormat { + return format in diffReportFormats; } export async function handleDiff({ argv, config, collectSpecData }: CommandArgs) { - if (argv.output && !isOwnFormat(argv.format)) { + if (argv.output && !isReportFormat(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(', ')}.` + `The ${argv.format} format prints to stdout only. To write a report to a file, use one of these formats: ${Object.keys(diffReportFormats).join(', ')}.` ); } @@ -82,8 +68,8 @@ export async function handleDiff({ argv, config, collectSpecData }: CommandArgs< throw error; } - if (isOwnFormat(argv.format)) { - const output = SERIALIZERS[argv.format](result); + if (isReportFormat(argv.format)) { + const output = diffReportFormats[argv.format](result); if (argv.output) { writeFileSync(argv.output, output); logger.info(`Diff report written to ${argv.output}.\n`); diff --git a/packages/cli/src/commands/diff/__tests__/compare.test.ts b/packages/core/src/diff/__tests__/compare.test.ts similarity index 97% rename from packages/cli/src/commands/diff/__tests__/compare.test.ts rename to packages/core/src/diff/__tests__/compare.test.ts index cebe63f611..c8731cd84b 100644 --- a/packages/cli/src/commands/diff/__tests__/compare.test.ts +++ b/packages/core/src/diff/__tests__/compare.test.ts @@ -1,5 +1,5 @@ -import { compareMaps } from '../engine/compare.js'; -import type { NodeEntry } from '../engine/types.js'; +import type { NodeEntry } from '../../node-map/types.js'; +import { compareMaps } from '../compare.js'; function entry(partial: Partial & { pointer: string }): NodeEntry { return { diff --git a/packages/cli/src/commands/diff/__tests__/classify.test.ts b/packages/core/src/diff/__tests__/detect.test.ts similarity index 95% rename from packages/cli/src/commands/diff/__tests__/classify.test.ts rename to packages/core/src/diff/__tests__/detect.test.ts index db561dd0af..b9ea54ff39 100644 --- a/packages/cli/src/commands/diff/__tests__/classify.test.ts +++ b/packages/core/src/diff/__tests__/detect.test.ts @@ -1,6 +1,7 @@ -import { classifyChanges } from '../engine/classify/index.js'; -import { UsageIndex } from '../engine/classify/usage.js'; -import type { NodeEntry, RawChange } from '../engine/types.js'; +import type { NodeEntry } from '../../node-map/types.js'; +import { classifyChanges } from '../detect.js'; +import type { RawChange } from '../types.js'; +import { UsageIndex } from '../usage.js'; import { treeOf } from './tree.js'; const emptyMaps = { diff --git a/packages/cli/src/commands/diff/__tests__/diff-documents.test.ts b/packages/core/src/diff/__tests__/diff-documents.test.ts similarity index 96% rename from packages/cli/src/commands/diff/__tests__/diff-documents.test.ts rename to packages/core/src/diff/__tests__/diff-documents.test.ts index f268759e52..1f93145399 100644 --- a/packages/cli/src/commands/diff/__tests__/diff-documents.test.ts +++ b/packages/core/src/diff/__tests__/diff-documents.test.ts @@ -1,8 +1,9 @@ -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'; +import { createConfig } from '../../config/index.js'; +import { makeDocumentFromString } from '../../resolve.js'; +import { DiffError, diffDocuments } from '../index.js'; +import type { DiffResult } from '../types.js'; const BASE = outdent` openapi: 3.1.0 diff --git a/packages/cli/src/commands/diff/__tests__/polarity.test.ts b/packages/core/src/diff/__tests__/direction.test.ts similarity index 97% rename from packages/cli/src/commands/diff/__tests__/polarity.test.ts rename to packages/core/src/diff/__tests__/direction.test.ts index 901dd5a807..e221d33102 100644 --- a/packages/cli/src/commands/diff/__tests__/polarity.test.ts +++ b/packages/core/src/diff/__tests__/direction.test.ts @@ -1,6 +1,6 @@ -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 { getComponentRoot, type NodeLookup } from '../../node-map/chain.js'; +import { getAsync3Polarity, getOas3Polarity } from '../direction.js'; +import { mergePolarity, UsageIndex } from '../usage.js'; import { treeOf } from './tree.js'; // One document covering every direction-bearing shape at once. diff --git a/packages/cli/src/commands/diff/__tests__/documented-rules.test.ts b/packages/core/src/diff/__tests__/documented-rules.test.ts similarity index 89% rename from packages/cli/src/commands/diff/__tests__/documented-rules.test.ts rename to packages/core/src/diff/__tests__/documented-rules.test.ts index fdfb8b6b9a..48134b88a1 100644 --- a/packages/cli/src/commands/diff/__tests__/documented-rules.test.ts +++ b/packages/core/src/diff/__tests__/documented-rules.test.ts @@ -2,12 +2,12 @@ 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'; +import { async3Rules } from '../rules/async3.js'; +import { oas3Rules } from '../rules/oas3.js'; +import type { DiffRule } from '../types.js'; const docs = readFileSync( - join(dirname(fileURLToPath(import.meta.url)), '../../../../../../docs/@v2/commands/diff.md'), + join(dirname(fileURLToPath(import.meta.url)), '../../../../../docs/@v2/commands/diff.md'), 'utf8' ); diff --git a/packages/cli/src/commands/diff/__tests__/predicates.test.ts b/packages/core/src/diff/__tests__/predicates.test.ts similarity index 97% rename from packages/cli/src/commands/diff/__tests__/predicates.test.ts rename to packages/core/src/diff/__tests__/predicates.test.ts index 5ddaf2152e..7a75236a5a 100644 --- a/packages/cli/src/commands/diff/__tests__/predicates.test.ts +++ b/packages/core/src/diff/__tests__/predicates.test.ts @@ -1,10 +1,10 @@ +import { isScalarArray } from '../../utils/is-scalar.js'; import { constraintDirection, effectiveTypes, - isScalarArray, isTypeSetNarrowed, isTypeSetWidened, -} from '../engine/predicates.js'; +} from '../predicates.js'; describe('diff predicates', () => { it('does not treat an empty array as a scalar value', () => { diff --git a/packages/cli/src/commands/diff/__tests__/tree.ts b/packages/core/src/diff/__tests__/tree.ts similarity index 94% rename from packages/cli/src/commands/diff/__tests__/tree.ts rename to packages/core/src/diff/__tests__/tree.ts index 0a4f643db4..d02cdeb988 100644 --- a/packages/cli/src/commands/diff/__tests__/tree.ts +++ b/packages/core/src/diff/__tests__/tree.ts @@ -1,4 +1,4 @@ -import type { NodeEntry } from '../engine/types.js'; +import type { NodeEntry } from '../../node-map/types.js'; /** * Builds a lookup over a spelled-out node tree for tests that need real node diff --git a/packages/cli/src/commands/diff/engine/align-paths.ts b/packages/core/src/diff/align-paths.ts similarity index 96% rename from packages/cli/src/commands/diff/engine/align-paths.ts rename to packages/core/src/diff/align-paths.ts index b39f3128a2..a53d2ffeef 100644 --- a/packages/cli/src/commands/diff/engine/align-paths.ts +++ b/packages/core/src/diff/align-paths.ts @@ -1,7 +1,6 @@ -import { unescapePointerFragment } from '@redocly/openapi-core'; - -import { escapeIdentityKeyPart } from './node-identity.js'; -import type { NodeEntry } from './types.js'; +import type { NodeEntry } from '../node-map/types.js'; +import { unescapePointerFragment } from '../ref-utils.js'; +import { escapeIdentityKeyPart } from './identity.js'; export interface PathRename { baseTemplate: string; diff --git a/packages/cli/src/commands/diff/engine/compare.ts b/packages/core/src/diff/compare.ts similarity index 95% rename from packages/cli/src/commands/diff/engine/compare.ts rename to packages/core/src/diff/compare.ts index 20ff50fc2a..a3d954b807 100644 --- a/packages/cli/src/commands/diff/engine/compare.ts +++ b/packages/core/src/diff/compare.ts @@ -1,6 +1,6 @@ -import { dequal } from '@redocly/openapi-core'; - -import type { NodeEntry, RawChange } from './types.js'; +import type { NodeEntry } from '../node-map/types.js'; +import { dequal } from '../utils/dequal.js'; +import type { RawChange } from './types.js'; function removalOf(pointer: string, entry: NodeEntry): RawChange { return { diff --git a/packages/cli/src/commands/diff/engine/classify/index.ts b/packages/core/src/diff/detect.ts similarity index 90% rename from packages/cli/src/commands/diff/engine/classify/index.ts rename to packages/core/src/diff/detect.ts index 698545cf44..5525254567 100644 --- a/packages/cli/src/commands/diff/engine/classify/index.ts +++ b/packages/core/src/diff/detect.ts @@ -1,18 +1,17 @@ -import type { SpecVersion } from '@redocly/openapi-core'; - +import type { NodeLookup } from '../node-map/chain.js'; +import type { NodeEntry } from '../node-map/types.js'; +import type { SpecVersion } from '../oas-types.js'; +import { getAsync3Polarity, getOas3Polarity, type PolarityResolver } from './direction.js'; +import { async3Rules } from './rules/async3.js'; +import { oas3Rules } from './rules/oas3.js'; 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'; +} from './types.js'; import type { UsageIndex } from './usage.js'; /** diff --git a/packages/cli/src/commands/diff/engine/classify/polarity.ts b/packages/core/src/diff/direction.ts similarity index 94% rename from packages/cli/src/commands/diff/engine/classify/polarity.ts rename to packages/core/src/diff/direction.ts index 3e6bae9288..2ac38376ba 100644 --- a/packages/cli/src/commands/diff/engine/classify/polarity.ts +++ b/packages/core/src/diff/direction.ts @@ -1,6 +1,7 @@ -import type { NodeEntry, Polarity } from '../types.js'; -import { ancestorChain, type NodeLookup } from './chain.js'; -import { getComponentRoot, type UsageIndex } from './usage.js'; +import { ancestorChain, getComponentRoot, type NodeLookup } from '../node-map/chain.js'; +import type { NodeEntry } from '../node-map/types.js'; +import type { Polarity } from './types.js'; +import 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; diff --git a/packages/cli/src/commands/diff/__tests__/__snapshots__/html-report.html b/packages/core/src/diff/format/__tests__/__snapshots__/html-report.html similarity index 100% rename from packages/cli/src/commands/diff/__tests__/__snapshots__/html-report.html rename to packages/core/src/diff/format/__tests__/__snapshots__/html-report.html diff --git a/packages/cli/src/commands/diff/__tests__/serializers.test.ts b/packages/core/src/diff/format/__tests__/format.test.ts similarity index 92% rename from packages/cli/src/commands/diff/__tests__/serializers.test.ts rename to packages/core/src/diff/format/__tests__/format.test.ts index c5a54d4eb2..e983333253 100644 --- a/packages/cli/src/commands/diff/__tests__/serializers.test.ts +++ b/packages/core/src/diff/format/__tests__/format.test.ts @@ -1,11 +1,16 @@ 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'; +import type { DiffResult } from '../../types.js'; +import { htmlDiff } from '../html.js'; +import { markdownDiff } from '../markdown.js'; +import { stylishDiff } from '../stylish.js'; + +// format.test.ts — vitest runs with FORCE_COLOR=1, so the codes are stripped before snapshotting +function stripColors(output: string): string { + // oxlint-disable-next-line no-control-regex + return output.replace(/\x1b\[\d+m/g, ''); +} /** * One result carrying every shape the reports have to survive: a removal located in the @@ -124,7 +129,7 @@ const RESULT: DiffResult = { 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(` + expect(stripColors(stylishDiff(RESULT))).toMatchInlineSnapshot(` "/pet/{petId} ✔ non-breaking changed paths · /pet/{id} · path at revision.yaml:4:3 diff --git a/packages/cli/src/commands/diff/__tests__/problems.test.ts b/packages/core/src/diff/format/__tests__/problems.test.ts similarity index 94% rename from packages/cli/src/commands/diff/__tests__/problems.test.ts rename to packages/core/src/diff/format/__tests__/problems.test.ts index 46ca466eac..318a0823dd 100644 --- a/packages/cli/src/commands/diff/__tests__/problems.test.ts +++ b/packages/core/src/diff/format/__tests__/problems.test.ts @@ -1,7 +1,6 @@ -import { Source } from '@redocly/openapi-core'; - -import type { DiffResult } from '../engine/types.js'; -import { breakingChangesToProblems } from '../serializers/problems.js'; +import { Source } from '../../../resolve.js'; +import type { DiffResult } from '../../types.js'; +import { breakingChangesToProblems } from '../problems.js'; const baseSource = new Source('base.yaml', 'openapi: 3.1.0\n'); const revisionSource = new Source('revision.yaml', 'openapi: 3.1.0\n'); diff --git a/packages/cli/src/commands/diff/serializers/change-side.ts b/packages/core/src/diff/format/change-side.ts similarity index 83% rename from packages/cli/src/commands/diff/serializers/change-side.ts rename to packages/core/src/diff/format/change-side.ts index 2dddcf498d..a26524cc53 100644 --- a/packages/cli/src/commands/diff/serializers/change-side.ts +++ b/packages/core/src/diff/format/change-side.ts @@ -1,4 +1,4 @@ -import type { Change, ChangeSide } from '../engine/types.js'; +import type { Change, ChangeSide } from '../types.js'; // The side shown to the user: what was removed lives in the base document, // everything else is best inspected in the revision. diff --git a/packages/cli/src/commands/diff/serializers/html.ts b/packages/core/src/diff/format/html.ts similarity index 97% rename from packages/cli/src/commands/diff/serializers/html.ts rename to packages/core/src/diff/format/html.ts index 5830dfd796..eba9937686 100644 --- a/packages/cli/src/commands/diff/serializers/html.ts +++ b/packages/core/src/diff/format/html.ts @@ -1,4 +1,4 @@ -import type { Change, DiffResult } from '../engine/types.js'; +import type { Change, DiffResult } from '../types.js'; function escapeHtml(value: unknown): string { return String(value) diff --git a/packages/core/src/diff/format/index.ts b/packages/core/src/diff/format/index.ts new file mode 100644 index 0000000000..104df5cd1f --- /dev/null +++ b/packages/core/src/diff/format/index.ts @@ -0,0 +1,14 @@ +import type { DiffResult } from '../types.js'; +import { htmlDiff } from './html.js'; +import { jsonDiff } from './json.js'; +import { markdownDiff } from './markdown.js'; +import { stylishDiff } from './stylish.js'; + +export const diffReportFormats = { + stylish: stylishDiff, + json: jsonDiff, + markdown: markdownDiff, + html: htmlDiff, +} satisfies Record string>; + +export type DiffReportFormat = keyof typeof diffReportFormats; diff --git a/packages/cli/src/commands/diff/serializers/json.ts b/packages/core/src/diff/format/json.ts similarity index 64% rename from packages/cli/src/commands/diff/serializers/json.ts rename to packages/core/src/diff/format/json.ts index acbad38f47..d07414977c 100644 --- a/packages/cli/src/commands/diff/serializers/json.ts +++ b/packages/core/src/diff/format/json.ts @@ -1,4 +1,4 @@ -import type { DiffResult } from '../engine/types.js'; +import type { DiffResult } from '../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/core/src/diff/format/markdown.ts similarity index 95% rename from packages/cli/src/commands/diff/serializers/markdown.ts rename to packages/core/src/diff/format/markdown.ts index 243b22581f..ed7e72b5b8 100644 --- a/packages/cli/src/commands/diff/serializers/markdown.ts +++ b/packages/core/src/diff/format/markdown.ts @@ -1,4 +1,4 @@ -import type { Change, DiffResult } from '../engine/types.js'; +import type { Change, DiffResult } from '../types.js'; const IMPACT_LABEL: Record = { breaking: '🔴 breaking', diff --git a/packages/cli/src/commands/diff/serializers/problems.ts b/packages/core/src/diff/format/problems.ts similarity index 90% rename from packages/cli/src/commands/diff/serializers/problems.ts rename to packages/core/src/diff/format/problems.ts index 55b6ce2436..5e99e39d57 100644 --- a/packages/cli/src/commands/diff/serializers/problems.ts +++ b/packages/core/src/diff/format/problems.ts @@ -1,6 +1,6 @@ -import type { NormalizedProblem, Source } from '@redocly/openapi-core'; - -import type { DiffResult } from '../engine/types.js'; +import type { Source } from '../../resolve.js'; +import type { NormalizedProblem } from '../../walk.js'; +import type { DiffResult } from '../types.js'; import { displaySide } from './change-side.js'; // Lint's problem model describes defects carrying a severity, so only breaking diff --git a/packages/cli/src/commands/diff/serializers/stylish.ts b/packages/core/src/diff/format/stylish.ts similarity index 79% rename from packages/cli/src/commands/diff/serializers/stylish.ts rename to packages/core/src/diff/format/stylish.ts index 3fd04de96d..e4df04c7fe 100644 --- a/packages/cli/src/commands/diff/serializers/stylish.ts +++ b/packages/core/src/diff/format/stylish.ts @@ -1,13 +1,14 @@ -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 { isBrowser } from '../../env.js'; +import { colorize } from '../../logger.js'; +import { isAbsoluteUrl, unescapePointerFragment } from '../../ref-utils.js'; +import { compatRank, type Change, type Compat, type DiffResult } from '../types.js'; import { displaySide } from './change-side.js'; const ICONS: Record = { - breaking: red('✖ breaking '), - 'non-breaking': green('✔ non-breaking'), + breaking: colorize.red('✖ breaking '), + 'non-breaking': colorize.green('✔ non-breaking'), }; const HTTP_METHODS = new Set([ @@ -65,7 +66,7 @@ function locationOf(change: Change, cwd: string): string | undefined { } export function stylishDiff(result: DiffResult): string { - const cwd = process.cwd(); + const cwd = isBrowser ? '' : process.cwd(); const groups = new Map(); for (const change of result.changes) { const key = groupOf(change); @@ -76,22 +77,24 @@ export function stylishDiff(result: DiffResult): string { const lines: string[] = []; for (const [key, changes] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) { - lines.push(bold(blue(key))); + lines.push(colorize.bold(colorize.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)}`); + lines.push(` ${ICONS[change.compat]} ${colorize.bold(change.kind)} ${labelOf(change)}`); for (const verdict of change.verdicts ?? []) { - lines.push(gray(` ${verdict.message} (${verdict.ruleId})`)); + lines.push(colorize.gray(` ${verdict.message} (${verdict.ruleId})`)); } const location = locationOf(change, cwd); - if (location) lines.push(gray(` at ${location}`)); + if (location) lines.push(colorize.gray(` at ${location}`)); } lines.push(''); } const { breaking, nonBreaking } = result.summary; - lines.push(`${red(`${breaking} breaking`)}, ${green(`${nonBreaking} non-breaking`)}.`); + lines.push( + `${colorize.red(`${breaking} breaking`)}, ${colorize.green(`${nonBreaking} non-breaking`)}.` + ); return lines.join('\n'); } diff --git a/packages/cli/src/commands/diff/engine/node-identity.ts b/packages/core/src/diff/identity.ts similarity index 95% rename from packages/cli/src/commands/diff/engine/node-identity.ts rename to packages/core/src/diff/identity.ts index 9a2828ed14..32d5eec1a0 100644 --- a/packages/cli/src/commands/diff/engine/node-identity.ts +++ b/packages/core/src/diff/identity.ts @@ -1,4 +1,4 @@ -import { isPlainObject } from '@redocly/openapi-core'; +import { isPlainObject } from '../utils/is-plain-object.js'; // JSON Pointer escaping for identity-key content: keys become pointer segments. export function escapeIdentityKeyPart(value: string): string { diff --git a/packages/cli/src/commands/diff/engine/index.ts b/packages/core/src/diff/index.ts similarity index 84% rename from packages/cli/src/commands/diff/engine/index.ts rename to packages/core/src/diff/index.ts index d035f0103a..50e7f46d0c 100644 --- a/packages/cli/src/commands/diff/engine/index.ts +++ b/packages/core/src/diff/index.ts @@ -1,20 +1,16 @@ -import { - detectSpec, - getMajorSpecVersion, - getTypes, - normalizeTypes, - type Config, - type Document, - type SpecVersion, -} from '@redocly/openapi-core'; - +import type { Config } from '../config/index.js'; +import { detectSpec, getMajorSpecVersion } from '../detect-spec.js'; +import { collectNodeMap } from '../node-map/collect.js'; +import { getTypes, type SpecVersion } from '../oas-types.js'; +import type { Document } from '../resolve.js'; +import { normalizeTypes } from '../types/index.js'; 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 { classifyChanges } from './detect.js'; +import { getIdentityKey } from './identity.js'; import { locateChanges } from './locate.js'; import type { DiffResult, DiffSummary, RawChange } from './types.js'; +import { UsageIndex } from './usage.js'; export class DiffError extends Error {} @@ -48,11 +44,11 @@ export function diffDocuments(opts: { // Each side is collected with ITS OWN type tree (spec §5.6). const collect = (document: Document, specVersion: SpecVersion) => - collectDocumentMap({ + collectNodeMap({ document, types: normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config), specVersion, - config, + identityOf: getIdentityKey, }); const baseCollected = collect(base, baseVersion); diff --git a/packages/cli/src/commands/diff/engine/locate.ts b/packages/core/src/diff/locate.ts similarity index 87% rename from packages/cli/src/commands/diff/engine/locate.ts rename to packages/core/src/diff/locate.ts index b20d26c8f8..e7bd836ef0 100644 --- a/packages/cli/src/commands/diff/engine/locate.ts +++ b/packages/core/src/diff/locate.ts @@ -1,5 +1,5 @@ -import { getLineColLocation, type Source } from '@redocly/openapi-core'; - +import { getLineColLocation } from '../format/codeframes.js'; +import type { Source } from '../resolve.js'; import type { Change, ChangeSide } from './types.js'; // Nodes inlined by bundling do not exist in the root source AST; diff --git a/packages/cli/src/commands/diff/engine/predicates.ts b/packages/core/src/diff/predicates.ts similarity index 88% rename from packages/cli/src/commands/diff/engine/predicates.ts rename to packages/core/src/diff/predicates.ts index 9fd9691911..691a2d80d9 100644 --- a/packages/cli/src/commands/diff/engine/predicates.ts +++ b/packages/core/src/diff/predicates.ts @@ -1,18 +1,3 @@ -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 : []; diff --git a/packages/cli/src/commands/diff/engine/classify/async3.ts b/packages/core/src/diff/rules/async3.ts similarity index 66% rename from packages/cli/src/commands/diff/engine/classify/async3.ts rename to packages/core/src/diff/rules/async3.ts index 7619e856d1..f43e87bf06 100644 --- a/packages/cli/src/commands/diff/engine/classify/async3.ts +++ b/packages/core/src/diff/rules/async3.ts @@ -1,10 +1,10 @@ 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'; +import { channelAddressChanged, channelRemoved } from './channel.js'; +import { messageContentTypeChanged, messageRemoved } from './message.js'; +import { operationActionChanged, operationRemoved } from './operation.js'; +import { refTargetChanged } from './ref.js'; +import { schemaRules } from './schema.js'; +import { serverRemoved } from './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 diff --git a/packages/cli/src/commands/diff/engine/classify/rules/channel.ts b/packages/core/src/diff/rules/channel.ts similarity index 94% rename from packages/cli/src/commands/diff/engine/classify/rules/channel.ts rename to packages/core/src/diff/rules/channel.ts index 4d588d8e15..ed00ca37a1 100644 --- a/packages/cli/src/commands/diff/engine/classify/rules/channel.ts +++ b/packages/core/src/diff/rules/channel.ts @@ -1,4 +1,4 @@ -import { breaking, type DiffRule } from '../../types.js'; +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. diff --git a/packages/cli/src/commands/diff/engine/classify/rules/message.ts b/packages/core/src/diff/rules/message.ts similarity index 94% rename from packages/cli/src/commands/diff/engine/classify/rules/message.ts rename to packages/core/src/diff/rules/message.ts index 9d435b761b..4632f80042 100644 --- a/packages/cli/src/commands/diff/engine/classify/rules/message.ts +++ b/packages/core/src/diff/rules/message.ts @@ -1,4 +1,4 @@ -import { breaking, type DiffRule } from '../../types.js'; +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. diff --git a/packages/cli/src/commands/diff/engine/classify/oas3.ts b/packages/core/src/diff/rules/oas3.ts similarity index 81% rename from packages/cli/src/commands/diff/engine/classify/oas3.ts rename to packages/core/src/diff/rules/oas3.ts index e646048cef..69dbdfcb7a 100644 --- a/packages/cli/src/commands/diff/engine/classify/oas3.ts +++ b/packages/core/src/diff/rules/oas3.ts @@ -1,21 +1,21 @@ import type { DiffRuleRegistry } from '../types.js'; -import { operationRemoved, pathRemoved } from './rules/operation.js'; +import { operationRemoved, pathRemoved } from './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'; +} from './parameter.js'; +import { refTargetChanged } from './ref.js'; +import { requestBodyBecameRequired, requestBodyRemoved } from './request-body.js'; +import { mediaTypeRemoved, responseHeaderRemoved, responseRemoved } from './response.js'; +import { schemaRules } from './schema.js'; import { securityRequirementAdded, securitySchemeChanged, securitySchemeRemoved, securityScopesAdded, -} from './rules/security.js'; +} from './security.js'; export const oas3Rules: DiffRuleRegistry = { Operation: [operationRemoved], diff --git a/packages/cli/src/commands/diff/engine/classify/rules/operation.ts b/packages/core/src/diff/rules/operation.ts similarity index 94% rename from packages/cli/src/commands/diff/engine/classify/rules/operation.ts rename to packages/core/src/diff/rules/operation.ts index e66a9e5f31..11715e3eab 100644 --- a/packages/cli/src/commands/diff/engine/classify/rules/operation.ts +++ b/packages/core/src/diff/rules/operation.ts @@ -1,4 +1,4 @@ -import { breaking, type DiffRule } from '../../types.js'; +import { breaking, type DiffRule } from '../types.js'; export const operationRemoved: DiffRule = { id: 'operation-removed', diff --git a/packages/cli/src/commands/diff/engine/classify/rules/parameter.ts b/packages/core/src/diff/rules/parameter.ts similarity index 93% rename from packages/cli/src/commands/diff/engine/classify/rules/parameter.ts rename to packages/core/src/diff/rules/parameter.ts index 9546e88126..c769cbf607 100644 --- a/packages/cli/src/commands/diff/engine/classify/rules/parameter.ts +++ b/packages/core/src/diff/rules/parameter.ts @@ -1,7 +1,6 @@ -import { isPlainObject } from '@redocly/openapi-core'; - -import { becameTrue } from '../../predicates.js'; -import { breaking, type DiffRule } from '../../types.js'; +import { isPlainObject } from '../../utils/is-plain-object.js'; +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, diff --git a/packages/cli/src/commands/diff/engine/classify/rules/ref.ts b/packages/core/src/diff/rules/ref.ts similarity index 93% rename from packages/cli/src/commands/diff/engine/classify/rules/ref.ts rename to packages/core/src/diff/rules/ref.ts index 04de27cf12..07ad8e94ac 100644 --- a/packages/cli/src/commands/diff/engine/classify/rules/ref.ts +++ b/packages/core/src/diff/rules/ref.ts @@ -1,4 +1,4 @@ -import { breaking, type DiffRule } from '../../types.js'; +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. diff --git a/packages/cli/src/commands/diff/engine/classify/rules/request-body.ts b/packages/core/src/diff/rules/request-body.ts similarity index 87% rename from packages/cli/src/commands/diff/engine/classify/rules/request-body.ts rename to packages/core/src/diff/rules/request-body.ts index 0e57106aab..c85c9529bf 100644 --- a/packages/cli/src/commands/diff/engine/classify/rules/request-body.ts +++ b/packages/core/src/diff/rules/request-body.ts @@ -1,5 +1,5 @@ -import { becameTrue } from '../../predicates.js'; -import { breaking, type DiffRule } from '../../types.js'; +import { becameTrue } from '../predicates.js'; +import { breaking, type DiffRule } from '../types.js'; export const requestBodyBecameRequired: DiffRule = { id: 'request-body-became-required', diff --git a/packages/cli/src/commands/diff/engine/classify/rules/response.ts b/packages/core/src/diff/rules/response.ts similarity index 95% rename from packages/cli/src/commands/diff/engine/classify/rules/response.ts rename to packages/core/src/diff/rules/response.ts index a3ae7eeb03..1340ed9721 100644 --- a/packages/cli/src/commands/diff/engine/classify/rules/response.ts +++ b/packages/core/src/diff/rules/response.ts @@ -1,4 +1,4 @@ -import { breaking, type DiffRule } from '../../types.js'; +import { breaking, type DiffRule } from '../types.js'; export const responseRemoved: DiffRule = { id: 'response-removed', diff --git a/packages/cli/src/commands/diff/engine/classify/rules/schema.ts b/packages/core/src/diff/rules/schema.ts similarity index 99% rename from packages/cli/src/commands/diff/engine/classify/rules/schema.ts rename to packages/core/src/diff/rules/schema.ts index 6b7a7958d8..bb37c40b24 100644 --- a/packages/cli/src/commands/diff/engine/classify/rules/schema.ts +++ b/packages/core/src/diff/rules/schema.ts @@ -6,8 +6,8 @@ import { isTypeSetWidened, missingItems, type ConstraintDirection, -} from '../../predicates.js'; -import { breaking, type DiffRule, type Polarity, type Verdict } from '../../types.js'; +} 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 diff --git a/packages/cli/src/commands/diff/engine/classify/rules/security.ts b/packages/core/src/diff/rules/security.ts similarity index 97% rename from packages/cli/src/commands/diff/engine/classify/rules/security.ts rename to packages/core/src/diff/rules/security.ts index b27ccd4518..3cd0c5ed4e 100644 --- a/packages/cli/src/commands/diff/engine/classify/rules/security.ts +++ b/packages/core/src/diff/rules/security.ts @@ -1,5 +1,5 @@ -import { addedItems } from '../../predicates.js'; -import { breaking, type DiffRule, type RawChange, type RuleContext } from '../../types.js'; +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. diff --git a/packages/cli/src/commands/diff/engine/classify/rules/server.ts b/packages/core/src/diff/rules/server.ts similarity index 89% rename from packages/cli/src/commands/diff/engine/classify/rules/server.ts rename to packages/core/src/diff/rules/server.ts index b47f8880ae..05eff95d5d 100644 --- a/packages/cli/src/commands/diff/engine/classify/rules/server.ts +++ b/packages/core/src/diff/rules/server.ts @@ -1,4 +1,4 @@ -import { breaking, type DiffRule } from '../../types.js'; +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. diff --git a/packages/cli/src/commands/diff/engine/types.ts b/packages/core/src/diff/types.ts similarity index 74% rename from packages/cli/src/commands/diff/engine/types.ts rename to packages/core/src/diff/types.ts index 59006e847a..b8b8212856 100644 --- a/packages/cli/src/commands/diff/engine/types.ts +++ b/packages/core/src/diff/types.ts @@ -1,20 +1,10 @@ -import type { SpecVersion } from '@redocly/openapi-core'; +import type { NodeEntry } from '../node-map/types.js'; +import type { SpecVersion } from '../oas-types.js'; 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() diff --git a/packages/cli/src/commands/diff/engine/classify/usage.ts b/packages/core/src/diff/usage.ts similarity index 68% rename from packages/cli/src/commands/diff/engine/classify/usage.ts rename to packages/core/src/diff/usage.ts index 707240367e..35df1f487a 100644 --- a/packages/cli/src/commands/diff/engine/classify/usage.ts +++ b/packages/core/src/diff/usage.ts @@ -1,17 +1,5 @@ -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; -} +import { getComponentRoot, type NodeLookup } from '../node-map/chain.js'; +import type { Polarity } from './types.js'; export function mergePolarity(a: Polarity, b: Polarity): Polarity { if (a === b) return a; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 361ee28200..f19fb8461d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -129,6 +129,10 @@ export { bundleDocument, type ComponentNamesStrategy } from './bundle/bundle-doc export { mapTypeToComponent } from './bundle/bundle-visitor.js'; export { type Assertions, type Assertion } from './rules/common/assertions/index.js'; export { logger, type LoggerInterface } from './logger.js'; +export { diffDocuments, DiffError } from './diff/index.js'; +export type { Change, DiffResult, DiffSummary } from './diff/types.js'; +export { diffReportFormats, type DiffReportFormat } from './diff/format/index.js'; +export { breakingChangesToProblems } from './diff/format/problems.js'; export { HandledError, AbortFlowError } from './utils/error.js'; export { isSupportedExtension } from './utils/is-supported-extension.js'; export { isBrowser } from './env.js'; diff --git a/packages/cli/src/commands/diff/__tests__/collect.test.ts b/packages/core/src/node-map/__tests__/collect.test.ts similarity index 91% rename from packages/cli/src/commands/diff/__tests__/collect.test.ts rename to packages/core/src/node-map/__tests__/collect.test.ts index 505e52e272..4aea25180d 100644 --- a/packages/cli/src/commands/diff/__tests__/collect.test.ts +++ b/packages/core/src/node-map/__tests__/collect.test.ts @@ -1,21 +1,20 @@ -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'; +import { createConfig } from '../../config/index.js'; +import { detectSpec } from '../../detect-spec.js'; +import { getIdentityKey } from '../../diff/identity.js'; +import { getTypes } from '../../oas-types.js'; +import { makeDocumentFromString } from '../../resolve.js'; +import { normalizeTypes } from '../../types/index.js'; +import { collectNodeMap } from '../collect.js'; +import type { NodeEntry } from '../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 }); + return collectNodeMap({ document, types, specVersion, identityOf: getIdentityKey }); } /** `stable pointer (real pointer) TypeName` per node — the map the comparison runs on. */ @@ -25,7 +24,7 @@ function tree(entries: Map): string { .join('\n'); } -describe('collectDocumentMap', () => { +describe('collectNodeMap', () => { it('keys every node by a stable pointer while remembering the real one', async () => { const { entries } = await collect(outdent` openapi: 3.1.0 diff --git a/packages/core/src/node-map/chain.ts b/packages/core/src/node-map/chain.ts new file mode 100644 index 0000000000..9c22e41c61 --- /dev/null +++ b/packages/core/src/node-map/chain.ts @@ -0,0 +1,32 @@ +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; +} + +/** + * 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; +} diff --git a/packages/cli/src/commands/diff/engine/collect.ts b/packages/core/src/node-map/collect.ts similarity index 78% rename from packages/cli/src/commands/diff/engine/collect.ts rename to packages/core/src/node-map/collect.ts index 714f832081..12ca3fd2f5 100644 --- a/packages/cli/src/commands/diff/engine/collect.ts +++ b/packages/core/src/node-map/collect.ts @@ -1,34 +1,22 @@ -import { - isPlainObject, - isRef, - normalizeVisitors, - walkDocument, - type Config, - type Document, - type NormalizedNodeType, - type SpecVersion, - type UserContext, - type WalkContext, -} from '@redocly/openapi-core'; +import type { SpecVersion } from '../oas-types.js'; +import { isRef } from '../ref-utils.js'; +import type { Document } from '../resolve.js'; +import type { NormalizedNodeType } from '../types/index.js'; +import { isPlainObject } from '../utils/is-plain-object.js'; +import { isScalar, isScalarArray } from '../utils/is-scalar.js'; +import { normalizeVisitors } from '../visitors.js'; +import { walkDocument, type UserContext, type WalkContext } from '../walk.js'; +import type { NodeEntry, NodeMap, UsageEdge } from './types.js'; -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: { +export function collectNodeMap(opts: { document: Document; types: Record; specVersion: SpecVersion; - config: Config; -}): CollectedDocument { - const { document, types, specVersion, config } = opts; + identityOf: (typeName: string, node: unknown) => string | undefined; +}): NodeMap { + const { document, types, specVersion, identityOf } = opts; const entries = new Map(); - const usageEdges: Array<{ site: string; target: string }> = []; + const usageEdges: UsageEdge[] = []; // realPointer → stablePointer, filled top-down (walk is pre-order) const stableByReal = new Map(); const collisionCounts = new Map(); @@ -45,7 +33,7 @@ export function collectDocumentMap(opts: { let stableSegment = segment; if (Array.isArray(ctx.parent)) { - const identity = getIdentityKey(ctx.type.name, node); + const identity = identityOf(ctx.type.name, node); if (identity !== undefined) stableSegment = identity; } @@ -94,7 +82,7 @@ export function collectDocumentMap(opts: { [{ severity: 'warn', ruleId: 'diff-collect', visitor }], types ); - const ctx: WalkContext = { problems: [], specVersion, config, visitorsData: {} }; + const ctx: WalkContext = { problems: [], specVersion, visitorsData: {} }; walkDocument({ document, diff --git a/packages/core/src/node-map/types.ts b/packages/core/src/node-map/types.ts new file mode 100644 index 0000000000..fd38b6bbd9 --- /dev/null +++ b/packages/core/src/node-map/types.ts @@ -0,0 +1,20 @@ +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 UsageEdge { + site: string; + target: string; +} + +export interface NodeMap { + entries: Map; + usageEdges: UsageEdge[]; +} diff --git a/packages/core/src/utils/is-scalar.ts b/packages/core/src/utils/is-scalar.ts new file mode 100644 index 0000000000..d5a67ca7e3 --- /dev/null +++ b/packages/core/src/utils/is-scalar.ts @@ -0,0 +1,14 @@ +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); +} From aecb4268b23efa892b3b9fc66c4be62c42027580 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:13:25 +0300 Subject: [PATCH 23/57] refactor(core): index nodes by key with their walker location and comparable properties --- ...predicates.test.ts => constraints.test.ts} | 4 +- packages/core/src/diff/__tests__/tree.ts | 35 +-- packages/core/src/diff/align-paths.ts | 7 +- .../diff/{predicates.ts => constraints.ts} | 0 packages/core/src/diff/identity.ts | 40 ++-- packages/core/src/diff/index.ts | 4 +- packages/core/src/diff/rules/parameter.ts | 2 +- packages/core/src/diff/rules/request-body.ts | 2 +- packages/core/src/diff/rules/schema.ts | 2 +- packages/core/src/diff/rules/security.ts | 2 +- .../src/node-map/__tests__/collect.test.ts | 224 +++++++----------- packages/core/src/node-map/chain.ts | 24 +- packages/core/src/node-map/collect.ts | 91 ++++--- packages/core/src/node-map/types.ts | 43 +++- 14 files changed, 232 insertions(+), 248 deletions(-) rename packages/core/src/diff/__tests__/{predicates.test.ts => constraints.test.ts} (98%) rename packages/core/src/diff/{predicates.ts => constraints.ts} (100%) diff --git a/packages/core/src/diff/__tests__/predicates.test.ts b/packages/core/src/diff/__tests__/constraints.test.ts similarity index 98% rename from packages/core/src/diff/__tests__/predicates.test.ts rename to packages/core/src/diff/__tests__/constraints.test.ts index 7a75236a5a..af413a3f46 100644 --- a/packages/core/src/diff/__tests__/predicates.test.ts +++ b/packages/core/src/diff/__tests__/constraints.test.ts @@ -4,9 +4,9 @@ import { effectiveTypes, isTypeSetNarrowed, isTypeSetWidened, -} from '../predicates.js'; +} from '../constraints.js'; -describe('diff predicates', () => { +describe('diff constraints', () => { 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). diff --git a/packages/core/src/diff/__tests__/tree.ts b/packages/core/src/diff/__tests__/tree.ts index d02cdeb988..0a877ba5ed 100644 --- a/packages/core/src/diff/__tests__/tree.ts +++ b/packages/core/src/diff/__tests__/tree.ts @@ -1,29 +1,30 @@ import type { NodeEntry } from '../../node-map/types.js'; +import { Location } from '../../ref-utils.js'; +import { Source } from '../../resolve.js'; + +const source = new Source('tree.yaml', ''); /** - * 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. + * Builds a lookup over a spelled-out node tree for tests that need real node types rather + * than bare keys. Each line is `key typeName`, optionally followed by `name=value` + * properties; a node's parent is the closest preceding line whose key is a prefix of it — + * which is what `collectNodeMap` records when it walks a document. */ export function treeOf(nodes: string): Map { const entries = new Map(); - const pointers: string[] = []; + const keys: 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), + const [key, typeName, ...assignments] = line.trim().split(/\s+/); + const parentKey = + [...keys].reverse().find((candidate) => key.startsWith(`${candidate}/`)) ?? null; + keys.push(key); + entries.set(key, { + key, + parentKey, + location: new Location(source, key), typeName, - scalars: Object.fromEntries(assignments.map((pair) => pair.split('='))), - refs: {}, + properties: Object.fromEntries(assignments.map((pair) => pair.split('='))), raw: {}, }); } diff --git a/packages/core/src/diff/align-paths.ts b/packages/core/src/diff/align-paths.ts index a53d2ffeef..fe1b77ff92 100644 --- a/packages/core/src/diff/align-paths.ts +++ b/packages/core/src/diff/align-paths.ts @@ -1,6 +1,5 @@ import type { NodeEntry } from '../node-map/types.js'; -import { unescapePointerFragment } from '../ref-utils.js'; -import { escapeIdentityKeyPart } from './identity.js'; +import { escapePointerFragment, unescapePointerFragment } from '../ref-utils.js'; export interface PathRename { baseTemplate: string; @@ -92,8 +91,8 @@ export function alignRenamedPaths( // pre-escaped the way node-identity builds '{path:}' segments paramMap: new Map( paramNames(rename.revisionTemplate).map((name, position) => [ - escapeIdentityKeyPart(name), - escapeIdentityKeyPart(baseParams[position]), + escapePointerFragment(name), + escapePointerFragment(baseParams[position]), ]) ), }; diff --git a/packages/core/src/diff/predicates.ts b/packages/core/src/diff/constraints.ts similarity index 100% rename from packages/core/src/diff/predicates.ts rename to packages/core/src/diff/constraints.ts diff --git a/packages/core/src/diff/identity.ts b/packages/core/src/diff/identity.ts index 32d5eec1a0..a58dfeb353 100644 --- a/packages/core/src/diff/identity.ts +++ b/packages/core/src/diff/identity.ts @@ -1,26 +1,24 @@ -import { isPlainObject } from '../utils/is-plain-object.js'; +import type { IdentityFn } from '../node-map/types.js'; +import { escapePointerFragment } from '../ref-utils.js'; -// 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 ListItemIdentity = (node: Record) => string | undefined; -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)}}` +// Identity segments for list items that have a natural identity; everything else keeps +// its position. Each part is pointer-escaped so a `/` inside a name cannot forge a level. +const LIST_ITEM_IDENTITIES: Record = { + Parameter: (parameter) => + typeof parameter.in === 'string' && typeof parameter.name === 'string' + ? `{${escapePointerFragment(parameter.in)}:${escapePointerFragment(parameter.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('+')}}`, + Server: (server) => + typeof server.url === 'string' ? `{${escapePointerFragment(server.url)}}` : undefined, + Tag: (tag) => (typeof tag.name === 'string' ? `{${escapePointerFragment(tag.name)}}` : undefined), + SecurityRequirement: (requirement) => + `{${Object.keys(requirement).sort().map(escapePointerFragment).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); -} +export const identityOf: IdentityFn = (node, { typeName, parent }) => { + if (!Array.isArray(parent)) return undefined; + const segment = LIST_ITEM_IDENTITIES[typeName]?.(node); + return segment === undefined ? undefined : { segment }; +}; diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index 50e7f46d0c..70889e2063 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -7,7 +7,7 @@ import { normalizeTypes } from '../types/index.js'; import { alignRenamedPaths, type PathRename } from './align-paths.js'; import { compareMaps } from './compare.js'; import { classifyChanges } from './detect.js'; -import { getIdentityKey } from './identity.js'; +import { identityOf } from './identity.js'; import { locateChanges } from './locate.js'; import type { DiffResult, DiffSummary, RawChange } from './types.js'; import { UsageIndex } from './usage.js'; @@ -48,7 +48,7 @@ export function diffDocuments(opts: { document, types: normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config), specVersion, - identityOf: getIdentityKey, + identityOf, }); const baseCollected = collect(base, baseVersion); diff --git a/packages/core/src/diff/rules/parameter.ts b/packages/core/src/diff/rules/parameter.ts index c769cbf607..f4f0682918 100644 --- a/packages/core/src/diff/rules/parameter.ts +++ b/packages/core/src/diff/rules/parameter.ts @@ -1,5 +1,5 @@ import { isPlainObject } from '../../utils/is-plain-object.js'; -import { becameTrue } from '../predicates.js'; +import { becameTrue } from '../constraints.js'; import { breaking, type DiffRule } from '../types.js'; // Registered for both `Parameter` and `ParameterList`: the last parameter of an diff --git a/packages/core/src/diff/rules/request-body.ts b/packages/core/src/diff/rules/request-body.ts index c85c9529bf..3ee708cff0 100644 --- a/packages/core/src/diff/rules/request-body.ts +++ b/packages/core/src/diff/rules/request-body.ts @@ -1,4 +1,4 @@ -import { becameTrue } from '../predicates.js'; +import { becameTrue } from '../constraints.js'; import { breaking, type DiffRule } from '../types.js'; export const requestBodyBecameRequired: DiffRule = { diff --git a/packages/core/src/diff/rules/schema.ts b/packages/core/src/diff/rules/schema.ts index bb37c40b24..bbe80db205 100644 --- a/packages/core/src/diff/rules/schema.ts +++ b/packages/core/src/diff/rules/schema.ts @@ -6,7 +6,7 @@ import { isTypeSetWidened, missingItems, type ConstraintDirection, -} from '../predicates.js'; +} from '../constraints.js'; import { breaking, type DiffRule, type Polarity, type Verdict } from '../types.js'; /** diff --git a/packages/core/src/diff/rules/security.ts b/packages/core/src/diff/rules/security.ts index 3cd0c5ed4e..2971497fe5 100644 --- a/packages/core/src/diff/rules/security.ts +++ b/packages/core/src/diff/rules/security.ts @@ -1,4 +1,4 @@ -import { addedItems } from '../predicates.js'; +import { addedItems } from '../constraints.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 diff --git a/packages/core/src/node-map/__tests__/collect.test.ts b/packages/core/src/node-map/__tests__/collect.test.ts index 4aea25180d..6a8256bc77 100644 --- a/packages/core/src/node-map/__tests__/collect.test.ts +++ b/packages/core/src/node-map/__tests__/collect.test.ts @@ -2,50 +2,55 @@ import { outdent } from 'outdent'; import { createConfig } from '../../config/index.js'; import { detectSpec } from '../../detect-spec.js'; -import { getIdentityKey } from '../../diff/identity.js'; import { getTypes } from '../../oas-types.js'; import { makeDocumentFromString } from '../../resolve.js'; import { normalizeTypes } from '../../types/index.js'; import { collectNodeMap } from '../collect.js'; -import type { NodeEntry } from '../types.js'; +import type { IdentityFn, NodeEntry } from '../types.js'; -async function collect(yaml: string) { - const document = makeDocumentFromString(yaml, ''); +const noIdentity: IdentityFn = () => undefined; + +async function collect(yaml: string, identityOf: IdentityFn = noIdentity) { + const document = makeDocumentFromString(yaml, 'api.yaml'); const config = await createConfig({}); const specVersion = detectSpec(document.parsed); const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - return collectNodeMap({ document, types, specVersion, identityOf: getIdentityKey }); + return collectNodeMap({ document, types, specVersion, identityOf }); } -/** `stable pointer (real pointer) TypeName` per node — the map the comparison runs on. */ +/** `key (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}`) + .map((entry) => `${entry.key} (${entry.location.pointer}) ${entry.typeName}`) .join('\n'); } +const PETSTORE = outdent` + openapi: 3.1.0 + info: { title: Pets, version: '1.0' } + paths: + /pets: + get: + parameters: + - { name: limit, in: query, schema: { type: integer, maximum: 100 } } + responses: + '200': + content: + application/json: + schema: { $ref: '#/components/schemas/Pet' } + components: + schemas: + Pet: + type: object + required: [id] + properties: + id: { type: string } +`; + describe('collectNodeMap', () => { - 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 } - `); + it('keys every object and array node hierarchically and keeps the walker location on it', async () => { + const { entries } = await collect(PETSTORE); - // 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 @@ -53,120 +58,75 @@ describe('collectNodeMap', () => { #/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/parameters/0 (#/paths/~1pets/get/parameters/0) Parameter + #/paths/~1pets/get/parameters/0/schema (#/paths/~1pets/get/parameters/0/schema) Schema #/paths/~1pets/get/responses (#/paths/~1pets/get/responses) Responses - #/paths/~1pets/get/responses/200 (#/paths/~1pets/get/responses/200) Response" + #/paths/~1pets/get/responses/200 (#/paths/~1pets/get/responses/200) Response + #/paths/~1pets/get/responses/200/content (#/paths/~1pets/get/responses/200/content) MediaTypesMap + #/paths/~1pets/get/responses/200/content/application~1json (#/paths/~1pets/get/responses/200/content/application~1json) MediaType + #/components (#/components) Components + #/components/schemas (#/components/schemas) NamedSchemas + #/components/schemas/Pet (#/components/schemas/Pet) Schema + #/components/schemas/Pet/properties (#/components/schemas/Pet/properties) SchemaProperties + #/components/schemas/Pet/properties/id (#/components/schemas/Pet/properties/id) Schema" `); - expect(entries.get('#/paths/~1pets/get/parameters/{query:limit}')!.scalars).toMatchObject({ - name: 'limit', - in: 'query', - required: true, - }); + expect(entries.get('#/info')?.location.source.absoluteRef).toBe('api.yaml'); + expect(entries.get('#/info')?.parentKey).toBe('#/'); }); - 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 } - `); + it('keeps scalars, scalar arrays and $refs as properties and leaves nested nodes out', async () => { + const { entries, usageEdges } = await collect(PETSTORE); - 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', + const parameter = entries.get('#/paths/~1pets/get/parameters/0'); + expect(parameter?.properties).toEqual({ name: 'limit', in: 'query' }); + expect(parameter?.raw).toEqual({ + name: 'limit', + in: 'query', + schema: { type: 'integer', maximum: 100 }, }); + expect(entries.get('#/components/schemas/Pet')?.properties).toEqual({ + type: 'object', + required: ['id'], + }); + const mediaType = entries.get('#/paths/~1pets/get/responses/200/content/application~1json'); + expect(mediaType?.properties).toEqual({ schema: { $ref: '#/components/schemas/Pet' } }); + expect(usageEdges).toEqual([ + { + 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' + it('lets the identity replace the walker key, add properties, and suffixes a duplicate identity', async () => { + const byName: IdentityFn = (node, { typeName }) => + typeName === 'Parameter' && typeof node.name === 'string' + ? { segment: `{${node.name}}`, properties: { identified: true } } + : undefined; + const { entries } = await collect( + outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + paths: + /p: + get: + parameters: + - { name: a, in: query } + - { name: a, in: header } + responses: {} + `, + byName ); - 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); + const keys = [...entries.keys()].filter((key) => key.includes('parameters/')); + expect(keys).toEqual(['#/paths/~1p/get/parameters/{a}', '#/paths/~1p/get/parameters/{a}#2']); + expect(entries.get('#/paths/~1p/get/parameters/{a}')?.properties).toEqual({ + name: 'a', + in: 'query', + identified: true, + }); + expect(entries.get('#/paths/~1p/get/parameters/{a}#2')?.location.pointer).toBe( + '#/paths/~1p/get/parameters/1' + ); }); }); diff --git a/packages/core/src/node-map/chain.ts b/packages/core/src/node-map/chain.ts index 9c22e41c61..99c97bc986 100644 --- a/packages/core/src/node-map/chain.ts +++ b/packages/core/src/node-map/chain.ts @@ -1,32 +1,32 @@ 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; +export type NodeLookup = (key: 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[] { +/** The node's ancestors and itself, root first, as far as the map can resolve them. */ +export function ancestorChain(key: string, lookup: NodeLookup): NodeEntry[] { const chain: NodeEntry[] = []; const seen = new Set(); - for (let current: string | null = pointer; current && !seen.has(current); ) { + for (let current: string | null = key; current && !seen.has(current); ) { seen.add(current); const entry = lookup(current); if (!entry) break; chain.unshift(entry); - current = entry.parentPointer; + current = entry.parentKey; } return chain; } /** - * 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. + * The key 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); +export function getComponentRoot(key: string, lookup: NodeLookup): string | undefined { + const chain = ancestorChain(key, lookup); const componentsIndex = chain.findIndex((entry) => entry.typeName === 'Components'); - return componentsIndex === -1 ? undefined : chain[componentsIndex + 2]?.pointer; + return componentsIndex === -1 ? undefined : chain[componentsIndex + 2]?.key; } diff --git a/packages/core/src/node-map/collect.ts b/packages/core/src/node-map/collect.ts index 12ca3fd2f5..5ebf472b78 100644 --- a/packages/core/src/node-map/collect.ts +++ b/packages/core/src/node-map/collect.ts @@ -6,19 +6,20 @@ import { isPlainObject } from '../utils/is-plain-object.js'; import { isScalar, isScalarArray } from '../utils/is-scalar.js'; import { normalizeVisitors } from '../visitors.js'; import { walkDocument, type UserContext, type WalkContext } from '../walk.js'; -import type { NodeEntry, NodeMap, UsageEdge } from './types.js'; +import { ancestorChain } from './chain.js'; +import type { IdentityFn, NodeEntry, NodeMap, UsageEdge } from './types.js'; export function collectNodeMap(opts: { document: Document; types: Record; specVersion: SpecVersion; - identityOf: (typeName: string, node: unknown) => string | undefined; + identityOf: IdentityFn; }): NodeMap { const { document, types, specVersion, identityOf } = opts; const entries = new Map(); const usageEdges: UsageEdge[] = []; - // realPointer → stablePointer, filled top-down (walk is pre-order) - const stableByReal = new Map(); + // real pointer → key, filled top-down (the walk is pre-order) + const keyByPointer = new Map(); const collisionCounts = new Map(); const visitor = { @@ -26,52 +27,50 @@ export function collectNodeMap(opts: { 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); + const { parentPointer, segment } = splitPointer(ctx.location.pointer); + const parentKey = + parentPointer === null ? null : (keyByPointer.get(parentPointer) ?? parentPointer); + const ancestors = + parentKey === null ? [] : ancestorChain(parentKey, (key) => entries.get(key)); + const identity = isPlainObject(node) + ? identityOf(node, { + typeName: ctx.type.name, + key: ctx.key, + parent: ctx.parent, + ancestors, + }) + : undefined; - let stableSegment = segment; - if (Array.isArray(ctx.parent)) { - const identity = identityOf(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 + // The root's own key 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}`; + const prefix = parentKey === '#/' ? '#' : parentKey; + let key = prefix === null ? segment : `${prefix}/${identity?.segment ?? segment}`; + if (entries.has(key)) { + const occurrence = (collisionCounts.get(key) ?? 1) + 1; + collisionCounts.set(key, occurrence); + key = `${key}#${occurrence}`; } - stableByReal.set(realPointer, pointer); + keyByPointer.set(ctx.location.pointer, key); - const scalars: Record = {}; - const refs: Record = {}; + const properties: Record = {}; if (isPlainObject(node)) { - for (const [prop, value] of Object.entries(node)) { + for (const [name, 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; + // The site is the node holding the reference: a `$ref` is not a node of its own. + usageEdges.push({ site: key, target: value.$ref }); + } + if (isRef(value) || isScalar(value) || isScalarArray(value)) { + properties[name] = value; } } } - entries.set(pointer, { - pointer, - realPointer, - parentPointer: stableParent, - keyInParent: ctx.key, + entries.set(key, { + key, + parentKey, + location: ctx.location, typeName: ctx.type.name, - scalars, - refs, + properties: { ...properties, ...identity?.properties }, raw: node, }); }, @@ -79,7 +78,7 @@ export function collectNodeMap(opts: { }; const normalizedVisitors = normalizeVisitors( - [{ severity: 'warn', ruleId: 'diff-collect', visitor }], + [{ severity: 'warn', ruleId: 'node-map', visitor }], types ); const ctx: WalkContext = { problems: [], specVersion, visitorsData: {} }; @@ -88,8 +87,8 @@ export function collectNodeMap(opts: { 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). + // Empty map: `$ref` nodes fail to resolve and are NOT traversed — a reference + // is recorded as a property of the node holding it. resolvedRefMap: new Map(), ctx, }); @@ -97,11 +96,11 @@ export function collectNodeMap(opts: { return { entries, usageEdges }; } -function splitPointer(pointer: string): { parentReal: string | null; segment: string } { +function splitPointer(pointer: string): { parentPointer: string | null; segment: string } { if (pointer === '#/' || pointer === '#') { - return { parentReal: null, segment: pointer }; + return { parentPointer: null, segment: pointer }; } const lastSlash = pointer.lastIndexOf('/'); - const parentReal = lastSlash <= 1 ? '#/' : pointer.slice(0, lastSlash); - return { parentReal, segment: pointer.slice(lastSlash + 1) }; + const parentPointer = lastSlash <= 1 ? '#/' : pointer.slice(0, lastSlash); + return { parentPointer, segment: pointer.slice(lastSlash + 1) }; } diff --git a/packages/core/src/node-map/types.ts b/packages/core/src/node-map/types.ts index fd38b6bbd9..13d09259a7 100644 --- a/packages/core/src/node-map/types.ts +++ b/packages/core/src/node-map/types.ts @@ -1,16 +1,22 @@ +import type { Location } from '../ref-utils.js'; + 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 + /** Stable matching key, the same for the same logical node on both sides; also the map key. */ + key: string; + parentKey: string | null; + /** Where the node really is: source and JSON Pointer, as the walker reports it. */ + location: Location; + typeName: string; + /** The node's own comparable values — scalars, scalar arrays, `$ref`s — verbatim. Nested objects are nodes of their own. */ + properties: Record; + /** The whole node, children included — the payload of an added or removed change. */ + raw: unknown; } export interface UsageEdge { + /** The key of the node holding the `$ref`. */ site: string; + /** The `$ref` string, which for a component is also its key. */ target: string; } @@ -18,3 +24,24 @@ export interface NodeMap { entries: Map; usageEdges: UsageEdge[]; } + +export interface IdentityContext { + typeName: string; + /** The walker's own key: an index in a list, a property name in a map. */ + key: string | number; + parent: unknown; + /** The ancestors already collected, root first — the walk is pre-order. */ + ancestors: NodeEntry[]; +} + +export interface NodeIdentity { + /** Replaces the walker's key as the last segment of the node's key. */ + segment: string; + /** What the key used to say and compare should still see — the path template, for one. */ + properties?: Record; +} + +export type IdentityFn = ( + node: Record, + context: IdentityContext +) => NodeIdentity | undefined; From 39511e165bfd8b85bba9389ed3b6f033e180c298 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:36:56 +0300 Subject: [PATCH 24/57] refactor(core): type diff changes as a discriminated union located by pointer --- .../core/src/diff/__tests__/compare.test.ts | 179 +++++------------- packages/core/src/diff/compare.ts | 71 ++++--- packages/core/src/diff/types.ts | 59 +++--- 3 files changed, 119 insertions(+), 190 deletions(-) diff --git a/packages/core/src/diff/__tests__/compare.test.ts b/packages/core/src/diff/__tests__/compare.test.ts index c8731cd84b..6c3301cab2 100644 --- a/packages/core/src/diff/__tests__/compare.test.ts +++ b/packages/core/src/diff/__tests__/compare.test.ts @@ -1,162 +1,87 @@ import type { NodeEntry } from '../../node-map/types.js'; +import { Location } from '../../ref-utils.js'; +import { Source } from '../../resolve.js'; import { compareMaps } from '../compare.js'; -function entry(partial: Partial & { pointer: string }): NodeEntry { +const source = new Source('api.yaml', ''); + +function entry(partial: Partial & { key: string }): NodeEntry { return { - realPointer: partial.pointer, - parentPointer: null, - keyInParent: '', + parentKey: null, + location: new Location(source, partial.key), typeName: 'Schema', - scalars: {}, - refs: {}, - raw: {}, + properties: partial.properties ?? {}, + raw: partial.raw ?? partial.properties ?? {}, ...partial, }; } function toMap(entries: NodeEntry[]): Map { - return new Map(entries.map((entry) => [entry.pointer, entry])); + return new Map(entries.map((item) => [item.key, item])); +} + +/** `kind key · property base-pointer → revision-pointer` per change. */ +function summarize(changes: ReturnType): string[] { + return changes.map((change) => { + const property = change.kind === 'modified' ? ` · ${change.property}` : ''; + const base = change.kind === 'added' ? '-' : change.base.location.pointer; + const revision = change.kind === 'removed' ? '-' : change.revision.location.pointer; + return `${change.kind} ${change.key}${property} ${base} → ${revision}`; + }); } 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' } }), - ]); + it('emits one modified change per differing property, located at the escaped property pointer', () => { + const base = toMap([entry({ key: '#/a', properties: { type: 'integer', 'x/y': 1 } })]); + const revision = toMap([entry({ key: '#/a', properties: { type: 'number', 'x/y': 2 } })]); - 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", - }, - ] - `); + expect(summarize(compareMaps(base, revision))).toEqual([ + 'modified #/a · type #/a/type → #/a/type', + 'modified #/a · x/y #/a/x~1y → #/a/x~1y', + ]); }); - it('collapses a removed subtree into one change at its root', () => { - const shared = entry({ pointer: '#/paths', typeName: 'PathsMap' }); + it('collapses a removed subtree into one change at its root, carrying the raw node', () => { const base = toMap([ - shared, - entry({ - pointer: '#/paths/~1pets', - parentPointer: '#/paths', - typeName: 'PathItem', - raw: { get: {} }, - }), - entry({ - pointer: '#/paths/~1pets/get', - parentPointer: '#/paths/~1pets', - typeName: 'Operation', - }), + entry({ key: '#/a', raw: { b: { c: 1 } } }), + entry({ key: '#/a/b', parentKey: '#/a', raw: { c: 1 } }), ]); - expect(compareMaps(base, toMap([shared]))).toMatchInlineSnapshot(` - [ - { - "base": { - "pointer": "#/paths/~1pets", - "value": { - "get": {}, - }, - }, - "kind": "removed", - "pointer": "#/paths/~1pets", - "typeName": "PathItem", - }, - ] - `); + const [change] = compareMaps(base, new Map()); + + expect(summarize([change])).toEqual(['removed #/a #/a → -']); + expect(change.kind === 'removed' && change.base.value).toEqual({ b: { c: 1 } }); + expect(compareMaps(base, new Map())).toHaveLength(1); }); - it('treats a node whose type changed as a removed+added pair and suppresses its subtree', () => { + it('treats a node whose type changed as a removed+added pair and skips its subtree', () => { const base = toMap([ - entry({ pointer: '#/x', typeName: 'Schema', raw: { type: 'object' } }), - entry({ pointer: '#/x/properties/a', parentPointer: '#/x', scalars: { type: 'string' } }), + entry({ key: '#/a', typeName: 'Schema' }), + entry({ key: '#/a/b', parentKey: '#/a' }), ]); const revision = toMap([ - entry({ pointer: '#/x', typeName: 'Example', raw: { value: 1 } }), - entry({ pointer: '#/x/properties/a', parentPointer: '#/x', scalars: { type: 'number' } }), + entry({ key: '#/a', typeName: 'Parameter' }), + entry({ key: '#/a/b', parentKey: '#/a' }), ]); - 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", - }, - ] - `); + expect(summarize(compareMaps(base, revision))).toEqual([ + 'removed #/a #/a → -', + 'added #/a - → #/a', + ]); }); 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", - }, - ] - `); + const base = toMap([entry({ key: '#/a', properties: { schema: { $ref: '#/A' } } })]); + const revision = toMap([entry({ key: '#/a', properties: { schema: { $ref: '#/B' } } })]); + + const [change] = compareMaps(base, revision); + + expect(change.kind === 'modified' && change.property).toBe('schema'); + expect(change.kind === 'modified' && change.revision.value).toEqual({ $ref: '#/B' }); }); it('emits nothing when the two maps are identical', () => { - const entries = [entry({ pointer: '#/a', scalars: { type: 'string' } })]; + const entries = [entry({ key: '#/a', properties: { enum: ['x', 'y'] } })]; expect(compareMaps(toMap(entries), toMap(entries))).toEqual([]); }); diff --git a/packages/core/src/diff/compare.ts b/packages/core/src/diff/compare.ts index a3d954b807..ffe44c4e24 100644 --- a/packages/core/src/diff/compare.ts +++ b/packages/core/src/diff/compare.ts @@ -1,30 +1,31 @@ import type { NodeEntry } from '../node-map/types.js'; import { dequal } from '../utils/dequal.js'; -import type { RawChange } from './types.js'; +import { isPlainObject } from '../utils/is-plain-object.js'; +import type { Change } from './types.js'; -function removalOf(pointer: string, entry: NodeEntry): RawChange { +function removalOf(entry: NodeEntry): Change { return { - pointer, + key: entry.key, kind: 'removed', typeName: entry.typeName, - base: { pointer: entry.realPointer, value: entry.raw }, + base: { location: entry.location, value: entry.raw }, }; } -function additionOf(pointer: string, entry: NodeEntry): RawChange { +function additionOf(entry: NodeEntry): Change { return { - pointer, + key: entry.key, kind: 'added', typeName: entry.typeName, - revision: { pointer: entry.realPointer, value: entry.raw }, + revision: { location: entry.location, value: entry.raw }, }; } export function compareMaps( base: Map, revision: Map -): RawChange[] { - const changes: RawChange[] = []; +): Change[] { + const changes: Change[] = []; const keys = new Set([...base.keys(), ...revision.keys()]); // Pass 1: boundary nodes — added roots, removed roots, replaced (typeName differs). @@ -40,15 +41,15 @@ export function compareMaps( const getEntry = (key: string) => base.get(key) ?? revision.get(key); const hasBoundaryAncestor = (key: string): boolean => { - let parent = getEntry(key)?.parentPointer ?? null; + let parent = getEntry(key)?.parentKey ?? null; while (parent !== null) { if (boundaries.has(parent)) return true; - parent = getEntry(parent)?.parentPointer ?? null; + parent = getEntry(parent)?.parentKey ?? null; } return false; }; - // Pass 2: emission, in deterministic pointer order. + // Pass 2: emission, in deterministic key order. for (const key of [...keys].sort()) { if (hasBoundaryAncestor(key)) continue; // implied by a reported ancestor const baseEntry = base.get(key); @@ -56,38 +57,36 @@ export function compareMaps( 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)); + if (baseEntry) changes.push(removalOf(baseEntry)); + if (revisionEntry) changes.push(additionOf(revisionEntry)); } else if (baseEntry.typeName !== revisionEntry.typeName) { - // replaced → a removed+added pair at the same pointer - changes.push(removalOf(key, baseEntry), additionOf(key, revisionEntry)); + // replaced → a removed+added pair at the same key + changes.push(removalOf(baseEntry), additionOf(revisionEntry)); } else { const properties = new Set([ - ...Object.keys(baseEntry.scalars), - ...Object.keys(baseEntry.refs), - ...Object.keys(revisionEntry.scalars), - ...Object.keys(revisionEntry.refs), + ...Object.keys(baseEntry.properties), + ...Object.keys(revisionEntry.properties), ]); 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 }, - }); - } + const before = baseEntry.properties[property]; + const after = revisionEntry.properties[property]; + if (dequal(before, after)) continue; + changes.push({ + key, + kind: 'modified', + typeName: baseEntry.typeName, + property, + base: { location: propertyLocation(baseEntry, property), value: before }, + revision: { location: propertyLocation(revisionEntry, property), value: after }, + }); } } } return changes; } + +function propertyLocation(entry: NodeEntry, property: string) { + const isOwnProperty = isPlainObject(entry.raw) && property in entry.raw; + return isOwnProperty ? entry.location.child([property]) : entry.location; +} diff --git a/packages/core/src/diff/types.ts b/packages/core/src/diff/types.ts index b8b8212856..1a7326515f 100644 --- a/packages/core/src/diff/types.ts +++ b/packages/core/src/diff/types.ts @@ -1,31 +1,40 @@ import type { NodeEntry } from '../node-map/types.js'; import type { SpecVersion } from '../oas-types.js'; +import type { Location } from '../ref-utils.js'; export type Compat = 'breaking' | 'non-breaking'; -export type ChangeKind = 'added' | 'removed' | 'changed'; - -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 LocatedNode { + location: Location; + value: unknown; } -export interface Change { - pointer: string; // stable node pointer — the change's identity - property?: string; // set for property-level changes - kind: ChangeKind; +interface ChangeBase { + key: string; 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 type Change = + | (ChangeBase & { kind: 'added'; revision: LocatedNode }) + | (ChangeBase & { kind: 'removed'; base: LocatedNode }) + | (ChangeBase & { + kind: 'modified'; + property: string; + base: LocatedNode; + revision: LocatedNode; + }); + +export function displaySide(change: Change): LocatedNode { + return change.kind === 'removed' ? change.base : change.revision; +} + +export interface ChangeVerdict { + ruleId: string; + compat: Compat; + message: string; +} + +export type JudgedChange = Change & { compat: Compat; verdicts: ChangeVerdict[] }; export interface DiffSummary { breaking: number; @@ -36,7 +45,7 @@ export interface DiffResult { version: '1'; specVersions: { base: SpecVersion; revision: SpecVersion }; summary: DiffSummary; - changes: Change[]; + changes: JudgedChange[]; } export interface Verdict { @@ -44,25 +53,21 @@ export interface Verdict { 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; + base: (key: string) => NodeEntry | undefined; + revision: (key: string) => NodeEntry | undefined; /** Either side, revision first — for reading a node's own type or its ancestors. */ - nodeAt: (pointer: string) => NodeEntry | undefined; + nodeAt: (key: string) => NodeEntry | undefined; } export interface DiffRule { id: string; description: string; - visit(change: RawChange, ctx: RuleContext): Verdict | undefined; + visit(change: Change, ctx: RuleContext): Verdict | undefined; } export type DiffRuleRegistry = Record; From b024c95e928139cc8a79638b2d24fc071c955da3 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:01:13 +0300 Subject: [PATCH 25/57] refactor(core): key paths by shape and path parameters by position in diff --- .../core/src/diff/__tests__/identity.test.ts | 90 ++++++++++++ packages/core/src/diff/align-paths.ts | 128 ------------------ packages/core/src/diff/identity.ts | 58 ++++++-- packages/core/src/diff/index.ts | 61 ++------- 4 files changed, 152 insertions(+), 185 deletions(-) create mode 100644 packages/core/src/diff/__tests__/identity.test.ts delete mode 100644 packages/core/src/diff/align-paths.ts diff --git a/packages/core/src/diff/__tests__/identity.test.ts b/packages/core/src/diff/__tests__/identity.test.ts new file mode 100644 index 0000000000..b36f60d20f --- /dev/null +++ b/packages/core/src/diff/__tests__/identity.test.ts @@ -0,0 +1,90 @@ +import { outdent } from 'outdent'; + +import { createConfig } from '../../config/index.js'; +import { detectSpec } from '../../detect-spec.js'; +import { collectNodeMap } from '../../node-map/collect.js'; +import { getTypes } from '../../oas-types.js'; +import { makeDocumentFromString } from '../../resolve.js'; +import { normalizeTypes } from '../../types/index.js'; +import { identityOf } from '../identity.js'; + +async function keysOf(yaml: string): Promise>> { + const document = makeDocumentFromString(yaml, 'api.yaml'); + const config = await createConfig({}); + const specVersion = detectSpec(document.parsed); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + const { entries } = collectNodeMap({ document, types, specVersion, identityOf }); + return new Map([...entries.values()].map((entry) => [entry.key, entry.properties])); +} + +describe('identityOf', () => { + it('keys a path by its shape and keeps the template as a property', async () => { + const keys = await keysOf(outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + paths: + /pets/{petId}/toys/{toyId}: + get: + parameters: + - { name: toyId, in: path, required: true, schema: { type: string } } + - { name: petId, in: path, required: true, schema: { type: string } } + - { name: q, in: query, schema: { type: string } } + responses: {} + `); + + expect(keys.get('#/paths/~1pets~1{0}~1toys~1{1}')).toEqual({ + path: '/pets/{petId}/toys/{toyId}', + }); + expect( + [...keys.keys()].filter((key) => key.includes('parameters/') && !key.endsWith('/schema')) + ).toEqual([ + '#/paths/~1pets~1{0}~1toys~1{1}/get/parameters/{path:1}', + '#/paths/~1pets~1{0}~1toys~1{1}/get/parameters/{path:0}', + '#/paths/~1pets~1{0}~1toys~1{1}/get/parameters/{query:q}', + ]); + }); + + it('keeps a callback key as it is and falls back to the parameter name there', async () => { + const keys = await keysOf(outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + paths: + /subscribe: + post: + responses: {} + callbacks: + onEvent: + '{$request.body#/url}': + post: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: {} + `); + + expect(keys.has('#/paths/~1subscribe/post/callbacks/onEvent/{$request.body#~1url}')).toBe(true); + expect( + keys.has( + '#/paths/~1subscribe/post/callbacks/onEvent/{$request.body#~1url}/post/parameters/{path:id}' + ) + ).toBe(true); + }); + + it('keys servers by url, tags by name, and security requirements by their scheme names', async () => { + const keys = await keysOf(outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + servers: + - url: https://a.example/v1 + tags: + - name: pets + security: + - oauth: [read] + apiKey: [] + paths: {} + `); + + expect(keys.has('#/servers/{https:~1~1a.example~1v1}')).toBe(true); + expect(keys.has('#/tags/{pets}')).toBe(true); + expect(keys.has('#/security/{apiKey+oauth}')).toBe(true); + }); +}); diff --git a/packages/core/src/diff/align-paths.ts b/packages/core/src/diff/align-paths.ts deleted file mode 100644 index fe1b77ff92..0000000000 --- a/packages/core/src/diff/align-paths.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { NodeEntry } from '../node-map/types.js'; -import { escapePointerFragment, unescapePointerFragment } from '../ref-utils.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) => [ - escapePointerFragment(name), - escapePointerFragment(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/core/src/diff/identity.ts b/packages/core/src/diff/identity.ts index a58dfeb353..1e4ecdcd4f 100644 --- a/packages/core/src/diff/identity.ts +++ b/packages/core/src/diff/identity.ts @@ -1,15 +1,44 @@ -import type { IdentityFn } from '../node-map/types.js'; +import type { IdentityFn, NodeEntry } from '../node-map/types.js'; import { escapePointerFragment } from '../ref-utils.js'; -type ListItemIdentity = (node: Record) => string | undefined; +const TEMPLATE_EXPRESSION = /\{[^}]+\}/g; + +// "Templated paths with the same hierarchy but different templated names MUST NOT exist +// as they are identical" (OpenAPI, Paths Object) — the shape is the identity, the names are not. +function templateShape(template: string): string { + let position = 0; + return template.replace(TEMPLATE_EXPRESSION, () => `{${position++}}`); +} + +function templateParameterNames(template: string): string[] { + return [...template.matchAll(TEMPLATE_EXPRESSION)].map(([expression]) => expression.slice(1, -1)); +} + +// "If `in` is 'path', the `name` field MUST correspond to a template expression occurring +// within the path" (OpenAPI, Parameter Object) — so the position of that expression is the +// identity. A name the template does not contain (an invalid document, or a callback whose +// key is a runtime expression) falls back to the name. +function pathParameterIdentity(name: string, ancestors: NodeEntry[]): string { + const template = ancestors.findLast((entry) => entry.typeName === 'PathItem')?.properties.path; + const position = + typeof template === 'string' ? templateParameterNames(template).indexOf(name) : -1; + return position === -1 ? `{path:${escapePointerFragment(name)}}` : `{path:${position}}`; +} + +type ListItemIdentity = ( + node: Record, + ancestors: NodeEntry[] +) => string | undefined; // Identity segments for list items that have a natural identity; everything else keeps // its position. Each part is pointer-escaped so a `/` inside a name cannot forge a level. const LIST_ITEM_IDENTITIES: Record = { - Parameter: (parameter) => - typeof parameter.in === 'string' && typeof parameter.name === 'string' - ? `{${escapePointerFragment(parameter.in)}:${escapePointerFragment(parameter.name)}}` - : undefined, + Parameter: (parameter, ancestors) => { + if (typeof parameter.in !== 'string' || typeof parameter.name !== 'string') return undefined; + return parameter.in === 'path' + ? pathParameterIdentity(parameter.name, ancestors) + : `{${escapePointerFragment(parameter.in)}:${escapePointerFragment(parameter.name)}}`; + }, Server: (server) => typeof server.url === 'string' ? `{${escapePointerFragment(server.url)}}` : undefined, Tag: (tag) => (typeof tag.name === 'string' ? `{${escapePointerFragment(tag.name)}}` : undefined), @@ -17,8 +46,17 @@ const LIST_ITEM_IDENTITIES: Record = { `{${Object.keys(requirement).sort().map(escapePointerFragment).join('+')}}`, }; -export const identityOf: IdentityFn = (node, { typeName, parent }) => { - if (!Array.isArray(parent)) return undefined; - const segment = LIST_ITEM_IDENTITIES[typeName]?.(node); - return segment === undefined ? undefined : { segment }; +export const identityOf: IdentityFn = (node, { typeName, key, parent, ancestors }) => { + if (Array.isArray(parent)) { + const segment = LIST_ITEM_IDENTITIES[typeName]?.(node, ancestors); + return segment === undefined ? undefined : { segment }; + } + if ( + typeName === 'PathItem' && + ancestors.at(-1)?.typeName === 'Paths' && + typeof key === 'string' + ) { + return { segment: escapePointerFragment(templateShape(key)), properties: { path: key } }; + } + return undefined; }; diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index 70889e2063..b2fb45d915 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -4,29 +4,14 @@ import { collectNodeMap } from '../node-map/collect.js'; import { getTypes, type SpecVersion } from '../oas-types.js'; import type { Document } from '../resolve.js'; import { normalizeTypes } from '../types/index.js'; -import { alignRenamedPaths, type PathRename } from './align-paths.js'; import { compareMaps } from './compare.js'; import { classifyChanges } from './detect.js'; import { identityOf } from './identity.js'; -import { locateChanges } from './locate.js'; -import type { DiffResult, DiffSummary, RawChange } from './types.js'; +import type { DiffResult, DiffSummary } from './types.js'; import { UsageIndex } from './usage.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; @@ -42,7 +27,7 @@ export function diffDocuments(opts: { ); } - // Each side is collected with ITS OWN type tree (spec §5.6). + // Each side is collected with its own type tree. const collect = (document: Document, specVersion: SpecVersion) => collectNodeMap({ document, @@ -51,38 +36,20 @@ export function diffDocuments(opts: { identityOf, }); - const baseCollected = collect(base, baseVersion); - const revisionCollected = collect(revision, revisionVersion); + const baseMap = collect(base, baseVersion); + const revisionMap = 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 - ); + // A removed node only exists in the base, an added one only in the revision. + const nodeAt = (key: string) => revisionMap.entries.get(key) ?? baseMap.entries.get(key); + const usage = new UsageIndex([...baseMap.usageEdges, ...revisionMap.usageEdges], nodeAt); - const changes = locateChanges( - classifyChanges({ - changes: rawChanges, - specVersion: revisionVersion, - base: baseCollected.entries, - revision: alignedRevision, - usage, - }), - base.source, - revision.source - ); + const changes = classifyChanges({ + changes: compareMaps(baseMap.entries, revisionMap.entries), + specVersion: revisionVersion, + base: baseMap.entries, + revision: revisionMap.entries, + usage, + }); const summary = changes.reduce( (acc, change) => { From 98fbe56ec9b95e30301255009b73f5737f0487ad Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:29:51 +0300 Subject: [PATCH 26/57] refactor(core): judge diff changes through the discriminated union and drop locate --- .../core/src/diff/__tests__/detect.test.ts | 35 +++++++------ .../src/diff/__tests__/diff-documents.test.ts | 52 +++++++++---------- packages/core/src/diff/detect.ts | 22 ++++---- packages/core/src/diff/direction.ts | 28 +++++----- packages/core/src/diff/locate.ts | 22 -------- packages/core/src/diff/rules/channel.ts | 4 +- packages/core/src/diff/rules/message.ts | 4 +- packages/core/src/diff/rules/operation.ts | 4 +- packages/core/src/diff/rules/parameter.ts | 11 ++-- packages/core/src/diff/rules/ref.ts | 22 +++++--- packages/core/src/diff/rules/request-body.ts | 5 +- packages/core/src/diff/rules/schema.ts | 46 ++++++++-------- packages/core/src/diff/rules/security.ts | 18 +++---- packages/core/src/diff/usage.ts | 14 ++--- 14 files changed, 139 insertions(+), 148 deletions(-) delete mode 100644 packages/core/src/diff/locate.ts diff --git a/packages/core/src/diff/__tests__/detect.test.ts b/packages/core/src/diff/__tests__/detect.test.ts index b9ea54ff39..bdd9265d37 100644 --- a/packages/core/src/diff/__tests__/detect.test.ts +++ b/packages/core/src/diff/__tests__/detect.test.ts @@ -1,6 +1,8 @@ import type { NodeEntry } from '../../node-map/types.js'; +import { Location } from '../../ref-utils.js'; +import { Source } from '../../resolve.js'; import { classifyChanges } from '../detect.js'; -import type { RawChange } from '../types.js'; +import type { Change } from '../types.js'; import { UsageIndex } from '../usage.js'; import { treeOf } from './tree.js'; @@ -14,28 +16,30 @@ const emptyMaps = { // 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[] = [ + const location = new Location(new Source('api.yaml', ''), '#/info/title'); + const changes: Change[] = [ { - pointer: '#/info', + key: '#/info', + kind: 'modified', property: 'title', - kind: 'changed', typeName: 'Info', - base: { pointer: '#/info/title', value: 'a' }, - revision: { pointer: '#/info/title', value: 'b' }, + base: { location, value: 'a' }, + revision: { location, value: 'b' }, }, ]; const [change] = classifyChanges({ changes, specVersion: 'oas3_1', ...emptyMaps }); expect(change.compat).toBe('non-breaking'); - expect(change.verdicts).toBeUndefined(); + expect(change.verdicts).toEqual([]); }); it('returns structural-only (non-breaking) for specs without a registry', () => { - const changes: RawChange[] = [ + const location = new Location(new Source('api.yaml', ''), '#/x'); + const changes: Change[] = [ { - pointer: '#/x', + key: '#/x', kind: 'removed', typeName: 'Operation', - base: { pointer: '#/x', value: {} }, + base: { location, value: {} }, }, ]; const [change] = classifyChanges({ changes, specVersion: 'async2', ...emptyMaps }); @@ -76,14 +80,15 @@ describe('classifyChanges', () => { ], tree ); - const changes: RawChange[] = [ + const location = new Location(new Source('api.yaml', ''), '#/components/schemas/S/enum'); + const changes: Change[] = [ { - pointer: '#/components/schemas/S', + key: '#/components/schemas/S', property: 'enum', - kind: 'changed', + kind: 'modified', typeName: 'Schema', - base: { pointer: '#/components/schemas/S/enum', value: ['a', 'b'] }, - revision: { pointer: '#/components/schemas/S/enum', value: ['a', 'c'] }, + base: { location, value: ['a', 'b'] }, + revision: { location, value: ['a', 'c'] }, }, ]; const [change] = classifyChanges({ diff --git a/packages/core/src/diff/__tests__/diff-documents.test.ts b/packages/core/src/diff/__tests__/diff-documents.test.ts index 1f93145399..f3e8635739 100644 --- a/packages/core/src/diff/__tests__/diff-documents.test.ts +++ b/packages/core/src/diff/__tests__/diff-documents.test.ts @@ -44,14 +44,17 @@ const REVISION = outdent` 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}`) + const sides = [ + change.kind !== 'added' ? change.base : undefined, + change.kind !== 'removed' ? change.revision : undefined, + ].filter((side) => side !== undefined); + const at = sides + .map((side) => `${side.location.source.absoluteRef} ${side.location.pointer}`) .join(' → '); - const name = `${change.pointer}${change.property ? ` · ${change.property}` : ''}`; + const name = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key; return [ `${change.compat} ${change.kind} ${name}`, - ...(change.verdicts ?? []).map((verdict) => ` ${verdict.ruleId}: ${verdict.message}`), + ...change.verdicts.map((verdict) => ` ${verdict.ruleId}: ${verdict.message}`), ` at ${at}`, ].join('\n'); }) @@ -72,13 +75,13 @@ describe('diffDocuments', () => { // (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 + "breaking modified #/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" + at base.yaml #/paths/~1pets/get/parameters/0 → rev.yaml #/paths/~1pets/get/parameters/1/required + non-breaking modified #/paths/~1pets/get/parameters/{query:limit}/schema · type + at base.yaml #/paths/~1pets/get/parameters/0/schema/type → rev.yaml #/paths/~1pets/get/parameters/1/schema/type + non-breaking modified #/paths/~1pets/get/responses/200 · description + at base.yaml #/paths/~1pets/get/responses/200/description → rev.yaml #/paths/~1pets/get/responses/200/description" `); expect(result.summary).toEqual({ breaking: 1, nonBreaking: 2 }); }); @@ -122,17 +125,17 @@ describe('diffDocuments', () => { // 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. + // both keyed by the path's shape. 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" + "non-breaking modified #/paths/~1pet~1{0} · path + at base.yaml #/paths/~1pet~1{id} → rev.yaml #/paths/~1pet~1{petId} + non-breaking modified #/paths/~1pet~1{0}/get/parameters/{path:0} · name + at base.yaml #/paths/~1pet~1{id}/get/parameters/0/name → rev.yaml #/paths/~1pet~1{petId}/get/parameters/0/name" `); expect(result.summary.breaking).toBe(0); }); - it('reports ambiguous path renames as remove+add', async () => { + it('keys two templates of the same shape in document order, suffixing the second', async () => { const config = await createConfig({}); const base = makeDocumentFromString( outdent` @@ -162,18 +165,15 @@ describe('diffDocuments', () => { `, 'rev.yaml' ); + // The revision is spec-invalid: `/a/{y}/b` and `/a/{z}/b` are the same path. The first one + // matches the base by shape, the second is a new key like any duplicate identity (2.1.4). 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" + "non-breaking modified #/paths/~1a~1{0}~1b · path + at base.yaml #/paths/~1a~1{x}~1b → rev.yaml #/paths/~1a~1{y}~1b + non-breaking added #/paths/~1a~1{0}~1b#2 + at rev.yaml #/paths/~1a~1{z}~1b" `); }); }); diff --git a/packages/core/src/diff/detect.ts b/packages/core/src/diff/detect.ts index 5525254567..12018acd35 100644 --- a/packages/core/src/diff/detect.ts +++ b/packages/core/src/diff/detect.ts @@ -9,8 +9,8 @@ import { type Change, type ChangeVerdict, type DiffRuleRegistry, + type JudgedChange, type Polarity, - type RawChange, } from './types.js'; import type { UsageIndex } from './usage.js'; @@ -33,33 +33,33 @@ function expandPolarity(polarity: Polarity): Polarity[] { } export function classifyChanges(opts: { - changes: RawChange[]; + changes: Change[]; specVersion: SpecVersion; base: Map; revision: Map; usage: UsageIndex; -}): Change[] { +}): JudgedChange[] { 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 })); + return changes.map((change) => ({ ...change, compat: 'non-breaking' as const, verdicts: [] })); } // 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); + const nodeAt: NodeLookup = (key) => revision.get(key) ?? base.get(key); return changes.map((change) => { const rules = spec.rules[change.typeName] ?? []; const verdicts: ChangeVerdict[] = []; - for (const polarity of expandPolarity(spec.polarityOf(change.pointer, usage, nodeAt))) { + for (const polarity of expandPolarity(spec.polarityOf(change.key, usage, nodeAt))) { const ctx = { polarity, specVersion, - base: (pointer: string) => base.get(pointer), - revision: (pointer: string) => revision.get(pointer), + base: (key: string) => base.get(key), + revision: (key: string) => revision.get(key), nodeAt, }; for (const rule of rules) { @@ -76,10 +76,6 @@ export function classifyChanges(opts: { (a, b) => compatRank(b.compat) - compatRank(a.compat) || a.ruleId.localeCompare(b.ruleId) ); - return { - ...change, - compat: verdicts[0]?.compat ?? 'non-breaking', - ...(verdicts.length ? { verdicts } : {}), - }; + return { ...change, compat: verdicts[0]?.compat ?? 'non-breaking', verdicts }; }); } diff --git a/packages/core/src/diff/direction.ts b/packages/core/src/diff/direction.ts index 2ac38376ba..dfa0f2e448 100644 --- a/packages/core/src/diff/direction.ts +++ b/packages/core/src/diff/direction.ts @@ -4,7 +4,7 @@ import type { Polarity } from './types.js'; import 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; +export type PolarityResolver = (key: string, usage: UsageIndex, lookup: NodeLookup) => Polarity; function opposite(polarity: Polarity): Polarity { if (polarity === 'request') return 'response'; @@ -27,10 +27,10 @@ const REQUEST_TYPES = new Set(['RequestBody', 'Parameter', 'ParameterList']); // callback declared inside a callback pointing the right way. const INVERTING_TYPES = new Set(['CallbacksMap', 'WebhooksMap']); -function getOas3SitePolarity(pointer: string, lookup: NodeLookup): Polarity { +function getOas3SitePolarity(key: string, lookup: NodeLookup): Polarity { let inverted = false; - for (const { typeName } of ancestorChain(pointer, lookup)) { + for (const { typeName } of ancestorChain(key, lookup)) { if (INVERTING_TYPES.has(typeName)) { inverted = !inverted; } else if (RESPONSE_TYPES.has(typeName)) { @@ -43,15 +43,15 @@ function getOas3SitePolarity(pointer: string, lookup: NodeLookup): Polarity { return 'neutral'; } -export const getOas3Polarity: PolarityResolver = (pointer, usage, lookup) => { +export const getOas3Polarity: PolarityResolver = (key, 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); + const componentRoot = getComponentRoot(key, lookup); if (componentRoot) { return usage.polarityOf(componentRoot, (site) => getOas3SitePolarity(site, lookup)); } - return getOas3SitePolarity(pointer, lookup); + return getOas3SitePolarity(key, lookup); }; /** @@ -69,7 +69,7 @@ function getOperationPolarity(chain: NodeEntry[]): Polarity { const operation = [...chain].reverse().find((entry) => entry.typeName === 'Operation'); if (!operation) return 'neutral'; - const polarity = actionPolarity(operation.scalars.action); + const polarity = actionPolarity(operation.properties.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; @@ -80,27 +80,27 @@ function getOperationPolarity(chain: NodeEntry[]): Polarity { * `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()); +export const getAsync3Polarity: PolarityResolver = (key, usage, lookup) => + resolveAsync3Polarity(key, usage, lookup, new Set()); function resolveAsync3Polarity( - pointer: string, + key: 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); + if (resolving.has(key)) return 'neutral'; + resolving.add(key); - const chain = ancestorChain(pointer, lookup); + const chain = ancestorChain(key, 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) => + const polarity = usage.polarityOf(entry.key, (site) => resolveAsync3Polarity(site, usage, lookup, resolving) ); if (polarity !== 'neutral') return polarity; diff --git a/packages/core/src/diff/locate.ts b/packages/core/src/diff/locate.ts deleted file mode 100644 index e7bd836ef0..0000000000 --- a/packages/core/src/diff/locate.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getLineColLocation } from '../format/codeframes.js'; -import type { Source } from '../resolve.js'; -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/core/src/diff/rules/channel.ts b/packages/core/src/diff/rules/channel.ts index ed00ca37a1..04a9fda692 100644 --- a/packages/core/src/diff/rules/channel.ts +++ b/packages/core/src/diff/rules/channel.ts @@ -22,9 +22,9 @@ 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; + if (change.kind !== 'modified' || change.property !== 'address') return; return breaking( - `The channel address changed from '${change.base?.value}' to '${change.revision?.value}'.` + `The channel address changed from '${change.base.value}' to '${change.revision.value}'.` ); }, }; diff --git a/packages/core/src/diff/rules/message.ts b/packages/core/src/diff/rules/message.ts index 4632f80042..50bf6e327f 100644 --- a/packages/core/src/diff/rules/message.ts +++ b/packages/core/src/diff/rules/message.ts @@ -19,9 +19,9 @@ 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; + if (change.kind !== 'modified' || change.property !== 'contentType') return; return breaking( - `The message content type changed from '${change.base?.value}' to '${change.revision?.value}'.` + `The message content type changed from '${change.base.value}' to '${change.revision.value}'.` ); }, }; diff --git a/packages/core/src/diff/rules/operation.ts b/packages/core/src/diff/rules/operation.ts index 11715e3eab..77a7b36252 100644 --- a/packages/core/src/diff/rules/operation.ts +++ b/packages/core/src/diff/rules/operation.ts @@ -24,9 +24,9 @@ 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; + if (change.kind !== 'modified' || change.property !== 'action') return; return breaking( - `The operation action changed from '${change.base?.value}' to '${change.revision?.value}'.` + `The operation action changed from '${change.base.value}' to '${change.revision.value}'.` ); }, }; diff --git a/packages/core/src/diff/rules/parameter.ts b/packages/core/src/diff/rules/parameter.ts index f4f0682918..498687f19e 100644 --- a/packages/core/src/diff/rules/parameter.ts +++ b/packages/core/src/diff/rules/parameter.ts @@ -24,7 +24,7 @@ export const parameterAddedRequired: DiffRule = { visit(change, ctx) { if (change.kind !== 'added' || ctx.polarity !== 'request') return; const added = - change.typeName === 'ParameterList' ? change.revision?.value : [change.revision?.value]; + 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.'); @@ -37,8 +37,9 @@ 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)) { + if (change.kind !== 'modified' || change.property !== 'required' || ctx.polarity !== 'request') + return; + if (becameTrue(change.base.value, change.revision.value)) { return breaking('Parameter became required.'); } return undefined; @@ -53,10 +54,10 @@ 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 (change.kind !== 'modified' || !SERIALIZATION.has(change.property)) return; if (ctx.polarity !== 'request') return; return breaking( - `Parameter \`${change.property}\` changed from '${change.base?.value}' to '${change.revision?.value}'.` + `Parameter \`${change.property}\` changed from '${change.base.value}' to '${change.revision.value}'.` ); }, }; diff --git a/packages/core/src/diff/rules/ref.ts b/packages/core/src/diff/rules/ref.ts index 07ad8e94ac..c84bc86196 100644 --- a/packages/core/src/diff/rules/ref.ts +++ b/packages/core/src/diff/rules/ref.ts @@ -1,18 +1,24 @@ +import { isRef } from '../../ref-utils.js'; 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. +function describeTarget(value: unknown): string { + if (isRef(value)) return `'${value.$ref}'`; + return value === undefined ? 'an inline definition' : `'${value}'`; +} + +// Key-aligned comparison cannot verify whether two different targets are +// content-equivalent — 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; + visit(change) { + if (change.kind !== 'modified') return; + if (!isRef(change.base.value) && !isRef(change.revision.value)) 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.` + `The reference target changed from ${describeTarget(change.base.value)} to ${describeTarget( + change.revision.value + )}. The diff cannot check that the two targets are equivalent.` ); }, }; diff --git a/packages/core/src/diff/rules/request-body.ts b/packages/core/src/diff/rules/request-body.ts index 3ee708cff0..6c61b8423c 100644 --- a/packages/core/src/diff/rules/request-body.ts +++ b/packages/core/src/diff/rules/request-body.ts @@ -5,8 +5,9 @@ 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)) { + if (change.kind !== 'modified' || change.property !== 'required' || ctx.polarity !== 'request') + return; + if (becameTrue(change.base.value, change.revision.value)) { return breaking('The request body became required.'); } return undefined; diff --git a/packages/core/src/diff/rules/schema.ts b/packages/core/src/diff/rules/schema.ts index bbe80db205..3078d5d2cd 100644 --- a/packages/core/src/diff/rules/schema.ts +++ b/packages/core/src/diff/rules/schema.ts @@ -29,13 +29,13 @@ export const schemaTypeChanged: DiffRule = { 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; + if (change.kind !== 'modified' || 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 before = effectiveTypes(change.base.value, ctx.base(change.key)?.properties.nullable); const after = effectiveTypes( - change.revision?.value, - ctx.revision(change.pointer)?.scalars.nullable + change.revision.value, + ctx.revision(change.key)?.properties.nullable ); const described = `from '${[...before].join(' | ')}' to '${[...after].join(' | ')}'`; @@ -53,8 +53,9 @@ 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 (change.kind !== 'modified' || 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(', ')}.`); } @@ -66,8 +67,9 @@ 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 (change.kind !== 'modified' || 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(', ')}.`); } @@ -79,8 +81,9 @@ 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 (change.kind !== 'modified' || 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(', ')}.`); } @@ -93,8 +96,9 @@ export const requiredPropertiesRemoved: DiffRule = { 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 (change.kind !== 'modified' || 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(', ')}.`); } @@ -108,8 +112,8 @@ export const propertyRemovedFromResponse: DiffRule = { 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; + const parentKey = ctx.nodeAt(change.key)?.parentKey; + if (!parentKey || ctx.nodeAt(parentKey)?.typeName !== 'SchemaProperties') return; return breaking('Schema property was removed.'); }, }; @@ -131,9 +135,9 @@ function constraintRule(rule: { id: string; description: string; properties: str 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; + if (change.kind !== 'modified' || !properties.has(change.property)) return; + const before = change.base.value; + const after = change.revision.value; return verdictFor( constraintDirection(change.property, before, after), ctx.polarity, @@ -176,13 +180,13 @@ 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; + if (change.kind === 'modified') return; - const parentPointer = ctx.nodeAt(change.pointer)?.parentPointer; - const parent = parentPointer ? ctx.nodeAt(parentPointer) : undefined; + const parentKey = ctx.nodeAt(change.key)?.parentKey; + const parent = parentKey ? ctx.nodeAt(parentKey) : undefined; if (parent?.typeName !== 'SchemaList') return; - const combinator = String(parent.keyInParent); + const combinator = parent.key.slice(parent.key.lastIndexOf('/') + 1); if (combinator !== 'allOf' && !ALTERNATIVE_COMBINATORS.has(combinator)) return; const removed = change.kind === 'removed'; diff --git a/packages/core/src/diff/rules/security.ts b/packages/core/src/diff/rules/security.ts index 2971497fe5..8eb2861d28 100644 --- a/packages/core/src/diff/rules/security.ts +++ b/packages/core/src/diff/rules/security.ts @@ -1,5 +1,5 @@ import { addedItems } from '../constraints.js'; -import { breaking, type DiffRule, type RawChange, type RuleContext } from '../types.js'; +import { breaking, type Change, type DiffRule, 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. @@ -9,9 +9,9 @@ import { breaking, type DiffRule, type RawChange, type RuleContext } from '../ty * 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; +function fillsAnEmptyList(change: Change, ctx: RuleContext): boolean { + const parentKey = ctx.nodeAt(change.key)?.parentKey; + const baseList = parentKey ? ctx.base(parentKey)?.raw : undefined; return Array.isArray(baseList) && baseList.length === 0; } @@ -41,16 +41,16 @@ 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; + if (change.kind !== 'modified' || !SCHEME_IDENTITY.has(change.property)) return; // Switching the scheme's `type` drags its other fields along (an apiKey has // `in`/`name`, a bearer has `scheme`), so the type change speaks for them all. const typeChanged = - ctx.base(change.pointer)?.scalars.type !== ctx.revision(change.pointer)?.scalars.type; + ctx.base(change.key)?.properties.type !== ctx.revision(change.key)?.properties.type; if (typeChanged && change.property !== 'type') return; return breaking( - `Security scheme \`${change.property}\` changed from '${change.base?.value}' to '${change.revision?.value}'.` + `Security scheme \`${change.property}\` changed from '${change.base.value}' to '${change.revision.value}'.` ); }, }; @@ -69,8 +69,8 @@ export const securityScopesAdded: DiffRule = { 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 (change.kind !== 'modified') 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/core/src/diff/usage.ts b/packages/core/src/diff/usage.ts index 35df1f487a..480bda9778 100644 --- a/packages/core/src/diff/usage.ts +++ b/packages/core/src/diff/usage.ts @@ -22,14 +22,14 @@ export class UsageIndex { } } - /** `resolveSitePolarity` receives the pointer of the node that holds the reference. */ - polarityOf(componentPointer: string, resolveSitePolarity: (site: string) => Polarity): Polarity { + /** `resolveSitePolarity` receives the key of the node that holds the reference. */ + polarityOf(componentKey: 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); + const visit = (key: string): Polarity => { + if (seen.has(key)) return 'neutral'; // cycle guard + seen.add(key); let result: Polarity = 'neutral'; - for (const site of this.sitesByTarget.get(pointer) ?? []) { + for (const site of this.sitesByTarget.get(key) ?? []) { // a ref site inside another component chains to that component's own usage const siteComponentRoot = getComponentRoot(site, this.lookup); const sitePolarity = siteComponentRoot @@ -40,6 +40,6 @@ export class UsageIndex { } return result; }; - return visit(getComponentRoot(componentPointer, this.lookup) ?? componentPointer); + return visit(getComponentRoot(componentKey, this.lookup) ?? componentKey); } } From eb368dcd598c76c0f6723111588a5c84e3b81a4a Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:03:43 +0300 Subject: [PATCH 27/57] refactor(core): carry locations on diff nodes and type changes as a discriminated union --- packages/cli/src/commands/diff/index.ts | 6 +- .../__tests__/__snapshots__/html-report.html | 66 +++----- .../src/diff/format/__tests__/format.test.ts | 159 ++++++++++-------- .../diff/format/__tests__/problems.test.ts | 31 ++-- packages/core/src/diff/format/change-side.ts | 9 - packages/core/src/diff/format/html.ts | 17 +- packages/core/src/diff/format/json.ts | 68 +++++++- packages/core/src/diff/format/markdown.ts | 8 +- packages/core/src/diff/format/problems.ts | 29 +--- packages/core/src/diff/format/stylish.ts | 40 +++-- .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- tests/e2e/diff/helpers.ts | 4 +- .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../diff/oas3-enum-values-added/snapshot.txt | 2 +- .../oas3-enum-values-removed/snapshot.txt | 2 +- .../oas3-nullability-changed/snapshot.txt | 2 +- .../snapshot.txt | 8 +- .../oas3-numeric-range-changed/snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../oas3-path-parameter-renamed/snapshot.txt | 4 +- tests/e2e/diff/oas3-path-removed/snapshot.txt | 2 +- .../diff/oas3-ref-target-changed/snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../oas3-schema-format-changed/snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../oas3-security-scheme-changed/snapshot.txt | 8 +- .../oas3-security-scopes-added/snapshot.txt | 2 +- .../oas3-string-length-changed/snapshot.txt | 2 +- 36 files changed, 280 insertions(+), 221 deletions(-) delete mode 100644 packages/core/src/diff/format/change-side.ts diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts index b2fba646de..0bb9eef543 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -77,11 +77,7 @@ export async function handleDiff({ argv, config, collectSpecData }: CommandArgs< logger.output(output + '\n'); } } else { - const problems = breakingChangesToProblems( - result, - baseDocument.source, - revisionDocument.source - ); + const problems = breakingChangesToProblems(result); formatProblems(problems, { format: argv.format, totals: getTotals(problems), diff --git a/packages/core/src/diff/format/__tests__/__snapshots__/html-report.html b/packages/core/src/diff/format/__tests__/__snapshots__/html-report.html index 6c0226eab3..a9dad52384 100644 --- a/packages/core/src/diff/format/__tests__/__snapshots__/html-report.html +++ b/packages/core/src/diff/format/__tests__/__snapshots__/html-report.html @@ -22,7 +22,7 @@

API diff

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

@@ -35,10 +35,10 @@

API diff

{
   "base": {
-    "pointer": "#/paths/~1pets/delete",
     "file": "base.yaml",
-    "line": 30,
-    "col": 3,
+    "pointer": "#/paths/~1pets/delete",
+    "line": 16,
+    "col": 7,
     "value": {
       "summary": "<script>alert(1)</script>"
     }
@@ -49,21 +49,21 @@ 

API diff

breaking - changed + modified #/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
+    "pointer": "#/paths/~1pets/get/parameters/0/required",
+    "line": 6,
+    "col": 11
   },
   "revision": {
-    "pointer": "#/paths/~1pets/get/parameters/1/required",
     "file": "revision.yaml",
-    "line": 11,
+    "pointer": "#/paths/~1pets/get/parameters/0/required",
+    "line": 8,
     "col": 21,
     "value": true
   }
@@ -73,16 +73,23 @@ 

API diff

breaking - changed + modified #/paths/~1pets/post/requestBody/content/application~1json/schema · pattern `pattern` changed from 'a' to 'a|b'. string-length-changed
{
-  "revision": {
+  "base": {
+    "file": "base.yaml",
     "pointer": "#/paths/~1pets/post/requestBody/content/application~1json/schema/pattern",
+    "line": 14,
+    "col": 24,
+    "value": "a"
+  },
+  "revision": {
     "file": "revision.yaml",
-    "line": 18,
-    "col": 22,
+    "pointer": "#/paths/~1pets/post/requestBody/content/application~1json/schema/pattern",
+    "line": 15,
+    "col": 24,
     "value": "a|b"
   }
 }
@@ -97,39 +104,14 @@

API diff

{
   "revision": {
-    "pointer": "#/components/schemas/Pet",
     "file": "revision.yaml",
-    "line": 20,
-    "col": 5,
+    "pointer": "#/components/schemas/Pet",
+    "line": 19,
+    "col": 7,
     "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}"
-  }
 }
diff --git a/packages/core/src/diff/format/__tests__/format.test.ts b/packages/core/src/diff/format/__tests__/format.test.ts index e983333253..de650cc754 100644 --- a/packages/core/src/diff/format/__tests__/format.test.ts +++ b/packages/core/src/diff/format/__tests__/format.test.ts @@ -1,6 +1,9 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { outdent } from 'outdent'; +import { Location } from '../../../ref-utils.js'; +import { Source } from '../../../resolve.js'; import type { DiffResult } from '../../types.js'; import { htmlDiff } from '../html.js'; import { markdownDiff } from '../markdown.js'; @@ -12,26 +15,72 @@ function stripColors(output: string): string { return output.replace(/\x1b\[\d+m/g, ''); } +const base = new Source( + 'base.yaml', + outdent` + openapi: 3.1.0 + paths: + /pets: + get: + parameters: + - name: limit + in: query + schema: { type: integer } + post: + requestBody: + content: + application/json: + schema: + pattern: 'a' + delete: + summary: + ` +); + +const revision = new Source( + 'revision.yaml', + outdent` + openapi: 3.1.0 + paths: + /pets: + get: + parameters: + - name: limit + in: query + required: true + schema: { type: integer } + post: + requestBody: + content: + application/json: + schema: + pattern: 'a|b' + components: + schemas: + Pet: + type: object + ` +); + +const at = (source: Source, pointer: string) => new Location(source, pointer); + /** * 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. + * base document, a property change located in both, an added component, 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 }, + summary: { breaking: 3, nonBreaking: 1 }, changes: [ { - pointer: '#/paths/~1pets/delete', + key: '#/paths/~1pets/delete', kind: 'removed', typeName: 'Operation', base: { - pointer: '#/paths/~1pets/delete', - file: 'base.yaml', - line: 30, - col: 3, + location: at(base, '#/paths/~1pets/delete'), value: { summary: '' }, }, compat: 'breaking', @@ -40,21 +89,16 @@ const RESULT: DiffResult = { ], }, { - pointer: '#/paths/~1pets/get/parameters/{query:limit}', + key: '#/paths/~1pets/get/parameters/{query:limit}', property: 'required', - kind: 'changed', + kind: 'modified', typeName: 'Parameter', base: { - pointer: '#/paths/~1pets/get/parameters/0/required', - file: 'base.yaml', - line: 9, - col: 21, + location: at(base, '#/paths/~1pets/get/parameters/0/required'), + value: undefined, }, revision: { - pointer: '#/paths/~1pets/get/parameters/1/required', - file: 'revision.yaml', - line: 11, - col: 21, + location: at(revision, '#/paths/~1pets/get/parameters/0/required'), value: true, }, compat: 'breaking', @@ -67,15 +111,22 @@ const RESULT: DiffResult = { ], }, { - pointer: '#/paths/~1pets/post/requestBody/content/application~1json/schema', + key: '#/paths/~1pets/post/requestBody/content/application~1json/schema', property: 'pattern', - kind: 'changed', + kind: 'modified', typeName: 'Schema', + base: { + location: at( + base, + '#/paths/~1pets/post/requestBody/content/application~1json/schema/pattern' + ), + value: 'a', + }, revision: { - pointer: '#/paths/~1pets/post/requestBody/content/application~1json/schema/pattern', - file: 'revision.yaml', - line: 18, - col: 22, + location: at( + revision, + '#/paths/~1pets/post/requestBody/content/application~1json/schema/pattern' + ), value: 'a|b', }, compat: 'breaking', @@ -90,38 +141,15 @@ const RESULT: DiffResult = { ], }, { - pointer: '#/components/schemas/Pet', + key: '#/components/schemas/Pet', kind: 'added', typeName: 'Schema', revision: { - pointer: '#/components/schemas/Pet', - file: 'revision.yaml', - line: 20, - col: 5, + location: at(revision, '#/components/schemas/Pet'), 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', + verdicts: [], }, ], }; @@ -130,30 +158,26 @@ 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(stripColors(stylishDiff(RESULT))).toMatchInlineSnapshot(` - "/pet/{petId} - ✔ non-breaking changed paths · /pet/{id} · path - at revision.yaml:4:3 - - components + "components ✔ non-breaking added components/schemas/Pet - at revision.yaml:20:5 + at revision.yaml:19:7 DELETE /pets ✖ breaking removed paths · /pets · delete Operation was removed. (operation-removed) - at base.yaml:30:3 + at base.yaml:16:7 GET /pets - ✖ breaking changed parameters/{query:limit} · required + ✖ breaking modified parameters/{query:limit} · required Parameter became required. (parameter-became-required) - at revision.yaml:11:21 + at revision.yaml:8:21 POST /pets - ✖ breaking changed requestBody/content/application~1json/schema · pattern + ✖ breaking modified requestBody/content/application~1json/schema · pattern \`pattern\` changed from 'a' to 'a|b'. (string-length-changed) - at revision.yaml:18:22 + at revision.yaml:15:24 - 3 breaking, 2 non-breaking." + 3 breaking, 1 non-breaking." `); }); }); @@ -163,15 +187,14 @@ describe('markdownDiff', () => { expect(markdownDiff(RESULT)).toMatchInlineSnapshot(` "## API diff - **3** breaking · **2** non-breaking + **3** breaking · **1** 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\` | |" + | 🔴 breaking | modified | \`#/paths/~1pets/get/parameters/{query:limit} · required\` | Parameter became required. \`parameter-became-required\` | + | 🔴 breaking | modified | \`#/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\` | |" `); }); }); diff --git a/packages/core/src/diff/format/__tests__/problems.test.ts b/packages/core/src/diff/format/__tests__/problems.test.ts index 318a0823dd..147685d5f9 100644 --- a/packages/core/src/diff/format/__tests__/problems.test.ts +++ b/packages/core/src/diff/format/__tests__/problems.test.ts @@ -1,9 +1,11 @@ +import { Location } from '../../../ref-utils.js'; import { Source } from '../../../resolve.js'; import type { DiffResult } from '../../types.js'; import { breakingChangesToProblems } from '../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 at = (source: Source, pointer: string) => new Location(source, pointer); const result: DiffResult = { version: '1', @@ -11,22 +13,28 @@ const result: DiffResult = { summary: { breaking: 2, nonBreaking: 1 }, changes: [ { - pointer: '#/paths/~1pets/delete', + key: '#/paths/~1pets/delete', kind: 'removed', typeName: 'Operation', - base: { pointer: '#/paths/~1pets/delete', file: 'base.yaml', line: 21, col: 7 }, + base: { location: at(baseSource, '#/paths/~1pets/delete'), value: undefined }, compat: 'breaking', verdicts: [ { compat: 'breaking', ruleId: 'operation-removed', message: 'Operation was removed.' }, ], }, { - pointer: '#/paths/~1pets/get/parameters/{query:limit}', + key: '#/paths/~1pets/get/parameters/{query:limit}', property: 'required', - kind: 'changed', + kind: 'modified', typeName: 'Parameter', - base: { pointer: '#/paths/~1pets/get/parameters/0/required' }, - revision: { pointer: '#/paths/~1pets/get/parameters/0/required', value: true }, + base: { + location: at(baseSource, '#/paths/~1pets/get/parameters/0/required'), + value: undefined, + }, + revision: { + location: at(revisionSource, '#/paths/~1pets/get/parameters/0/required'), + value: true, + }, compat: 'breaking', verdicts: [ { @@ -37,20 +45,21 @@ const result: DiffResult = { ], }, { - pointer: '#/info', + key: '#/info', property: 'version', - kind: 'changed', + kind: 'modified', typeName: 'Info', - base: { pointer: '#/info/version' }, - revision: { pointer: '#/info/version' }, + base: { location: at(baseSource, '#/info/version'), value: '1.0.0' }, + revision: { location: at(revisionSource, '#/info/version'), value: '1.0.1' }, compat: 'non-breaking', + verdicts: [], }, ], }; describe('breakingChangesToProblems', () => { it('describes each breaking change the way a lint problem is described', () => { - const problems = breakingChangesToProblems(result, baseSource, revisionSource); + const problems = breakingChangesToProblems(result); // 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 diff --git a/packages/core/src/diff/format/change-side.ts b/packages/core/src/diff/format/change-side.ts deleted file mode 100644 index a26524cc53..0000000000 --- a/packages/core/src/diff/format/change-side.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Change, ChangeSide } from '../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/core/src/diff/format/html.ts b/packages/core/src/diff/format/html.ts index eba9937686..d404f9b361 100644 --- a/packages/core/src/diff/format/html.ts +++ b/packages/core/src/diff/format/html.ts @@ -1,4 +1,5 @@ -import type { Change, DiffResult } from '../types.js'; +import type { Compat, DiffResult, JudgedChange } from '../types.js'; +import { toJsonChange } from './json.js'; function escapeHtml(value: unknown): string { return String(value) @@ -8,24 +9,22 @@ function escapeHtml(value: unknown): string { .replace(/"/g, '"'); } -const IMPACT_CLASS: Record = { +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 } : {}), - }; +function renderChange(change: JudgedChange): string { + const location = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key; + const { base, revision } = toJsonChange(change); + const payload = { ...(base ? { base } : {}), ...(revision ? { revision } : {}) }; return `
${escapeHtml(change.compat)} ${escapeHtml(change.kind)} ${escapeHtml(location)} - ${(change.verdicts ?? []) + ${change.verdicts .map( (v) => `${escapeHtml(v.message)} ${escapeHtml( diff --git a/packages/core/src/diff/format/json.ts b/packages/core/src/diff/format/json.ts index d07414977c..ff8d7a1faf 100644 --- a/packages/core/src/diff/format/json.ts +++ b/packages/core/src/diff/format/json.ts @@ -1,5 +1,69 @@ -import type { DiffResult } from '../types.js'; +import { getLineColLocation } from '../../format/codeframes.js'; +import type { + Change, + ChangeVerdict, + Compat, + DiffResult, + JudgedChange, + LocatedNode, +} from '../types.js'; + +export interface JsonLocatedNode { + file: string; + pointer: string; + line: number; + col: number; + value: unknown; +} + +/** The wire shape: optional sides are the JSON reader's contract, the union stays internal. */ +export interface JsonChange { + key: string; + typeName: string; + kind: Change['kind']; + property?: string; + compat: Compat; + verdicts: ChangeVerdict[]; + base?: JsonLocatedNode; + revision?: JsonLocatedNode; +} + +export interface JsonDiffResult extends Omit { + changes: JsonChange[]; +} + +// Nodes inlined by bundling do not exist in the root source AST; +// getLineColLocation falls back to 1:1 for such pointers. +function toJsonNode({ location, value }: LocatedNode): JsonLocatedNode { + const { start } = getLineColLocation(location); + return { + file: location.source.absoluteRef, + pointer: location.pointer, + line: start.line, + col: start.col, + value, + }; +} + +export function toJsonChange(change: JudgedChange): JsonChange { + const { key, typeName, kind, compat, verdicts } = change; + const common = { key, typeName, kind, compat, verdicts }; + switch (change.kind) { + case 'added': + return { ...common, revision: toJsonNode(change.revision) }; + case 'removed': + return { ...common, base: toJsonNode(change.base) }; + case 'modified': + return { + ...common, + property: change.property, + base: toJsonNode(change.base), + revision: toJsonNode(change.revision), + }; + } +} export function jsonDiff(result: DiffResult): string { - return JSON.stringify(result, null, 2); + const report: JsonDiffResult = { ...result, changes: result.changes.map(toJsonChange) }; + return JSON.stringify(report, null, 2); } diff --git a/packages/core/src/diff/format/markdown.ts b/packages/core/src/diff/format/markdown.ts index ed7e72b5b8..0cd8ad57d8 100644 --- a/packages/core/src/diff/format/markdown.ts +++ b/packages/core/src/diff/format/markdown.ts @@ -1,6 +1,6 @@ -import type { Change, DiffResult } from '../types.js'; +import type { Compat, DiffResult } from '../types.js'; -const IMPACT_LABEL: Record = { +const IMPACT_LABEL: Record = { breaking: '🔴 breaking', 'non-breaking': '🟢 non-breaking', }; @@ -27,10 +27,10 @@ export function markdownDiff(result: DiffResult): string { ]; for (const change of result.changes) { - const location = change.property ? `${change.pointer} · ${change.property}` : change.pointer; + const location = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key; // 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 ?? []) + const details = change.verdicts .map((verdict) => `${escapeCell(verdict.message)} \`${verdict.ruleId}\``) .join('
'); lines.push( diff --git a/packages/core/src/diff/format/problems.ts b/packages/core/src/diff/format/problems.ts index 5e99e39d57..5a5abaefbb 100644 --- a/packages/core/src/diff/format/problems.ts +++ b/packages/core/src/diff/format/problems.ts @@ -1,36 +1,23 @@ -import type { Source } from '../../resolve.js'; import type { NormalizedProblem } from '../../walk.js'; -import type { DiffResult } from '../types.js'; -import { displaySide } from './change-side.js'; +import { displaySide, type DiffResult } from '../types.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[] { +// This is what lets the diff report reuse the lint formatters in core. +export function breakingChangesToProblems(result: DiffResult): 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]; - + const [verdict] = change.verdicts; return { - message: verdict?.message ?? `${change.kind} ${change.pointer}`, + message: verdict?.message ?? `${change.kind} ${change.key}`, 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 + location: [displaySide(change).location], + // Point at the counterpart in the base 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 } } - : {}), + ...(change.kind === 'modified' ? { from: change.base.location } : {}), suggest: [], }; }); diff --git a/packages/core/src/diff/format/stylish.ts b/packages/core/src/diff/format/stylish.ts index e4df04c7fe..879a64fe29 100644 --- a/packages/core/src/diff/format/stylish.ts +++ b/packages/core/src/diff/format/stylish.ts @@ -1,10 +1,17 @@ import * as path from 'node:path'; import { isBrowser } from '../../env.js'; +import { getLineColLocation } from '../../format/codeframes.js'; import { colorize } from '../../logger.js'; import { isAbsoluteUrl, unescapePointerFragment } from '../../ref-utils.js'; -import { compatRank, type Change, type Compat, type DiffResult } from '../types.js'; -import { displaySide } from './change-side.js'; +import { + compatRank, + displaySide, + type Change, + type Compat, + type DiffResult, + type JudgedChange, +} from '../types.js'; const ICONS: Record = { breaking: colorize.red('✖ breaking '), @@ -29,7 +36,7 @@ function segmentsOf(pointer: string): string[] { } function groupOf(change: Change): string { - const segments = segmentsOf(displaySide(change)?.pointer ?? change.pointer); + const segments = segmentsOf(displaySide(change).location.pointer); if (segments[0] === 'paths' && segments.length > 1) { const pathKey = unescapePointerFragment(segments[1]); const method = segments[2]; @@ -47,27 +54,29 @@ function labelSegments(segments: string[]): string[] { } function labelOf(change: Change): string { - const segments = segmentsOf(change.pointer); + const segments = segmentsOf(change.key); 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; + if (!label) return change.kind === 'modified' ? change.property : change.key; + return change.kind === 'modified' ? `${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}`; +function locationOf(change: Change, cwd: string): string { + const { location } = displaySide(change); + const { start } = getLineColLocation(location); + const file = isAbsoluteUrl(location.source.absoluteRef) + ? location.source.absoluteRef + : path.relative(cwd, location.source.absoluteRef); + return `${file}:${start.line}:${start.col}`; } export function stylishDiff(result: DiffResult): string { const cwd = isBrowser ? '' : process.cwd(); - const groups = new Map(); + const groups = new Map(); for (const change of result.changes) { const key = groupOf(change); const group = groups.get(key) ?? []; @@ -79,15 +88,14 @@ export function stylishDiff(result: DiffResult): string { for (const [key, changes] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) { lines.push(colorize.bold(colorize.blue(key))); const sorted = [...changes].sort( - (a, b) => compatRank(b.compat) - compatRank(a.compat) || a.pointer.localeCompare(b.pointer) + (a, b) => compatRank(b.compat) - compatRank(a.compat) || a.key.localeCompare(b.key) ); for (const change of sorted) { lines.push(` ${ICONS[change.compat]} ${colorize.bold(change.kind)} ${labelOf(change)}`); - for (const verdict of change.verdicts ?? []) { + for (const verdict of change.verdicts) { lines.push(colorize.gray(` ${verdict.message} (${verdict.ruleId})`)); } - const location = locationOf(change, cwd); - if (location) lines.push(colorize.gray(` at ${location}`)); + lines.push(colorize.gray(` at ${locationOf(change, cwd)}`)); } lines.push(''); } diff --git a/tests/e2e/diff/async3-channel-address-changed/snapshot.txt b/tests/e2e/diff/async3-channel-address-changed/snapshot.txt index b3998baef0..c3dc0f3813 100644 --- a/tests/e2e/diff/async3-channel-address-changed/snapshot.txt +++ b/tests/e2e/diff/async3-channel-address-changed/snapshot.txt @@ -1,5 +1,5 @@ channels - ✖ breaking changed channels/userSignedUp · address + ✖ breaking modified channels/userSignedUp · address The channel address changed from 'user/signedup' to 'user/signed-up'. (channel-address-changed) at revision.yaml:7:14 diff --git a/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt b/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt index f030f429dd..b59e513b83 100644 --- a/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt +++ b/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt @@ -1,5 +1,5 @@ channels - ✖ breaking changed channels/userSignedUp/messages/signup · contentType + ✖ breaking modified 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 diff --git a/tests/e2e/diff/async3-operation-action-changed/snapshot.txt b/tests/e2e/diff/async3-operation-action-changed/snapshot.txt index 11e9116987..60ee9bd014 100644 --- a/tests/e2e/diff/async3-operation-action-changed/snapshot.txt +++ b/tests/e2e/diff/async3-operation-action-changed/snapshot.txt @@ -1,5 +1,5 @@ operations - ✖ breaking changed operations/onUserSignedUp · action + ✖ breaking modified operations/onUserSignedUp · action The operation action changed from 'receive' to 'send'. (operation-action-changed) at revision.yaml:22:13 diff --git a/tests/e2e/diff/async3-payload-required-added/snapshot.txt b/tests/e2e/diff/async3-payload-required-added/snapshot.txt index 19d5987d7a..738f9b1fa9 100644 --- a/tests/e2e/diff/async3-payload-required-added/snapshot.txt +++ b/tests/e2e/diff/async3-payload-required-added/snapshot.txt @@ -1,5 +1,5 @@ channels - ✖ breaking changed channels/userSignedUp/messages/signup/payload · required + ✖ breaking modified channels/userSignedUp/messages/signup/payload · required Properties became required: plan. (required-properties-added) at revision.yaml:14:13 diff --git a/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt b/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt index 39a4ec7f25..1c1a5d12bd 100644 --- a/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt +++ b/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt @@ -1,5 +1,5 @@ channels - ✔ non-breaking changed channels/userSignedUp/messages/signup/payload · required + ✔ non-breaking modified channels/userSignedUp/messages/signup/payload · required at revision.yaml:14:13 0 breaking, 1 non-breaking. diff --git a/tests/e2e/diff/helpers.ts b/tests/e2e/diff/helpers.ts index 6a881e7208..4ceaa440be 100644 --- a/tests/e2e/diff/helpers.ts +++ b/tests/e2e/diff/helpers.ts @@ -23,11 +23,11 @@ export function runDiff(fixture: string, ...args: string[]): string { export function runJsonDiff(fixture: string): { summary: { breaking: number; nonBreaking: number }; changes: { - pointer: string; + key: string; property?: string; kind: string; compat: string; - verdicts?: { ruleId: string; compat: string; message: string }[]; + verdicts: { ruleId: string; compat: string; message: string }[]; }[]; } { const output = runDiff(fixture, '--format=json'); diff --git a/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt b/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt index e4060dbf09..a4fe5e91e6 100644 --- a/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✖ breaking changed requestBody/content/application~1json/schema · additionalProperties + ✖ breaking modified requestBody/content/application~1json/schema · additionalProperties `additionalProperties` changed from 'true' to 'false'. (additional-properties-changed) at revision.yaml:13:37 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 index 325e0bed1b..ef63315cde 100644 --- a/tests/e2e/diff/oas3-enum-values-added-to-request/snapshot.txt +++ b/tests/e2e/diff/oas3-enum-values-added-to-request/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✔ non-breaking changed requestBody/content/application~1json/schema · enum + ✔ non-breaking modified requestBody/content/application~1json/schema · enum at revision.yaml:13:21 0 breaking, 1 non-breaking. diff --git a/tests/e2e/diff/oas3-enum-values-added/snapshot.txt b/tests/e2e/diff/oas3-enum-values-added/snapshot.txt index 6bb421439e..758212a268 100644 --- a/tests/e2e/diff/oas3-enum-values-added/snapshot.txt +++ b/tests/e2e/diff/oas3-enum-values-added/snapshot.txt @@ -1,5 +1,5 @@ GET /pets - ✖ breaking changed responses/200/content/application~1json/schema · enum + ✖ breaking modified responses/200/content/application~1json/schema · enum Enum values added: sold. (enum-values-added) at revision.yaml:15:23 diff --git a/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt b/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt index ec95a3dfc3..f12139d18e 100644 --- a/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt @@ -1,5 +1,5 @@ GET /pets - ✖ breaking changed parameters/{query:sort}/schema · enum + ✖ breaking modified parameters/{query:sort}/schema · enum Enum values removed: desc. (enum-values-removed) at revision.yaml:14:15 diff --git a/tests/e2e/diff/oas3-nullability-changed/snapshot.txt b/tests/e2e/diff/oas3-nullability-changed/snapshot.txt index 06275817d4..caf696a69c 100644 --- a/tests/e2e/diff/oas3-nullability-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-nullability-changed/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✖ breaking changed requestBody/content/application~1json/schema/properties/name · type + ✖ breaking modified requestBody/content/application~1json/schema/properties/name · type Schema type narrowed from 'string | null' to 'string'. (schema-type-changed) at revision.yaml:15:25 diff --git a/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt index 4520ba5a16..749dd7c9f3 100644 --- a/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt +++ b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt @@ -1,11 +1,11 @@ -document - ✔ non-breaking changed openapi +openapi + ✔ non-breaking modified openapi at revision.yaml:1:10 POST /pets - ✔ non-breaking changed requestBody/content/application~1json/schema/properties/name · nullable + ✔ non-breaking modified requestBody/content/application~1json/schema/properties/name · nullable at revision.yaml:15:19 - ✔ non-breaking changed requestBody/content/application~1json/schema/properties/name · type + ✔ non-breaking modified requestBody/content/application~1json/schema/properties/name · type at revision.yaml:16:21 0 breaking, 3 non-breaking. diff --git a/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt b/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt index 865e3f27a1..7ccb5c1b44 100644 --- a/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✖ breaking changed requestBody/content/application~1json/schema/properties/age · minimum + ✖ breaking modified requestBody/content/application~1json/schema/properties/age · minimum `minimum` changed from '0' to '10'. (numeric-range-changed) at revision.yaml:16:28 diff --git a/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt b/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt index 7d6169c90b..39be4ceb67 100644 --- a/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt @@ -1,5 +1,5 @@ GET /pets - ✖ breaking changed parameters/{query:limit} · required + ✖ breaking modified parameters/{query:limit} · required Parameter became required. (parameter-became-required) at revision.yaml:11:21 diff --git a/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt b/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt index 8bdf94a51e..41de4982d9 100644 --- a/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt @@ -1,5 +1,5 @@ GET /pets - ✖ breaking changed parameters/{query:tags} · style + ✖ breaking modified parameters/{query:tags} · style Parameter `style` changed from 'form' to 'spaceDelimited'. (parameter-serialization-changed) at revision.yaml:11:18 diff --git a/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt b/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt index 30aa95cdd8..d37bbda3ff 100644 --- a/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt +++ b/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt @@ -1,9 +1,9 @@ /pets/{petId} - ✔ non-breaking changed paths · /pets/{id} · path + ✔ non-breaking modified paths · /pets/{0} · path at revision.yaml:7:5 GET /pets/{petId} - ✔ non-breaking changed parameters/{path:id} · name + ✔ non-breaking modified parameters/{path:0} · name at revision.yaml:9:17 0 breaking, 2 non-breaking. diff --git a/tests/e2e/diff/oas3-path-removed/snapshot.txt b/tests/e2e/diff/oas3-path-removed/snapshot.txt index 3eb58edc05..f18b91ed0b 100644 --- a/tests/e2e/diff/oas3-path-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-path-removed/snapshot.txt @@ -1,5 +1,5 @@ /pets/{petId} - ✖ breaking removed paths · /pets/{petId} + ✖ breaking removed paths · /pets/{0} Path was removed. (path-removed) at base.yaml:11:5 diff --git a/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt b/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt index fbaca8753e..2db27a4340 100644 --- a/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt @@ -1,5 +1,5 @@ GET /pets - ✖ breaking changed responses/200/content/application~1json · schema + ✖ breaking modified 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 diff --git a/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt b/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt index dca9842584..c0b33950dd 100644 --- a/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt +++ b/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✖ breaking changed requestBody · required + ✖ breaking modified requestBody · required The request body became required. (request-body-became-required) at revision.yaml:9:19 diff --git a/tests/e2e/diff/oas3-required-properties-added/snapshot.txt b/tests/e2e/diff/oas3-required-properties-added/snapshot.txt index 53e1d5f1fb..aa7d0edbaf 100644 --- a/tests/e2e/diff/oas3-required-properties-added/snapshot.txt +++ b/tests/e2e/diff/oas3-required-properties-added/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✖ breaking changed requestBody/content/application~1json/schema · required + ✖ breaking modified requestBody/content/application~1json/schema · required Properties became required: name. (required-properties-added) at revision.yaml:13:25 diff --git a/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt b/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt index c9029a0774..2a111c6e33 100644 --- a/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt @@ -1,5 +1,5 @@ GET /pets - ✖ breaking changed responses/200/content/application~1json/schema · required + ✖ breaking modified responses/200/content/application~1json/schema · required Properties are no longer required: name. (required-properties-removed) at revision.yaml:15:27 diff --git a/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt b/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt index a229e7805b..a0896b29a9 100644 --- a/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✖ breaking changed requestBody/content/application~1json/schema/properties/id · format + ✖ breaking modified requestBody/content/application~1json/schema/properties/id · format `format` was added with value 'uuid'. (schema-format-changed) at revision.yaml:16:27 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 index 9279f6d5d8..be84f51ae0 100644 --- a/tests/e2e/diff/oas3-schema-type-widened-in-request/snapshot.txt +++ b/tests/e2e/diff/oas3-schema-type-widened-in-request/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✔ non-breaking changed requestBody/content/application~1json/schema/properties/id · type + ✔ non-breaking modified requestBody/content/application~1json/schema/properties/id · type at revision.yaml:16:21 0 breaking, 1 non-breaking. diff --git a/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt b/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt index cb76fa7b2c..82bbf5f8d4 100644 --- a/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt @@ -1,12 +1,12 @@ components - ✖ breaking changed components/securitySchemes/apiKey · type + ✖ breaking modified 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 + ✔ non-breaking modified components/securitySchemes/apiKey · in at revision.yaml:16:7 - ✔ non-breaking changed components/securitySchemes/apiKey · name + ✔ non-breaking modified components/securitySchemes/apiKey · name at revision.yaml:16:7 - ✔ non-breaking changed components/securitySchemes/apiKey · scheme + ✔ non-breaking modified components/securitySchemes/apiKey · scheme at revision.yaml:17:15 1 breaking, 3 non-breaking. diff --git a/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt b/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt index 6984715a2b..c950c4f090 100644 --- a/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt +++ b/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt @@ -1,5 +1,5 @@ GET /pets - ✖ breaking changed security/{oauth} · oauth + ✖ breaking modified security/{oauth} · oauth Scheme `oauth` requires new scopes: pets:write. (security-scopes-added) at revision.yaml:10:13 diff --git a/tests/e2e/diff/oas3-string-length-changed/snapshot.txt b/tests/e2e/diff/oas3-string-length-changed/snapshot.txt index 4743ffd825..a8f4a24549 100644 --- a/tests/e2e/diff/oas3-string-length-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-string-length-changed/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✖ breaking changed requestBody/content/application~1json/schema/properties/name · maxLength + ✖ breaking modified requestBody/content/application~1json/schema/properties/name · maxLength `maxLength` changed from '100' to '10'. (string-length-changed) at revision.yaml:16:30 From 97a3cd853fbb23e9e2539dd4a19caf4512ed9364 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:28:43 +0300 Subject: [PATCH 28/57] refactor(core): give diff rules the visitor factory shape and a reporting context --- .../core/src/diff/__tests__/detect.test.ts | 120 +++++++++++------- .../core/src/diff/__tests__/direction.test.ts | 62 ++++----- packages/core/src/diff/detect.ts | 84 ++++++------ packages/core/src/diff/direction.ts | 44 +++---- .../src/diff/format/__tests__/format.test.ts | 13 +- .../diff/format/__tests__/problems.test.ts | 8 +- packages/core/src/diff/format/json.ts | 24 ++-- packages/core/src/diff/index.ts | 4 +- packages/core/src/diff/types.ts | 56 ++++---- packages/core/src/diff/usage.ts | 18 +-- 10 files changed, 238 insertions(+), 195 deletions(-) diff --git a/packages/core/src/diff/__tests__/detect.test.ts b/packages/core/src/diff/__tests__/detect.test.ts index bdd9265d37..7ccd844a7e 100644 --- a/packages/core/src/diff/__tests__/detect.test.ts +++ b/packages/core/src/diff/__tests__/detect.test.ts @@ -1,57 +1,81 @@ -import type { NodeEntry } from '../../node-map/types.js'; import { Location } from '../../ref-utils.js'; import { Source } from '../../resolve.js'; -import { classifyChanges } from '../detect.js'; -import type { Change } from '../types.js'; +import { detectBreakingChanges, initDiffRules } from '../detect.js'; +import type { Change, DiffRule } from '../types.js'; import { UsageIndex } from '../usage.js'; import { treeOf } from './tree.js'; +const source = new Source('api.yaml', ''); +const at = (pointer: string) => new Location(source, pointer); + const emptyMaps = { - base: new Map(), - revision: new Map(), + 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 location = new Location(new Source('api.yaml', ''), '#/info/title'); +// What every finding passes through: which visitors run, what a report becomes, and how the +// same finding under two directions is kept once. The rules themselves are covered by tests/e2e/diff. +describe('detectBreakingChanges', () => { + it('leaves a change no rule reports on non-breaking with no verdicts', () => { const changes: Change[] = [ { key: '#/info', kind: 'modified', property: 'title', typeName: 'Info', - base: { location, value: 'a' }, - revision: { location, value: 'b' }, + base: { location: at('#/info/title'), value: 'a' }, + revision: { location: at('#/info/title'), value: 'b' }, }, ]; - const [change] = classifyChanges({ changes, specVersion: 'oas3_1', ...emptyMaps }); + + const [change] = detectBreakingChanges({ changes, specVersion: 'oas3_1', ...emptyMaps }); + expect(change.compat).toBe('non-breaking'); expect(change.verdicts).toEqual([]); }); - it('returns structural-only (non-breaking) for specs without a registry', () => { - const location = new Location(new Source('api.yaml', ''), '#/x'); + it('judges nothing for a specification without rules', () => { const changes: Change[] = [ { key: '#/x', kind: 'removed', typeName: 'Operation', - base: { location, value: {} }, + base: { location: at('#/x'), value: {} }, }, ]; - const [change] = classifyChanges({ changes, specVersion: 'async2', ...emptyMaps }); + + const [change] = detectBreakingChanges({ 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. + it('stamps the rule id on a report and defaults its location to the display side', () => { + const changes: Change[] = [ + { + key: '#/paths/~1x/delete', + kind: 'removed', + typeName: 'Operation', + base: { location: at('#/paths/~1x/delete'), value: {} }, + }, + ]; + + const [change] = detectBreakingChanges({ changes, specVersion: 'oas3_1', ...emptyMaps }); + + expect(change.compat).toBe('breaking'); + expect(change.verdicts).toEqual([ + { + ruleId: 'operation-removed', + message: 'Operation was removed.', + location: at('#/paths/~1x/delete'), + }, + ]); + }); + + it('visits a component used in a request and a response once per direction, keeping each finding once', () => { const entries = treeOf(` #/ Root - #/paths PathsMap + #/paths Paths #/paths/~1x PathItem #/paths/~1x/get Operation #/paths/~1x/get/parameters ParameterList @@ -64,56 +88,54 @@ describe('classifyChanges', () => { #/paths/~1x/get/responses/200/content/application~1json/schema Schema #/components Components #/components/schemas NamedSchemas - #/components/schemas/S Schema + #/components/schemas/Shared Schema `); - const tree = (pointer: string) => entries.get(pointer); + const lookup = (key: string) => entries.get(key); const usage = new UsageIndex( [ { site: '#/paths/~1x/get/parameters/{query:q}/schema', - target: '#/components/schemas/S', + target: '#/components/schemas/Shared', }, { site: '#/paths/~1x/get/responses/200/content/application~1json/schema', - target: '#/components/schemas/S', + target: '#/components/schemas/Shared', }, ], - tree + lookup ); - const location = new Location(new Source('api.yaml', ''), '#/components/schemas/S/enum'); const changes: Change[] = [ { - key: '#/components/schemas/S', - property: 'enum', + key: '#/components/schemas/Shared', kind: 'modified', + property: 'enum', typeName: 'Schema', - base: { location, value: ['a', 'b'] }, - revision: { location, value: ['a', 'c'] }, + base: { location: at('#/components/schemas/Shared/enum'), value: ['a', 'b'] }, + revision: { location: at('#/components/schemas/Shared/enum'), value: ['a', 'c'] }, }, ]; - const [change] = classifyChanges({ + + const [change] = detectBreakingChanges({ 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", - }, - ] - `); + + expect(change.verdicts.map((verdict) => verdict.ruleId)).toEqual([ + 'enum-values-added', + 'enum-values-removed', + ]); + }); +}); + +describe('initDiffRules', () => { + it('instantiates one visitor per rule under the rule id', () => { + const rule: DiffRule = () => ({ Schema() {} }); + + expect( + initDiffRules({ 'my-rule': rule }).map(({ id, visitor }) => [id, Object.keys(visitor)]) + ).toEqual([['my-rule', ['Schema']]]); }); }); diff --git a/packages/core/src/diff/__tests__/direction.test.ts b/packages/core/src/diff/__tests__/direction.test.ts index e221d33102..fe3631c59b 100644 --- a/packages/core/src/diff/__tests__/direction.test.ts +++ b/packages/core/src/diff/__tests__/direction.test.ts @@ -1,6 +1,6 @@ import { getComponentRoot, type NodeLookup } from '../../node-map/chain.js'; -import { getAsync3Polarity, getOas3Polarity } from '../direction.js'; -import { mergePolarity, UsageIndex } from '../usage.js'; +import { getAsync3Direction, getOas3Direction } from '../direction.js'; +import { mergeDirections, UsageIndex } from '../usage.js'; import { treeOf } from './tree.js'; // One document covering every direction-bearing shape at once. @@ -66,51 +66,51 @@ describe('getComponentRoot', () => { }); }); -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('mergeDirections', () => { + it('merges directions', () => { + expect(mergeDirections('neutral', 'request')).toBe('request'); + expect(mergeDirections('request', 'request')).toBe('request'); + expect(mergeDirections('request', 'response')).toBe('both'); + expect(mergeDirections('both', 'response')).toBe('both'); }); }); -describe('getOas3Polarity', () => { +describe('getOas3Direction', () => { it('reads the direction off the node types on the way down', () => { - expect(getOas3Polarity('#/paths/~1p/get/responses/200', emptyUsage, tree)).toBe('response'); + expect(getOas3Direction('#/paths/~1p/get/responses/200', emptyUsage, tree)).toBe('response'); expect( - getOas3Polarity('#/paths/~1p/get/parameters/{query:limit}/schema', emptyUsage, tree) + getOas3Direction('#/paths/~1p/get/parameters/{query:limit}/schema', emptyUsage, tree) ).toBe('request'); expect( - getOas3Polarity('#/paths/~1p/post/requestBody/content/application~1json', emptyUsage, tree) + getOas3Direction('#/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'); + expect(getOas3Direction('#/info/title', emptyUsage, tree)).toBe('neutral'); + expect(getOas3Direction('#/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) + getOas3Direction('#/paths/~1p/post/callbacks/onEvent/~1cb/post/requestBody', emptyUsage, tree) ).toBe('response'); - expect(getOas3Polarity('#/webhooks/newPet/post/requestBody', emptyUsage, tree)).toBe( + expect(getOas3Direction('#/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'); + expect(getOas3Direction('#/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( + getOas3Direction( '#/paths/~1p/post/requestBody/content/application~1json/schema/properties/responses', emptyUsage, tree ) ).toBe('request'); expect( - getOas3Polarity( + getOas3Direction( '#/paths/~1p/get/responses/200/content/application~1json/schema/properties/callbacks', emptyUsage, tree @@ -118,7 +118,7 @@ describe('getOas3Polarity', () => { ).toBe('response'); }); - it('derives component polarity from usage sites', () => { + it('derives component direction from usage sites', () => { const usage = new UsageIndex( [ { @@ -128,7 +128,7 @@ describe('getOas3Polarity', () => { ], tree ); - expect(getOas3Polarity('#/components/schemas/Pet/properties/name', usage, tree)).toBe( + expect(getOas3Direction('#/components/schemas/Pet/properties/name', usage, tree)).toBe( 'response' ); }); @@ -147,7 +147,7 @@ describe('getOas3Polarity', () => { ], tree ); - expect(getOas3Polarity('#/components/schemas/Pet', usage, tree)).toBe('both'); + expect(getOas3Direction('#/components/schemas/Pet', usage, tree)).toBe('both'); }); it('resolves transitive usage through other components, cycle-safe', () => { @@ -166,11 +166,11 @@ describe('getOas3Polarity', () => { ], tree ); - expect(getOas3Polarity('#/components/schemas/Address', usage, tree)).toBe('response'); + expect(getOas3Direction('#/components/schemas/Address', usage, tree)).toBe('response'); }); it('returns neutral for unused components', () => { - expect(getOas3Polarity('#/components/schemas/Orphan', emptyUsage, tree)).toBe('neutral'); + expect(getOas3Direction('#/components/schemas/Orphan', emptyUsage, tree)).toBe('neutral'); }); }); @@ -195,7 +195,7 @@ const asyncEntries = treeOf(` `); const asyncTree: NodeLookup = (pointer) => asyncEntries.get(pointer); -describe('getAsync3Polarity', () => { +describe('getAsync3Direction', () => { const usage = new UsageIndex( [ { site: '#/operations/onSignup', target: '#/channels/signups' }, @@ -207,24 +207,24 @@ describe('getAsync3Polarity', () => { 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( + expect(getAsync3Direction('#/channels/signups/messages/signup/payload', usage, asyncTree)).toBe( 'request' ); - expect(getAsync3Polarity('#/channels/receipts/messages/receipt', usage, asyncTree)).toBe( + expect(getAsync3Direction('#/channels/receipts/messages/receipt', usage, asyncTree)).toBe( 'response' ); }); it('flips the direction for a reply channel', () => { - expect(getAsync3Polarity('#/channels/orders', usage, asyncTree)).toBe('response'); + expect(getAsync3Direction('#/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'); + expect(getAsync3Direction('#/operations/sendReceipt', usage, asyncTree)).toBe('response'); }); it('returns neutral for a channel no operation references', () => { - expect(getAsync3Polarity('#/channels/signups', new UsageIndex([], asyncTree), asyncTree)).toBe( + expect(getAsync3Direction('#/channels/signups', new UsageIndex([], asyncTree), asyncTree)).toBe( 'neutral' ); }); @@ -237,7 +237,7 @@ describe('getAsync3Polarity', () => { ], asyncTree ); - expect(getAsync3Polarity('#/channels/signups/messages/signup', recursive, asyncTree)).toBe( + expect(getAsync3Direction('#/channels/signups/messages/signup', recursive, asyncTree)).toBe( 'neutral' ); }); diff --git a/packages/core/src/diff/detect.ts b/packages/core/src/diff/detect.ts index 12018acd35..616e8f6273 100644 --- a/packages/core/src/diff/detect.ts +++ b/packages/core/src/diff/detect.ts @@ -1,38 +1,49 @@ import type { NodeLookup } from '../node-map/chain.js'; import type { NodeEntry } from '../node-map/types.js'; import type { SpecVersion } from '../oas-types.js'; -import { getAsync3Polarity, getOas3Polarity, type PolarityResolver } from './direction.js'; +import { getAsync3Direction, getOas3Direction, type DirectionResolver } from './direction.js'; import { async3Rules } from './rules/async3.js'; import { oas3Rules } from './rules/oas3.js'; import { - compatRank, + displaySide, type Change, - type ChangeVerdict, - type DiffRuleRegistry, + type DiffReport, + type DiffRule, + type DiffVisitor, + type Direction, type JudgedChange, - type Polarity, + type RuleVerdict, } from './types.js'; import type { UsageIndex } from './usage.js'; +export interface InitializedDiffRule { + id: string; + visitor: DiffVisitor; +} + +export function initDiffRules(rules: Record): InitializedDiffRule[] { + return Object.entries(rules).map(([id, rule]) => ({ id, visitor: rule() })); +} + /** - * 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. + * What a specification family brings to detection: the rules to run, and the way that + * family states which direction the data in a node travels. */ const SPECS: Partial< - Record + Record; directionOf: DirectionResolver }> > = { - oas3_0: { rules: oas3Rules, polarityOf: getOas3Polarity }, - oas3_1: { rules: oas3Rules, polarityOf: getOas3Polarity }, + oas3_0: { rules: oas3Rules, directionOf: getOas3Direction }, + oas3_1: { rules: oas3Rules, directionOf: getOas3Direction }, // 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 }, + oas3_2: { rules: oas3Rules, directionOf: getOas3Direction }, + async3: { rules: async3Rules, directionOf: getAsync3Direction }, }; -function expandPolarity(polarity: Polarity): Polarity[] { - return polarity === 'both' ? ['request', 'response'] : [polarity]; +function expandDirection(direction: Direction): Direction[] { + return direction === 'both' ? ['request', 'response'] : [direction]; } -export function classifyChanges(opts: { +export function detectBreakingChanges(opts: { changes: Change[]; specVersion: SpecVersion; base: Map; @@ -47,35 +58,36 @@ export function classifyChanges(opts: { return changes.map((change) => ({ ...change, compat: 'non-breaking' as const, verdicts: [] })); } + const rules = initDiffRules(spec.rules); // A removed node only exists in the base, an added one only in the revision. const nodeAt: NodeLookup = (key) => revision.get(key) ?? base.get(key); return changes.map((change) => { - const rules = spec.rules[change.typeName] ?? []; - const verdicts: ChangeVerdict[] = []; + const verdicts: RuleVerdict[] = []; - for (const polarity of expandPolarity(spec.polarityOf(change.key, usage, nodeAt))) { - const ctx = { - polarity, - specVersion, - base: (key: string) => base.get(key), - revision: (key: string) => revision.get(key), - 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 }); - } + for (const direction of expandDirection(spec.directionOf(change.key, usage, nodeAt))) { + for (const { id, visitor } of rules) { + const report = ({ message, location = displaySide(change).location }: DiffReport) => { + // a node used both ways is visited twice; the same finding is kept once + if (!verdicts.some((verdict) => verdict.ruleId === id && verdict.message === message)) { + verdicts.push({ ruleId: id, message, location }); + } + }; + const context = { + report, + direction, + specVersion, + base: (key: string) => base.get(key), + revision: (key: string) => revision.get(key), + nodeAt, + }; + visitor[change.typeName]?.(change, context); + visitor.any?.(change, context); } } - verdicts.sort( - (a, b) => compatRank(b.compat) - compatRank(a.compat) || a.ruleId.localeCompare(b.ruleId) - ); + verdicts.sort((left, right) => left.ruleId.localeCompare(right.ruleId)); - return { ...change, compat: verdicts[0]?.compat ?? 'non-breaking', verdicts }; + return { ...change, compat: verdicts.length ? 'breaking' : 'non-breaking', verdicts }; }); } diff --git a/packages/core/src/diff/direction.ts b/packages/core/src/diff/direction.ts index dfa0f2e448..04cbd3e6df 100644 --- a/packages/core/src/diff/direction.ts +++ b/packages/core/src/diff/direction.ts @@ -1,15 +1,15 @@ import { ancestorChain, getComponentRoot, type NodeLookup } from '../node-map/chain.js'; import type { NodeEntry } from '../node-map/types.js'; -import type { Polarity } from './types.js'; +import type { Direction } from './types.js'; import type { UsageIndex } from './usage.js'; /** How one specification family decides which way the data in a node travels. */ -export type PolarityResolver = (key: string, usage: UsageIndex, lookup: NodeLookup) => Polarity; +export type DirectionResolver = (key: string, usage: UsageIndex, lookup: NodeLookup) => Direction; -function opposite(polarity: Polarity): Polarity { - if (polarity === 'request') return 'response'; - if (polarity === 'response') return 'request'; - return polarity; +function opposite(direction: Direction): Direction { + if (direction === 'request') return 'response'; + if (direction === 'response') return 'request'; + return direction; } // Direction comes from the node types the type tree assigns, not from pointer text: @@ -27,7 +27,7 @@ const REQUEST_TYPES = new Set(['RequestBody', 'Parameter', 'ParameterList']); // callback declared inside a callback pointing the right way. const INVERTING_TYPES = new Set(['CallbacksMap', 'WebhooksMap']); -function getOas3SitePolarity(key: string, lookup: NodeLookup): Polarity { +function getOas3SiteDirection(key: string, lookup: NodeLookup): Direction { let inverted = false; for (const { typeName } of ancestorChain(key, lookup)) { @@ -43,15 +43,15 @@ function getOas3SitePolarity(key: string, lookup: NodeLookup): Polarity { return 'neutral'; } -export const getOas3Polarity: PolarityResolver = (key, usage, lookup) => { +export const getOas3Direction: DirectionResolver = (key, 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(key, lookup); if (componentRoot) { - return usage.polarityOf(componentRoot, (site) => getOas3SitePolarity(site, lookup)); + return usage.directionOf(componentRoot, (site) => getOas3SiteDirection(site, lookup)); } - return getOas3SitePolarity(key, lookup); + return getOas3SiteDirection(key, lookup); }; /** @@ -59,20 +59,20 @@ export const getOas3Polarity: PolarityResolver = (key, usage, lookup) => { * 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 { +function actionDirection(action: unknown): Direction { if (action === 'receive') return 'request'; if (action === 'send') return 'response'; return 'neutral'; } -function getOperationPolarity(chain: NodeEntry[]): Polarity { +function getOperationDirection(chain: NodeEntry[]): Direction { const operation = [...chain].reverse().find((entry) => entry.typeName === 'Operation'); if (!operation) return 'neutral'; - const polarity = actionPolarity(operation.properties.action); + const direction = actionDirection(operation.properties.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; + return underReply ? opposite(direction) : direction; } /** @@ -80,30 +80,30 @@ function getOperationPolarity(chain: NodeEntry[]): Polarity { * `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 = (key, usage, lookup) => - resolveAsync3Polarity(key, usage, lookup, new Set()); +export const getAsync3Direction: DirectionResolver = (key, usage, lookup) => + resolveAsync3Direction(key, usage, lookup, new Set()); -function resolveAsync3Polarity( +function resolveAsync3Direction( key: string, usage: UsageIndex, lookup: NodeLookup, resolving: Set -): Polarity { +): Direction { // A payload that refers back into its own channel would otherwise resolve forever. if (resolving.has(key)) return 'neutral'; resolving.add(key); const chain = ancestorChain(key, lookup); - const own = getOperationPolarity(chain); + const own = getOperationDirection(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.key, (site) => - resolveAsync3Polarity(site, usage, lookup, resolving) + const direction = usage.directionOf(entry.key, (site) => + resolveAsync3Direction(site, usage, lookup, resolving) ); - if (polarity !== 'neutral') return polarity; + if (direction !== 'neutral') return direction; } return 'neutral'; diff --git a/packages/core/src/diff/format/__tests__/format.test.ts b/packages/core/src/diff/format/__tests__/format.test.ts index de650cc754..326c78228b 100644 --- a/packages/core/src/diff/format/__tests__/format.test.ts +++ b/packages/core/src/diff/format/__tests__/format.test.ts @@ -85,7 +85,11 @@ const RESULT: DiffResult = { }, compat: 'breaking', verdicts: [ - { ruleId: 'operation-removed', compat: 'breaking', message: 'Operation was removed.' }, + { + ruleId: 'operation-removed', + message: 'Operation was removed.', + location: at(base, '#/paths/~1pets/delete'), + }, ], }, { @@ -105,8 +109,8 @@ const RESULT: DiffResult = { verdicts: [ { ruleId: 'parameter-became-required', - compat: 'breaking', message: 'Parameter became required.', + location: at(revision, '#/paths/~1pets/get/parameters/0/required'), }, ], }, @@ -133,10 +137,13 @@ const RESULT: DiffResult = { 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'.", + location: at( + revision, + '#/paths/~1pets/post/requestBody/content/application~1json/schema/pattern' + ), }, ], }, diff --git a/packages/core/src/diff/format/__tests__/problems.test.ts b/packages/core/src/diff/format/__tests__/problems.test.ts index 147685d5f9..6e77c569e7 100644 --- a/packages/core/src/diff/format/__tests__/problems.test.ts +++ b/packages/core/src/diff/format/__tests__/problems.test.ts @@ -19,7 +19,11 @@ const result: DiffResult = { base: { location: at(baseSource, '#/paths/~1pets/delete'), value: undefined }, compat: 'breaking', verdicts: [ - { compat: 'breaking', ruleId: 'operation-removed', message: 'Operation was removed.' }, + { + ruleId: 'operation-removed', + message: 'Operation was removed.', + location: at(baseSource, '#/paths/~1pets/delete'), + }, ], }, { @@ -38,9 +42,9 @@ const result: DiffResult = { compat: 'breaking', verdicts: [ { - compat: 'breaking', ruleId: 'parameter-became-required', message: 'Parameter became required.', + location: at(revisionSource, '#/paths/~1pets/get/parameters/0/required'), }, ], }, diff --git a/packages/core/src/diff/format/json.ts b/packages/core/src/diff/format/json.ts index ff8d7a1faf..3bcab77076 100644 --- a/packages/core/src/diff/format/json.ts +++ b/packages/core/src/diff/format/json.ts @@ -1,12 +1,5 @@ import { getLineColLocation } from '../../format/codeframes.js'; -import type { - Change, - ChangeVerdict, - Compat, - DiffResult, - JudgedChange, - LocatedNode, -} from '../types.js'; +import type { Change, Compat, DiffResult, JudgedChange, LocatedNode } from '../types.js'; export interface JsonLocatedNode { file: string; @@ -16,6 +9,11 @@ export interface JsonLocatedNode { value: unknown; } +export interface JsonVerdict { + ruleId: string; + message: string; +} + /** The wire shape: optional sides are the JSON reader's contract, the union stays internal. */ export interface JsonChange { key: string; @@ -23,7 +21,7 @@ export interface JsonChange { kind: Change['kind']; property?: string; compat: Compat; - verdicts: ChangeVerdict[]; + verdicts: JsonVerdict[]; base?: JsonLocatedNode; revision?: JsonLocatedNode; } @@ -47,7 +45,13 @@ function toJsonNode({ location, value }: LocatedNode): JsonLocatedNode { export function toJsonChange(change: JudgedChange): JsonChange { const { key, typeName, kind, compat, verdicts } = change; - const common = { key, typeName, kind, compat, verdicts }; + const common = { + key, + typeName, + kind, + compat, + verdicts: verdicts.map(({ ruleId, message }) => ({ ruleId, message })), + }; switch (change.kind) { case 'added': return { ...common, revision: toJsonNode(change.revision) }; diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index b2fb45d915..4fc6892a15 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -5,7 +5,7 @@ import { getTypes, type SpecVersion } from '../oas-types.js'; import type { Document } from '../resolve.js'; import { normalizeTypes } from '../types/index.js'; import { compareMaps } from './compare.js'; -import { classifyChanges } from './detect.js'; +import { detectBreakingChanges } from './detect.js'; import { identityOf } from './identity.js'; import type { DiffResult, DiffSummary } from './types.js'; import { UsageIndex } from './usage.js'; @@ -43,7 +43,7 @@ export function diffDocuments(opts: { const nodeAt = (key: string) => revisionMap.entries.get(key) ?? baseMap.entries.get(key); const usage = new UsageIndex([...baseMap.usageEdges, ...revisionMap.usageEdges], nodeAt); - const changes = classifyChanges({ + const changes = detectBreakingChanges({ changes: compareMaps(baseMap.entries, revisionMap.entries), specVersion: revisionVersion, base: baseMap.entries, diff --git a/packages/core/src/diff/types.ts b/packages/core/src/diff/types.ts index 1a7326515f..06c6e5c44e 100644 --- a/packages/core/src/diff/types.ts +++ b/packages/core/src/diff/types.ts @@ -28,13 +28,35 @@ export function displaySide(change: Change): LocatedNode { return change.kind === 'removed' ? change.base : change.revision; } -export interface ChangeVerdict { +/** Which way the data in a node travels; decides whether "accepts less" or "returns more" is the breaking side. */ +export type Direction = 'request' | 'response' | 'both' | 'neutral'; + +export interface DiffReport { + message: string; + /** Defaults to the change's display side; a rule may point at a finer node. */ + location?: Location; +} + +export interface DiffRuleContext { + report: (report: DiffReport) => void; + direction: Direction; // never 'both': a node used both ways is visited once per direction + specVersion: SpecVersion; + base: (key: string) => NodeEntry | undefined; + revision: (key: string) => NodeEntry | undefined; + nodeAt: (key: string) => NodeEntry | undefined; // either side, revision first +} + +export type DiffVisit = (change: Change, context: DiffRuleContext) => void; +export type DiffVisitor = Record; // keyed by node type name; `any` runs for every change +export type DiffRule = () => DiffVisitor; + +export interface RuleVerdict { ruleId: string; - compat: Compat; message: string; + location: Location; } -export type JudgedChange = Change & { compat: Compat; verdicts: ChangeVerdict[] }; +export type JudgedChange = Change & { compat: Compat; verdicts: RuleVerdict[] }; export interface DiffSummary { breaking: number; @@ -48,36 +70,8 @@ export interface DiffResult { changes: JudgedChange[]; } -export interface Verdict { - compat: Compat; - message: string; -} - -export type Polarity = 'request' | 'response' | 'both' | 'neutral'; - -export interface RuleContext { - polarity: Polarity; - specVersion: SpecVersion; - base: (key: string) => NodeEntry | undefined; - revision: (key: string) => NodeEntry | undefined; - /** Either side, revision first — for reading a node's own type or its ancestors. */ - nodeAt: (key: string) => NodeEntry | undefined; -} - -export interface DiffRule { - id: string; - description: string; - visit(change: Change, 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/core/src/diff/usage.ts b/packages/core/src/diff/usage.ts index 480bda9778..9373887eba 100644 --- a/packages/core/src/diff/usage.ts +++ b/packages/core/src/diff/usage.ts @@ -1,7 +1,7 @@ import { getComponentRoot, type NodeLookup } from '../node-map/chain.js'; -import type { Polarity } from './types.js'; +import type { Direction } from './types.js'; -export function mergePolarity(a: Polarity, b: Polarity): Polarity { +export function mergeDirections(a: Direction, b: Direction): Direction { if (a === b) return a; if (a === 'neutral') return b; if (b === 'neutral') return a; @@ -22,20 +22,20 @@ export class UsageIndex { } } - /** `resolveSitePolarity` receives the key of the node that holds the reference. */ - polarityOf(componentKey: string, resolveSitePolarity: (site: string) => Polarity): Polarity { + /** `resolveSiteDirection` receives the key of the node that holds the reference. */ + directionOf(componentKey: string, resolveSiteDirection: (site: string) => Direction): Direction { const seen = new Set(); - const visit = (key: string): Polarity => { + const visit = (key: string): Direction => { if (seen.has(key)) return 'neutral'; // cycle guard seen.add(key); - let result: Polarity = 'neutral'; + let result: Direction = 'neutral'; for (const site of this.sitesByTarget.get(key) ?? []) { // a ref site inside another component chains to that component's own usage const siteComponentRoot = getComponentRoot(site, this.lookup); - const sitePolarity = siteComponentRoot + const siteDirection = siteComponentRoot ? visit(siteComponentRoot) - : resolveSitePolarity(site); - result = mergePolarity(result, sitePolarity); + : resolveSiteDirection(site); + result = mergeDirections(result, siteDirection); if (result === 'both') return 'both'; } return result; From 72f5798285b8740a9cf44d65be5ae2c382a0c7c1 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:43:32 +0300 Subject: [PATCH 29/57] refactor(core): write diff rules as visitor factories that report through the context --- .../diff/__tests__/documented-rules.test.ts | 32 +-- .../core/src/diff/__tests__/rules.test.ts | 22 ++ packages/core/src/diff/rules/async3.ts | 35 +-- packages/core/src/diff/rules/channel.ts | 39 ++- packages/core/src/diff/rules/index.ts | 10 + packages/core/src/diff/rules/message.ts | 36 ++- packages/core/src/diff/rules/oas3.ts | 66 +++-- packages/core/src/diff/rules/operation.ts | 38 ++- packages/core/src/diff/rules/parameter.ts | 92 +++---- packages/core/src/diff/rules/ref.ts | 19 +- packages/core/src/diff/rules/request-body.ts | 29 +-- packages/core/src/diff/rules/response.ts | 50 ++-- packages/core/src/diff/rules/schema.ts | 229 +++++++----------- packages/core/src/diff/rules/security.ts | 68 +++--- packages/core/src/diff/rules/server.ts | 22 +- 15 files changed, 346 insertions(+), 441 deletions(-) create mode 100644 packages/core/src/diff/__tests__/rules.test.ts create mode 100644 packages/core/src/diff/rules/index.ts diff --git a/packages/core/src/diff/__tests__/documented-rules.test.ts b/packages/core/src/diff/__tests__/documented-rules.test.ts index 48134b88a1..21bad435c6 100644 --- a/packages/core/src/diff/__tests__/documented-rules.test.ts +++ b/packages/core/src/diff/__tests__/documented-rules.test.ts @@ -2,48 +2,24 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { async3Rules } from '../rules/async3.js'; -import { oas3Rules } from '../rules/oas3.js'; -import type { DiffRule } from '../types.js'; +import { diffRuleIds } from '../rules/index.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]) -); +const documented = new Set([...docs.matchAll(RULE_TABLE_ROW)].map(([, ruleId]) => ruleId)); -// 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([]); + expect(diffRuleIds.filter((ruleId) => !documented.has(ruleId)).sort()).toEqual([]); }); it('lists no rule the command no longer has', () => { - const stale = [...documented.keys()].filter((ruleId) => !rulesById.has(ruleId)); - - expect(stale.sort()).toEqual([]); + expect([...documented].filter((ruleId) => !diffRuleIds.includes(ruleId)).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/core/src/diff/__tests__/rules.test.ts b/packages/core/src/diff/__tests__/rules.test.ts new file mode 100644 index 0000000000..a0248bdda4 --- /dev/null +++ b/packages/core/src/diff/__tests__/rules.test.ts @@ -0,0 +1,22 @@ +import { getTypes } from '../../oas-types.js'; +import { async3Rules, oas3Rules } from '../rules/index.js'; + +// A visitor key is a node type name; a typo (`HeaderMap` for `HeadersMap`) would make the +// rule silently never run, which no e2e fixture can tell from "nothing to report". +describe('diff rule registries', () => { + it.each([ + ['oas3_1', oas3Rules], + ['async3', async3Rules], + ] as const)( + '%s rules visit only node types that exist in that specification', + (specVersion, rules) => { + const typeNames = new Set(Object.keys(getTypes(specVersion))); + + const unknown = Object.values(rules) + .flatMap((rule) => Object.keys(rule())) + .filter((typeName) => typeName !== 'any' && !typeNames.has(typeName)); + + expect(unknown).toEqual([]); + } + ); +}); diff --git a/packages/core/src/diff/rules/async3.ts b/packages/core/src/diff/rules/async3.ts index f43e87bf06..da41270740 100644 --- a/packages/core/src/diff/rules/async3.ts +++ b/packages/core/src/diff/rules/async3.ts @@ -1,21 +1,22 @@ -import type { DiffRuleRegistry } from '../types.js'; -import { channelAddressChanged, channelRemoved } from './channel.js'; -import { messageContentTypeChanged, messageRemoved } from './message.js'; -import { operationActionChanged, operationRemoved } from './operation.js'; -import { refTargetChanged } from './ref.js'; +import type { DiffRule } from '../types.js'; +import { ChannelAddressChanged, ChannelRemoved } from './channel.js'; +import { MessageContentTypeChanged, MessageRemoved } from './message.js'; +import { OperationActionChanged, OperationRemoved } from './operation.js'; +import { RefTargetChanged } from './ref.js'; import { schemaRules } from './schema.js'; -import { serverRemoved } from './server.js'; +import { ServerRemoved } from './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], -}; +// channel (see `direction.ts`), so the whole schema rule set is reused as it is. +export const async3Rules = { + 'channel-removed': ChannelRemoved, + 'channel-address-changed': ChannelAddressChanged, + 'message-removed': MessageRemoved, + 'message-content-type-changed': MessageContentTypeChanged, + 'operation-removed': OperationRemoved, + 'operation-action-changed': OperationActionChanged, + 'server-removed': ServerRemoved, + 'ref-target-changed': RefTargetChanged, + ...schemaRules, +} satisfies Record; diff --git a/packages/core/src/diff/rules/channel.ts b/packages/core/src/diff/rules/channel.ts index 04a9fda692..caecde5fc5 100644 --- a/packages/core/src/diff/rules/channel.ts +++ b/packages/core/src/diff/rules/channel.ts @@ -1,30 +1,21 @@ -import { breaking, type DiffRule } from '../types.js'; +import 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.' - ); +// these rules do not read the direction. +export const ChannelRemoved: DiffRule = () => ({ + Channel(change, { report }) { + if (change.kind === 'removed') report({ message: 'The channel was removed.' }); + }, + NamedChannels(change, { report }) { + if (change.kind === 'removed') report({ message: 'Every channel was removed.' }); }, -}; +}); -export const channelAddressChanged: DiffRule = { - id: 'channel-address-changed', - description: 'The address is what clients publish to and subscribe on.', - visit(change) { +export const ChannelAddressChanged: DiffRule = () => ({ + Channel(change, { report }) { if (change.kind !== 'modified' || change.property !== 'address') return; - return breaking( - `The channel address changed from '${change.base.value}' to '${change.revision.value}'.` - ); + report({ + message: `The channel address changed from '${change.base.value}' to '${change.revision.value}'.`, + }); }, -}; +}); diff --git a/packages/core/src/diff/rules/index.ts b/packages/core/src/diff/rules/index.ts new file mode 100644 index 0000000000..e0af838b6b --- /dev/null +++ b/packages/core/src/diff/rules/index.ts @@ -0,0 +1,10 @@ +import { async3Rules } from './async3.js'; +import { oas3Rules } from './oas3.js'; + +export { async3Rules, oas3Rules }; + +export type DiffRuleId = keyof typeof oas3Rules | keyof typeof async3Rules; + +export const diffRuleIds: string[] = [ + ...new Set([...Object.keys(oas3Rules), ...Object.keys(async3Rules)]), +]; diff --git a/packages/core/src/diff/rules/message.ts b/packages/core/src/diff/rules/message.ts index 50bf6e327f..3192b60438 100644 --- a/packages/core/src/diff/rules/message.ts +++ b/packages/core/src/diff/rules/message.ts @@ -1,27 +1,19 @@ -import { breaking, type DiffRule } from '../types.js'; +import 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 MessageRemoved: DiffRule = () => ({ + Message(change, { report }) { + if (change.kind === 'removed') report({ message: 'The message was removed.' }); }, -}; + NamedMessages(change, { report }) { + if (change.kind === 'removed') report({ message: 'Every message of the channel 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) { +export const MessageContentTypeChanged: DiffRule = () => ({ + Message(change, { report }) { if (change.kind !== 'modified' || change.property !== 'contentType') return; - return breaking( - `The message content type changed from '${change.base.value}' to '${change.revision.value}'.` - ); + report({ + message: `The message content type changed from '${change.base.value}' to '${change.revision.value}'.`, + }); }, -}; +}); diff --git a/packages/core/src/diff/rules/oas3.ts b/packages/core/src/diff/rules/oas3.ts index 69dbdfcb7a..6801b35e03 100644 --- a/packages/core/src/diff/rules/oas3.ts +++ b/packages/core/src/diff/rules/oas3.ts @@ -1,40 +1,38 @@ -import type { DiffRuleRegistry } from '../types.js'; -import { operationRemoved, pathRemoved } from './operation.js'; +import type { DiffRule } from '../types.js'; +import { OperationRemoved, PathRemoved } from './operation.js'; import { - parameterAddedRequired, - parameterBecameRequired, - parameterRemoved, - parameterSerializationChanged, + ParameterAddedRequired, + ParameterBecameRequired, + ParameterRemoved, + ParameterSerializationChanged, } from './parameter.js'; -import { refTargetChanged } from './ref.js'; -import { requestBodyBecameRequired, requestBodyRemoved } from './request-body.js'; -import { mediaTypeRemoved, responseHeaderRemoved, responseRemoved } from './response.js'; +import { RefTargetChanged } from './ref.js'; +import { RequestBodyBecameRequired, RequestBodyRemoved } from './request-body.js'; +import { MediaTypeRemoved, ResponseHeaderRemoved, ResponseRemoved } from './response.js'; import { schemaRules } from './schema.js'; import { - securityRequirementAdded, - securitySchemeChanged, - securitySchemeRemoved, - securityScopesAdded, + SecurityRequirementAdded, + SecuritySchemeChanged, + SecuritySchemeRemoved, + SecurityScopesAdded, } from './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], -}; +export const oas3Rules = { + 'operation-removed': OperationRemoved, + 'path-removed': PathRemoved, + 'parameter-removed': ParameterRemoved, + 'parameter-added-required': ParameterAddedRequired, + 'parameter-became-required': ParameterBecameRequired, + 'parameter-serialization-changed': ParameterSerializationChanged, + 'request-body-removed': RequestBodyRemoved, + 'request-body-became-required': RequestBodyBecameRequired, + 'response-removed': ResponseRemoved, + 'response-header-removed': ResponseHeaderRemoved, + 'media-type-removed': MediaTypeRemoved, + 'security-requirement-added': SecurityRequirementAdded, + 'security-scopes-added': SecurityScopesAdded, + 'security-scheme-changed': SecuritySchemeChanged, + 'security-scheme-removed': SecuritySchemeRemoved, + 'ref-target-changed': RefTargetChanged, + ...schemaRules, +} satisfies Record; diff --git a/packages/core/src/diff/rules/operation.ts b/packages/core/src/diff/rules/operation.ts index 77a7b36252..4dd0422608 100644 --- a/packages/core/src/diff/rules/operation.ts +++ b/packages/core/src/diff/rules/operation.ts @@ -1,32 +1,24 @@ -import { breaking, type DiffRule } 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 OperationRemoved: DiffRule = () => ({ + Operation(change, { report }) { + if (change.kind === 'removed') report({ message: '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.'); +export const PathRemoved: DiffRule = () => ({ + PathItem(change, { report }) { + if (change.kind === 'removed') report({ message: '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) { +export const OperationActionChanged: DiffRule = () => ({ + Operation(change, { report }) { if (change.kind !== 'modified' || change.property !== 'action') return; - return breaking( - `The operation action changed from '${change.base.value}' to '${change.revision.value}'.` - ); + report({ + message: `The operation action changed from '${change.base.value}' to '${change.revision.value}'.`, + }); }, -}; +}); diff --git a/packages/core/src/diff/rules/parameter.ts b/packages/core/src/diff/rules/parameter.ts index 498687f19e..f99eb81e30 100644 --- a/packages/core/src/diff/rules/parameter.ts +++ b/packages/core/src/diff/rules/parameter.ts @@ -1,63 +1,65 @@ import { isPlainObject } from '../../utils/is-plain-object.js'; import { becameTrue } from '../constraints.js'; -import { breaking, type DiffRule } from '../types.js'; +import 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.' - ); +// 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 = () => ({ + Parameter(change, { report, direction }) { + if (change.kind === 'removed' && direction === 'request') { + report({ message: 'Parameter was removed.' }); + } + }, + ParameterList(change, { report, direction }) { + if (change.kind === 'removed' && direction === 'request') { + report({ message: 'Every parameter was removed.' }); + } }, -}; +}); + +function hasRequiredParameter(parameters: unknown[]): boolean { + return parameters.some((parameter) => isPlainObject(parameter) && parameter.required === true); +} -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.'); +export const ParameterAddedRequired: DiffRule = () => ({ + Parameter(change, { report, direction }) { + if (change.kind !== 'added' || direction !== 'request') return; + if (hasRequiredParameter([change.revision.value])) { + report({ message: 'A new required parameter was added.' }); } - return undefined; }, -}; + ParameterList(change, { report, direction }) { + if (change.kind !== 'added' || direction !== 'request') return; + if (Array.isArray(change.revision.value) && hasRequiredParameter(change.revision.value)) { + report({ message: '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.kind !== 'modified' || change.property !== 'required' || ctx.polarity !== 'request') +export const ParameterBecameRequired: DiffRule = () => ({ + Parameter(change, { report, direction }) { + if (change.kind !== 'modified' || change.property !== 'required' || direction !== 'request') return; if (becameTrue(change.base.value, change.revision.value)) { - return breaking('Parameter became required.'); + report({ message: '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.kind !== 'modified' || !SERIALIZATION.has(change.property)) return; - if (ctx.polarity !== 'request') return; - return breaking( - `Parameter \`${change.property}\` changed from '${change.base.value}' to '${change.revision.value}'.` - ); +export const ParameterSerializationChanged: DiffRule = () => ({ + Parameter(change, { report, direction }) { + if ( + change.kind !== 'modified' || + !SERIALIZATION.has(change.property) || + direction !== 'request' + ) + return; + report({ + message: `Parameter \`${change.property}\` changed from '${change.base.value}' to '${change.revision.value}'.`, + }); }, -}; +}); diff --git a/packages/core/src/diff/rules/ref.ts b/packages/core/src/diff/rules/ref.ts index c84bc86196..3e0590e456 100644 --- a/packages/core/src/diff/rules/ref.ts +++ b/packages/core/src/diff/rules/ref.ts @@ -1,5 +1,5 @@ import { isRef } from '../../ref-utils.js'; -import { breaking, type DiffRule } from '../types.js'; +import type { DiffRule } from '../types.js'; function describeTarget(value: unknown): string { if (isRef(value)) return `'${value.$ref}'`; @@ -8,17 +8,14 @@ function describeTarget(value: unknown): string { // Key-aligned comparison cannot verify whether two different targets are // content-equivalent — 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) { +export const RefTargetChanged: DiffRule = () => ({ + any(change, { report }) { if (change.kind !== 'modified') return; if (!isRef(change.base.value) && !isRef(change.revision.value)) return; - return breaking( - `The reference target changed from ${describeTarget(change.base.value)} to ${describeTarget( + report({ + message: `The reference target changed from ${describeTarget(change.base.value)} to ${describeTarget( change.revision.value - )}. The diff cannot check that the two targets are equivalent.` - ); + )}. The diff cannot check that the two targets are equivalent.`, + }); }, -}; +}); diff --git a/packages/core/src/diff/rules/request-body.ts b/packages/core/src/diff/rules/request-body.ts index 6c61b8423c..da91b8d856 100644 --- a/packages/core/src/diff/rules/request-body.ts +++ b/packages/core/src/diff/rules/request-body.ts @@ -1,25 +1,20 @@ import { becameTrue } from '../constraints.js'; -import { breaking, type DiffRule } from '../types.js'; +import 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.kind !== 'modified' || change.property !== 'required' || ctx.polarity !== 'request') +export const RequestBodyBecameRequired: DiffRule = () => ({ + RequestBody(change, { report, direction }) { + if (change.kind !== 'modified' || change.property !== 'required' || direction !== 'request') return; if (becameTrue(change.base.value, change.revision.value)) { - return breaking('The request body became required.'); + report({ message: '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.'); +export const RequestBodyRemoved: DiffRule = () => ({ + RequestBody(change, { report, direction }) { + if (change.kind === 'removed' && direction === 'request') { + report({ message: 'The request body was removed.' }); + } }, -}; +}); diff --git a/packages/core/src/diff/rules/response.ts b/packages/core/src/diff/rules/response.ts index 1340ed9721..cd5fdf9862 100644 --- a/packages/core/src/diff/rules/response.ts +++ b/packages/core/src/diff/rules/response.ts @@ -1,34 +1,28 @@ -import { breaking, type DiffRule } 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 ResponseRemoved: DiffRule = () => ({ + Response(change, { report }) { + if (change.kind === 'removed') report({ message: '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.'); +export const MediaTypeRemoved: DiffRule = () => ({ + MediaType(change, { report }) { + if (change.kind === 'removed') report({ message: '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.' - ); +// Dropping every header collapses into a single change on the map, dropping one lands on +// the header itself. +export const ResponseHeaderRemoved: DiffRule = () => ({ + Header(change, { report, direction }) { + if (change.kind === 'removed' && direction === 'response') { + report({ message: 'A response header was removed.' }); + } }, -}; + HeadersMap(change, { report, direction }) { + if (change.kind === 'removed' && direction === 'response') { + report({ message: 'The response headers were removed.' }); + } + }, +}); diff --git a/packages/core/src/diff/rules/schema.ts b/packages/core/src/diff/rules/schema.ts index 3078d5d2cd..ff0c7b8f90 100644 --- a/packages/core/src/diff/rules/schema.ts +++ b/packages/core/src/diff/rules/schema.ts @@ -7,116 +7,83 @@ import { missingItems, type ConstraintDirection, } from '../constraints.js'; -import { breaking, type DiffRule, type Polarity, type Verdict } from '../types.js'; +import type { DiffRule, Direction } 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; +function breaks(moved: ConstraintDirection, direction: Direction): boolean { + return ( + (moved === 'tighter' && direction === 'request') || + (moved === 'looser' && direction === 'response') + ); } -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) { +export const SchemaTypeChanged: DiffRule = () => ({ + Schema(change, { report, direction, base, revision }) { if (change.kind !== 'modified' || 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.key)?.properties.nullable); - const after = effectiveTypes( - change.revision.value, - ctx.revision(change.key)?.properties.nullable - ); + const before = effectiveTypes(change.base.value, base(change.key)?.properties.nullable); + const after = effectiveTypes(change.revision.value, revision(change.key)?.properties.nullable); const described = `from '${[...before].join(' | ')}' to '${[...after].join(' | ')}'`; - if (ctx.polarity === 'request' && isTypeSetNarrowed(before, after)) { - return breaking(`Schema type narrowed ${described}.`); + if (direction === 'request' && isTypeSetNarrowed(before, after)) { + report({ message: `Schema type narrowed ${described}.` }); } - if (ctx.polarity === 'response' && isTypeSetWidened(before, after)) { - return breaking(`Schema type widened ${described}.`); + if (direction === 'response' && isTypeSetWidened(before, after)) { + report({ message: `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.kind !== 'modified' || change.property !== 'enum' || ctx.polarity !== 'request') - return; +export const EnumValuesRemoved: DiffRule = () => ({ + Schema(change, { report, direction }) { + if (change.kind !== 'modified' || change.property !== 'enum' || direction !== 'request') return; const removed = missingItems(change.base.value, change.revision.value); - if (removed.length) { - return breaking(`Enum values removed: ${removed.join(', ')}.`); - } - return undefined; + if (removed.length) report({ message: `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.kind !== 'modified' || change.property !== 'enum' || ctx.polarity !== 'response') +export const EnumValuesAdded: DiffRule = () => ({ + Schema(change, { report, direction }) { + if (change.kind !== 'modified' || change.property !== 'enum' || direction !== 'response') return; const added = addedItems(change.base.value, change.revision.value); - if (added.length) { - return breaking(`Enum values added: ${added.join(', ')}.`); - } - return undefined; + if (added.length) report({ message: `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.kind !== 'modified' || change.property !== 'required' || ctx.polarity !== 'request') +export const RequiredPropertiesAdded: DiffRule = () => ({ + Schema(change, { report, direction }) { + if (change.kind !== 'modified' || change.property !== 'required' || direction !== 'request') return; const added = addedItems(change.base.value, change.revision.value); - if (added.length) { - return breaking(`Properties became required: ${added.join(', ')}.`); - } - return undefined; + if (added.length) report({ message: `Properties became required: ${added.join(', ')}.` }); }, -}; - -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.kind !== 'modified' || change.property !== 'required' || ctx.polarity !== 'response') +}); + +export const RequiredPropertiesRemoved: DiffRule = () => ({ + Schema(change, { report, direction }) { + if (change.kind !== 'modified' || change.property !== 'required' || direction !== '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; + if (removed.length) + report({ message: `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; +export const PropertyRemovedFromResponse: DiffRule = () => ({ + Schema(change, { report, direction, nodeAt }) { + if (change.kind !== 'removed' || direction !== 'response') return; // Only a member of a `properties` map counts; a subschema of `oneOf` does not. - const parentKey = ctx.nodeAt(change.key)?.parentKey; - if (!parentKey || ctx.nodeAt(parentKey)?.typeName !== 'SchemaProperties') return; - return breaking('Schema property was removed.'); + const parentKey = nodeAt(change.key)?.parentKey; + if (!parentKey || nodeAt(parentKey)?.typeName !== 'SchemaProperties') return; + report({ message: 'Schema property was removed.' }); }, -}; +}); function describeConstraint(property: string, before: unknown, after: unknown): string { if (before === undefined) return `\`${property}\` was added with value '${after}'.`; @@ -126,64 +93,45 @@ function describeConstraint(property: string, before: unknown, after: unknown): /** * 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 + * moved in, together with the node's direction, 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.kind !== 'modified' || !properties.has(change.property)) return; +function constraintRule(properties: string[]): DiffRule { + const watched = new Set(properties); + return () => ({ + Schema(change, { report, direction }) { + if (change.kind !== 'modified' || !watched.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) - ); + if (breaks(constraintDirection(change.property, before, after), direction)) { + report({ message: 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'], -}); +export const NumericRangeChanged = constraintRule([ + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + 'multipleOf', +]); +export const StringLengthChanged = constraintRule(['minLength', 'maxLength', 'pattern']); +export const SchemaFormatChanged = constraintRule(['format']); +export const AdditionalPropertiesChanged = constraintRule(['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. +// constraints, so adding one accepts less. A `SchemaList` keeps the walker's key, so +// the combinator is the last segment of its key. 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) { +export const SchemaCombinatorChanged: DiffRule = () => ({ + Schema(change, { report, direction, nodeAt }) { if (change.kind === 'modified') return; - const parentKey = ctx.nodeAt(change.key)?.parentKey; - const parent = parentKey ? ctx.nodeAt(parentKey) : undefined; + const parentKey = nodeAt(change.key)?.parentKey; + const parent = parentKey ? nodeAt(parentKey) : undefined; if (parent?.typeName !== 'SchemaList') return; const combinator = parent.key.slice(parent.key.lastIndexOf('/') + 1); @@ -191,29 +139,26 @@ export const schemaCombinatorChanged: DiffRule = { 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'}.` - ); + if (breaks(acceptsLess ? 'tighter' : 'looser', direction)) { + report({ message: `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, -]; +export const schemaRules = { + 'schema-type-changed': SchemaTypeChanged, + 'enum-values-removed': EnumValuesRemoved, + 'enum-values-added': EnumValuesAdded, + 'required-properties-added': RequiredPropertiesAdded, + 'required-properties-removed': RequiredPropertiesRemoved, + 'property-removed-from-response': PropertyRemovedFromResponse, + 'numeric-range-changed': NumericRangeChanged, + 'string-length-changed': StringLengthChanged, + 'schema-format-changed': SchemaFormatChanged, + 'additional-properties-changed': AdditionalPropertiesChanged, + 'schema-combinator-changed': SchemaCombinatorChanged, +} satisfies Record; diff --git a/packages/core/src/diff/rules/security.ts b/packages/core/src/diff/rules/security.ts index 8eb2861d28..8396527136 100644 --- a/packages/core/src/diff/rules/security.ts +++ b/packages/core/src/diff/rules/security.ts @@ -1,32 +1,32 @@ import { addedItems } from '../constraints.js'; -import { breaking, type Change, type DiffRule, type RuleContext } from '../types.js'; +import type { Change, DiffRule, DiffRuleContext } 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. +// the direction: 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: Change, ctx: RuleContext): boolean { - const parentKey = ctx.nodeAt(change.key)?.parentKey; - const baseList = parentKey ? ctx.base(parentKey)?.raw : undefined; +function fillsAnEmptyList(change: Change, { nodeAt, base }: DiffRuleContext): boolean { + const parentKey = nodeAt(change.key)?.parentKey; + const baseList = parentKey ? base(parentKey)?.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 +// 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.'); +export const SecurityRequirementAdded: DiffRule = () => ({ + SecurityRequirementList(change, { report }) { + if (change.kind === 'added') report({ message: 'The API now requires authentication.' }); }, -}; + SecurityRequirement(change, context) { + if (change.kind === 'added' && fillsAnEmptyList(change, context)) { + context.report({ message: 'The API now requires authentication.' }); + } + }, +}); const SCHEME_IDENTITY = new Set([ 'type', @@ -37,41 +37,33 @@ const SCHEME_IDENTITY = new Set([ '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) { +export const SecuritySchemeChanged: DiffRule = () => ({ + SecurityScheme(change, { report, base, revision }) { if (change.kind !== 'modified' || !SCHEME_IDENTITY.has(change.property)) return; // Switching the scheme's `type` drags its other fields along (an apiKey has // `in`/`name`, a bearer has `scheme`), so the type change speaks for them all. - const typeChanged = - ctx.base(change.key)?.properties.type !== ctx.revision(change.key)?.properties.type; + const typeChanged = base(change.key)?.properties.type !== revision(change.key)?.properties.type; if (typeChanged && change.property !== 'type') return; - return breaking( - `Security scheme \`${change.property}\` changed from '${change.base.value}' to '${change.revision.value}'.` - ); + report({ + message: `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 SecuritySchemeRemoved: DiffRule = () => ({ + SecurityScheme(change, { report }) { + if (change.kind === 'removed') report({ message: '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) { +export const SecurityScopesAdded: DiffRule = () => ({ + SecurityRequirement(change, { report }) { // A requirement's properties are scheme names, and each value is its scope list. if (change.kind !== 'modified') return; const added = addedItems(change.base.value, change.revision.value); if (!added.length) return; - return breaking(`Scheme \`${change.property}\` requires new scopes: ${added.join(', ')}.`); + report({ message: `Scheme \`${change.property}\` requires new scopes: ${added.join(', ')}.` }); }, -}; +}); diff --git a/packages/core/src/diff/rules/server.ts b/packages/core/src/diff/rules/server.ts index 05eff95d5d..a8f07f74e2 100644 --- a/packages/core/src/diff/rules/server.ts +++ b/packages/core/src/diff/rules/server.ts @@ -1,14 +1,12 @@ -import { breaking, type DiffRule } from '../types.js'; +import 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.' - ); +// Dropping every server collapses into a single change on the map, dropping one lands on +// the server itself. +export const ServerRemoved: DiffRule = () => ({ + Server(change, { report }) { + if (change.kind === 'removed') report({ message: 'The server was removed.' }); }, -}; + ServerMap(change, { report }) { + if (change.kind === 'removed') report({ message: 'Every server was removed.' }); + }, +}); From 25e3cb1e460fbdc605115f743f5059da26906d05 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:06:41 +0300 Subject: [PATCH 30/57] feat(core): rate diff changes by semver impact from a configurable rule map --- .../core/src/diff/__tests__/detect.test.ts | 98 +++++++++++++++++-- .../src/diff/__tests__/diff-documents.test.ts | 6 +- .../core/src/diff/__tests__/impact.test.ts | 13 +++ packages/core/src/diff/detect.ts | 55 ++++++++--- packages/core/src/diff/index.ts | 30 ++++-- packages/core/src/diff/rules/index.ts | 40 ++++++++ packages/core/src/diff/types.ts | 35 +++++-- 7 files changed, 235 insertions(+), 42 deletions(-) create mode 100644 packages/core/src/diff/__tests__/impact.test.ts diff --git a/packages/core/src/diff/__tests__/detect.test.ts b/packages/core/src/diff/__tests__/detect.test.ts index 7ccd844a7e..6a07c4e40d 100644 --- a/packages/core/src/diff/__tests__/detect.test.ts +++ b/packages/core/src/diff/__tests__/detect.test.ts @@ -1,6 +1,7 @@ import { Location } from '../../ref-utils.js'; import { Source } from '../../resolve.js'; import { detectBreakingChanges, initDiffRules } from '../detect.js'; +import { recommendedDiffRules } from '../rules/index.js'; import type { Change, DiffRule } from '../types.js'; import { UsageIndex } from '../usage.js'; import { treeOf } from './tree.js'; @@ -17,7 +18,7 @@ const emptyMaps = { // What every finding passes through: which visitors run, what a report becomes, and how the // same finding under two directions is kept once. The rules themselves are covered by tests/e2e/diff. describe('detectBreakingChanges', () => { - it('leaves a change no rule reports on non-breaking with no verdicts', () => { + it('leaves a change no rule reports on patch with no verdicts', () => { const changes: Change[] = [ { key: '#/info', @@ -29,9 +30,14 @@ describe('detectBreakingChanges', () => { }, ]; - const [change] = detectBreakingChanges({ changes, specVersion: 'oas3_1', ...emptyMaps }); + const [change] = detectBreakingChanges({ + changes, + specVersion: 'oas3_1', + ruleMap: recommendedDiffRules, + ...emptyMaps, + }); - expect(change.compat).toBe('non-breaking'); + expect(change.impact).toBe('patch'); expect(change.verdicts).toEqual([]); }); @@ -45,9 +51,14 @@ describe('detectBreakingChanges', () => { }, ]; - const [change] = detectBreakingChanges({ changes, specVersion: 'async2', ...emptyMaps }); + const [change] = detectBreakingChanges({ + changes, + specVersion: 'async2', + ruleMap: recommendedDiffRules, + ...emptyMaps, + }); - expect(change.compat).toBe('non-breaking'); + expect(change.impact).toBe('patch'); }); it('stamps the rule id on a report and defaults its location to the display side', () => { @@ -60,12 +71,18 @@ describe('detectBreakingChanges', () => { }, ]; - const [change] = detectBreakingChanges({ changes, specVersion: 'oas3_1', ...emptyMaps }); + const [change] = detectBreakingChanges({ + changes, + specVersion: 'oas3_1', + ruleMap: recommendedDiffRules, + ...emptyMaps, + }); - expect(change.compat).toBe('breaking'); + expect(change.impact).toBe('major'); expect(change.verdicts).toEqual([ { ruleId: 'operation-removed', + impact: 'major', message: 'Operation was removed.', location: at('#/paths/~1x/delete'), }, @@ -121,6 +138,7 @@ describe('detectBreakingChanges', () => { base: entries, revision: entries, usage, + ruleMap: recommendedDiffRules, }); expect(change.verdicts.map((verdict) => verdict.ruleId)).toEqual([ @@ -128,6 +146,64 @@ describe('detectBreakingChanges', () => { 'enum-values-removed', ]); }); + + it('defaults an unjudged addition to minor and an unjudged modification to patch', () => { + const changes: Change[] = [ + { + key: '#/tags/{pets}', + kind: 'added', + typeName: 'Tag', + revision: { location: at('#/tags/0'), value: {} }, + }, + { + key: '#/info', + kind: 'modified', + property: 'title', + typeName: 'Info', + base: { location: at('#/info/title'), value: 'a' }, + revision: { location: at('#/info/title'), value: 'b' }, + }, + ]; + + const [added, modified] = detectBreakingChanges({ + changes, + specVersion: 'oas3_1', + ruleMap: recommendedDiffRules, + ...emptyMaps, + }); + + expect(added.impact).toBe('minor'); + expect(modified.impact).toBe('patch'); + }); + + it('stamps the configured impact on a verdict and skips a rule set to off', () => { + const changes: Change[] = [ + { + key: '#/paths/~1x/delete', + kind: 'removed', + typeName: 'Operation', + base: { location: at('#/paths/~1x/delete'), value: {} }, + }, + ]; + + const [minor] = detectBreakingChanges({ + changes, + specVersion: 'oas3_1', + ruleMap: { 'operation-removed': 'minor' }, + ...emptyMaps, + }); + const [off] = detectBreakingChanges({ + changes, + specVersion: 'oas3_1', + ruleMap: { 'operation-removed': 'off' }, + ...emptyMaps, + }); + + expect(minor.impact).toBe('minor'); + expect(minor.verdicts[0].impact).toBe('minor'); + expect(off.impact).toBe('patch'); + expect(off.verdicts).toEqual([]); + }); }); describe('initDiffRules', () => { @@ -135,7 +211,11 @@ describe('initDiffRules', () => { const rule: DiffRule = () => ({ Schema() {} }); expect( - initDiffRules({ 'my-rule': rule }).map(({ id, visitor }) => [id, Object.keys(visitor)]) - ).toEqual([['my-rule', ['Schema']]]); + initDiffRules({ 'my-rule': rule }, { 'my-rule': 'major' }).map(({ id, impact, visitor }) => [ + id, + impact, + Object.keys(visitor), + ]) + ).toEqual([['my-rule', 'major', ['Schema']]]); }); }); diff --git a/packages/core/src/diff/__tests__/diff-documents.test.ts b/packages/core/src/diff/__tests__/diff-documents.test.ts index f3e8635739..872e303833 100644 --- a/packages/core/src/diff/__tests__/diff-documents.test.ts +++ b/packages/core/src/diff/__tests__/diff-documents.test.ts @@ -53,7 +53,7 @@ function report(result: DiffResult): string { .join(' → '); const name = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key; return [ - `${change.compat} ${change.kind} ${name}`, + `${change.impact} ${change.kind} ${name}`, ...change.verdicts.map((verdict) => ` ${verdict.ruleId}: ${verdict.message}`), ` at ${at}`, ].join('\n'); @@ -83,7 +83,7 @@ describe('diffDocuments', () => { non-breaking modified #/paths/~1pets/get/responses/200 · description at base.yaml #/paths/~1pets/get/responses/200/description → rev.yaml #/paths/~1pets/get/responses/200/description" `); - expect(result.summary).toEqual({ breaking: 1, nonBreaking: 2 }); + expect(result.summary).toEqual({ major: 1, minor: 0, patch: 2 }); }); it('throws DiffError for different spec families', async () => { @@ -132,7 +132,7 @@ describe('diffDocuments', () => { non-breaking modified #/paths/~1pet~1{0}/get/parameters/{path:0} · name at base.yaml #/paths/~1pet~1{id}/get/parameters/0/name → rev.yaml #/paths/~1pet~1{petId}/get/parameters/0/name" `); - expect(result.summary.breaking).toBe(0); + expect(result.summary.major).toBe(0); }); it('keys two templates of the same shape in document order, suffixing the second', async () => { diff --git a/packages/core/src/diff/__tests__/impact.test.ts b/packages/core/src/diff/__tests__/impact.test.ts new file mode 100644 index 0000000000..6b8dbb753f --- /dev/null +++ b/packages/core/src/diff/__tests__/impact.test.ts @@ -0,0 +1,13 @@ +import { highestImpact, impactRank } from '../types.js'; + +describe('impact ladder', () => { + it('orders patch below minor below major', () => { + expect(impactRank('patch')).toBeLessThan(impactRank('minor')); + expect(impactRank('minor')).toBeLessThan(impactRank('major')); + }); + + it('picks the highest impact and none from an empty list', () => { + expect(highestImpact(['patch', 'major', 'minor'])).toBe('major'); + expect(highestImpact([])).toBeUndefined(); + }); +}); diff --git a/packages/core/src/diff/detect.ts b/packages/core/src/diff/detect.ts index 616e8f6273..fdb92463c6 100644 --- a/packages/core/src/diff/detect.ts +++ b/packages/core/src/diff/detect.ts @@ -3,14 +3,18 @@ import type { NodeEntry } from '../node-map/types.js'; import type { SpecVersion } from '../oas-types.js'; import { getAsync3Direction, getOas3Direction, type DirectionResolver } from './direction.js'; import { async3Rules } from './rules/async3.js'; +import type { DiffRuleMap } from './rules/index.js'; import { oas3Rules } from './rules/oas3.js'; import { displaySide, + highestImpact, + impactRank, type Change, type DiffReport, type DiffRule, type DiffVisitor, type Direction, + type Impact, type JudgedChange, type RuleVerdict, } from './types.js'; @@ -18,11 +22,23 @@ import type { UsageIndex } from './usage.js'; export interface InitializedDiffRule { id: string; + impact: Impact; visitor: DiffVisitor; } -export function initDiffRules(rules: Record): InitializedDiffRule[] { - return Object.entries(rules).map(([id, rule]) => ({ id, visitor: rule() })); +export function initDiffRules( + rules: Record, + ruleMap: Partial> +): InitializedDiffRule[] { + return Object.entries(rules).flatMap(([id, rule]) => { + const impact = ruleMap[id] ?? 'off'; + return impact === 'off' ? [] : [{ id, impact, visitor: rule() }]; + }); +} + +// What a change means when no rule spoke: something new is a minor, everything else a patch. +function unjudgedImpact(kind: Change['kind']): Impact { + return kind === 'added' ? 'minor' : 'patch'; } /** @@ -49,33 +65,40 @@ export function detectBreakingChanges(opts: { base: Map; revision: Map; usage: UsageIndex; + ruleMap: DiffRuleMap; }): JudgedChange[] { - const { changes, specVersion, base, revision, usage } = opts; + const { changes, specVersion, base, revision, usage, ruleMap } = 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, verdicts: [] })); + return changes.map((change) => ({ + ...change, + impact: unjudgedImpact(change.kind), + direction: 'neutral' as const, + verdicts: [], + })); } - const rules = initDiffRules(spec.rules); + const rules = initDiffRules(spec.rules, ruleMap); // A removed node only exists in the base, an added one only in the revision. const nodeAt: NodeLookup = (key) => revision.get(key) ?? base.get(key); return changes.map((change) => { const verdicts: RuleVerdict[] = []; + const direction = spec.directionOf(change.key, usage, nodeAt); - for (const direction of expandDirection(spec.directionOf(change.key, usage, nodeAt))) { - for (const { id, visitor } of rules) { + for (const expandedDirection of expandDirection(direction)) { + for (const { id, impact, visitor } of rules) { const report = ({ message, location = displaySide(change).location }: DiffReport) => { // a node used both ways is visited twice; the same finding is kept once if (!verdicts.some((verdict) => verdict.ruleId === id && verdict.message === message)) { - verdicts.push({ ruleId: id, message, location }); + verdicts.push({ ruleId: id, impact, message, location }); } }; const context = { report, - direction, + direction: expandedDirection, specVersion, base: (key: string) => base.get(key), revision: (key: string) => revision.get(key), @@ -86,8 +109,18 @@ export function detectBreakingChanges(opts: { } } - verdicts.sort((left, right) => left.ruleId.localeCompare(right.ruleId)); + verdicts.sort( + (left, right) => + impactRank(right.impact) - impactRank(left.impact) || + left.ruleId.localeCompare(right.ruleId) + ); - return { ...change, compat: verdicts.length ? 'breaking' : 'non-breaking', verdicts }; + return { + ...change, + impact: + highestImpact(verdicts.map((verdict) => verdict.impact)) ?? unjudgedImpact(change.kind), + direction, + verdicts, + }; }); } diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index 4fc6892a15..ecac69b6f4 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -7,11 +7,27 @@ import { normalizeTypes } from '../types/index.js'; import { compareMaps } from './compare.js'; import { detectBreakingChanges } from './detect.js'; import { identityOf } from './identity.js'; -import type { DiffResult, DiffSummary } from './types.js'; +import { recommendedDiffRules } from './rules/index.js'; +import { + highestImpact, + type DiffResult, + type DiffSummary, + type Impact, + type JudgedChange, +} from './types.js'; import { UsageIndex } from './usage.js'; export class DiffError extends Error {} +// The change to `info.version` is the bump being checked, so it does not count toward it. +function requiredBump(changes: JudgedChange[]): Impact | undefined { + const counted = changes.filter( + (change) => + !(change.key === '#/info' && change.kind === 'modified' && change.property === 'version') + ); + return highestImpact(counted.map((change) => change.impact)); +} + export function diffDocuments(opts: { base: Document; revision: Document; @@ -49,21 +65,17 @@ export function diffDocuments(opts: { base: baseMap.entries, revision: revisionMap.entries, usage, + ruleMap: recommendedDiffRules, }); - const summary = changes.reduce( - (acc, change) => { - if (change.compat === 'breaking') acc.breaking++; - else acc.nonBreaking++; - return acc; - }, - { breaking: 0, nonBreaking: 0 } - ); + const summary: DiffSummary = { major: 0, minor: 0, patch: 0 }; + for (const change of changes) summary[change.impact]++; return { version: '1', specVersions: { base: baseVersion, revision: revisionVersion }, summary, + bump: requiredBump(changes), changes, }; } diff --git a/packages/core/src/diff/rules/index.ts b/packages/core/src/diff/rules/index.ts index e0af838b6b..b864679c82 100644 --- a/packages/core/src/diff/rules/index.ts +++ b/packages/core/src/diff/rules/index.ts @@ -1,3 +1,4 @@ +import type { Impact } from '../types.js'; import { async3Rules } from './async3.js'; import { oas3Rules } from './oas3.js'; @@ -8,3 +9,42 @@ export type DiffRuleId = keyof typeof oas3Rules | keyof typeof async3Rules; export const diffRuleIds: string[] = [ ...new Set([...Object.keys(oas3Rules), ...Object.keys(async3Rules)]), ]; + +export type DiffRuleMap = Partial>; + +/** The built-in preset: every rule is a major, which is what "breaking" meant. */ +export const recommendedDiffRules: Record = { + 'additional-properties-changed': 'major', + 'channel-address-changed': 'major', + 'channel-removed': 'major', + 'enum-values-added': 'major', + 'enum-values-removed': 'major', + 'media-type-removed': 'major', + 'message-content-type-changed': 'major', + 'message-removed': 'major', + 'numeric-range-changed': 'major', + 'operation-action-changed': 'major', + 'operation-removed': 'major', + 'parameter-added-required': 'major', + 'parameter-became-required': 'major', + 'parameter-removed': 'major', + 'parameter-serialization-changed': 'major', + 'path-removed': 'major', + 'property-removed-from-response': 'major', + 'ref-target-changed': 'major', + 'request-body-became-required': 'major', + 'request-body-removed': 'major', + 'required-properties-added': 'major', + 'required-properties-removed': 'major', + 'response-header-removed': 'major', + 'response-removed': 'major', + 'schema-combinator-changed': 'major', + 'schema-format-changed': 'major', + 'schema-type-changed': 'major', + 'security-requirement-added': 'major', + 'security-scheme-changed': 'major', + 'security-scheme-removed': 'major', + 'security-scopes-added': 'major', + 'server-removed': 'major', + 'string-length-changed': 'major', +}; diff --git a/packages/core/src/diff/types.ts b/packages/core/src/diff/types.ts index 06c6e5c44e..4d451b211d 100644 --- a/packages/core/src/diff/types.ts +++ b/packages/core/src/diff/types.ts @@ -2,7 +2,21 @@ import type { NodeEntry } from '../node-map/types.js'; import type { SpecVersion } from '../oas-types.js'; import type { Location } from '../ref-utils.js'; -export type Compat = 'breaking' | 'non-breaking'; +/** Lowest first. An impact is the semver part a change requires to be bumped. */ +export const impacts = ['patch', 'minor', 'major'] as const; +export type Impact = (typeof impacts)[number]; + +export function impactRank(impact: Impact): number { + return impacts.indexOf(impact); +} + +export function highestImpact(candidates: Impact[]): Impact | undefined { + return candidates.reduce( + (highest, impact) => + highest === undefined || impactRank(impact) > impactRank(highest) ? impact : highest, + undefined + ); +} export interface LocatedNode { location: Location; @@ -52,26 +66,27 @@ export type DiffRule = () => DiffVisitor; export interface RuleVerdict { ruleId: string; + impact: Impact; message: string; location: Location; } -export type JudgedChange = Change & { compat: Compat; verdicts: RuleVerdict[] }; +export type JudgedChange = Change & { + impact: Impact; + direction: Direction; + verdicts: RuleVerdict[]; +}; export interface DiffSummary { - breaking: number; - nonBreaking: number; + major: number; + minor: number; + patch: number; } export interface DiffResult { version: '1'; specVersions: { base: SpecVersion; revision: SpecVersion }; summary: DiffSummary; + bump?: Impact; changes: JudgedChange[]; } - -const COMPAT_RANK: Record = { breaking: 1, 'non-breaking': 0 }; - -export function compatRank(compat: Compat): number { - return COMPAT_RANK[compat]; -} From c2600c80f205a81ad6636e174a252de15c1ade38 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:18:53 +0300 Subject: [PATCH 31/57] feat(core): add a small semver parse and bump utility --- .../core/src/utils/__tests__/semver.test.ts | 31 ++++++++++++++++ packages/core/src/utils/semver.ts | 35 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 packages/core/src/utils/__tests__/semver.test.ts create mode 100644 packages/core/src/utils/semver.ts diff --git a/packages/core/src/utils/__tests__/semver.test.ts b/packages/core/src/utils/__tests__/semver.test.ts new file mode 100644 index 0000000000..6dfe297fc3 --- /dev/null +++ b/packages/core/src/utils/__tests__/semver.test.ts @@ -0,0 +1,31 @@ +import { bumpSemver, formatSemver, parseSemver, semverBumpBetween } from '../semver.js'; + +describe('semver', () => { + it.each([ + ['1.2.3', { major: 1, minor: 2, patch: 3 }], + ['1.2.3-beta.1+build', { major: 1, minor: 2, patch: 3 }], + ['1.2', undefined], + ['v1.2.3', undefined], + [undefined, undefined], + ])('parses %s', (version, expected) => { + expect(parseSemver(version)).toEqual(expected); + }); + + it.each([ + ['1.2.3', '2.0.0', 'major'], + ['1.2.3', '1.3.0', 'minor'], + ['1.2.3', '1.2.4', 'patch'], + ['1.2.3', '1.2.3', undefined], + ['2.0.0', '1.9.9', undefined], + ])('reads the bump from %s to %s as %s', (from, to, expected) => { + expect(semverBumpBetween(parseSemver(from)!, parseSemver(to)!)).toBe(expected); + }); + + it.each([ + ['major', '2.0.0'], + ['minor', '1.3.0'], + ['patch', '1.2.4'], + ] as const)('bumps 1.2.3 by a %s to %s', (part, expected) => { + expect(formatSemver(bumpSemver(parseSemver('1.2.3')!, part))).toBe(expected); + }); +}); diff --git a/packages/core/src/utils/semver.ts b/packages/core/src/utils/semver.ts new file mode 100644 index 0000000000..6141e6a021 --- /dev/null +++ b/packages/core/src/utils/semver.ts @@ -0,0 +1,35 @@ +export type SemverPart = 'major' | 'minor' | 'patch'; + +export interface Semver { + major: number; + minor: number; + patch: number; +} + +// Pre-release and build metadata are accepted and ignored: only the three numbers are compared. +const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/; + +export function parseSemver(version: unknown): Semver | undefined { + const match = typeof version === 'string' ? version.match(SEMVER) : null; + if (!match) return undefined; + const [, major, minor, patch] = match; + return { major: Number(major), minor: Number(minor), patch: Number(patch) }; +} + +export function formatSemver({ major, minor, patch }: Semver): string { + return `${major}.${minor}.${patch}`; +} + +/** The first part that grew from `from` to `to`; `undefined` when nothing grew. */ +export function semverBumpBetween(from: Semver, to: Semver): SemverPart | undefined { + if (to.major > from.major) return 'major'; + if (to.major === from.major && to.minor > from.minor) return 'minor'; + if (to.major === from.major && to.minor === from.minor && to.patch > from.patch) return 'patch'; + return undefined; +} + +export function bumpSemver(version: Semver, part: SemverPart): Semver { + if (part === 'major') return { major: version.major + 1, minor: 0, patch: 0 }; + if (part === 'minor') return { ...version, minor: version.minor + 1, patch: 0 }; + return { ...version, patch: version.patch + 1 }; +} From 68c51ff37efad8e210cffff9c3be48685decc01d Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:40:04 +0300 Subject: [PATCH 32/57] feat(core): show the semver impact of every diff change in the report formats --- .../src/diff/__tests__/diff-documents.test.ts | 16 ++-- .../__tests__/__snapshots__/html-report.html | 64 ++++++++++----- .../src/diff/format/__tests__/format.test.ts | 68 +++++++++++----- .../diff/format/__tests__/problems.test.ts | 78 +++++++++++-------- packages/core/src/diff/format/html.ts | 27 +++---- packages/core/src/diff/format/json.ts | 27 +++++-- packages/core/src/diff/format/markdown.ts | 16 ++-- packages/core/src/diff/format/problems.ts | 62 ++++++++++----- packages/core/src/diff/format/stylish.ts | 43 +++++----- packages/core/src/index.ts | 21 ++++- 10 files changed, 277 insertions(+), 145 deletions(-) diff --git a/packages/core/src/diff/__tests__/diff-documents.test.ts b/packages/core/src/diff/__tests__/diff-documents.test.ts index 872e303833..704d35e3d1 100644 --- a/packages/core/src/diff/__tests__/diff-documents.test.ts +++ b/packages/core/src/diff/__tests__/diff-documents.test.ts @@ -75,15 +75,16 @@ describe('diffDocuments', () => { // (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 modified #/paths/~1pets/get/parameters/{query:limit} · required + "major modified #/paths/~1pets/get/parameters/{query:limit} · required parameter-became-required: Parameter became required. at base.yaml #/paths/~1pets/get/parameters/0 → rev.yaml #/paths/~1pets/get/parameters/1/required - non-breaking modified #/paths/~1pets/get/parameters/{query:limit}/schema · type + patch modified #/paths/~1pets/get/parameters/{query:limit}/schema · type at base.yaml #/paths/~1pets/get/parameters/0/schema/type → rev.yaml #/paths/~1pets/get/parameters/1/schema/type - non-breaking modified #/paths/~1pets/get/responses/200 · description + patch modified #/paths/~1pets/get/responses/200 · description at base.yaml #/paths/~1pets/get/responses/200/description → rev.yaml #/paths/~1pets/get/responses/200/description" `); expect(result.summary).toEqual({ major: 1, minor: 0, patch: 2 }); + expect(result.bump).toBe('major'); }); it('throws DiffError for different spec families', async () => { @@ -127,12 +128,13 @@ describe('diffDocuments', () => { // the path template and the parameter name are reported as changes of their own, // both keyed by the path's shape. expect(report(result)).toMatchInlineSnapshot(` - "non-breaking modified #/paths/~1pet~1{0} · path + "patch modified #/paths/~1pet~1{0} · path at base.yaml #/paths/~1pet~1{id} → rev.yaml #/paths/~1pet~1{petId} - non-breaking modified #/paths/~1pet~1{0}/get/parameters/{path:0} · name + patch modified #/paths/~1pet~1{0}/get/parameters/{path:0} · name at base.yaml #/paths/~1pet~1{id}/get/parameters/0/name → rev.yaml #/paths/~1pet~1{petId}/get/parameters/0/name" `); expect(result.summary.major).toBe(0); + expect(result.bump).toBe('patch'); }); it('keys two templates of the same shape in document order, suffixing the second', async () => { @@ -170,9 +172,9 @@ describe('diffDocuments', () => { const result = diffDocuments({ base, revision, config }); expect(report(result)).toMatchInlineSnapshot(` - "non-breaking modified #/paths/~1a~1{0}~1b · path + "patch modified #/paths/~1a~1{0}~1b · path at base.yaml #/paths/~1a~1{x}~1b → rev.yaml #/paths/~1a~1{y}~1b - non-breaking added #/paths/~1a~1{0}~1b#2 + minor added #/paths/~1a~1{0}~1b#2 at rev.yaml #/paths/~1a~1{z}~1b" `); }); diff --git a/packages/core/src/diff/format/__tests__/__snapshots__/html-report.html b/packages/core/src/diff/format/__tests__/__snapshots__/html-report.html index a9dad52384..939c3413c9 100644 --- a/packages/core/src/diff/format/__tests__/__snapshots__/html-report.html +++ b/packages/core/src/diff/format/__tests__/__snapshots__/html-report.html @@ -10,8 +10,9 @@ .change { border: 1px solid #e0e4e8; border-radius: 6px; margin: .5rem 0; padding: .25rem .75rem; } .change summary { cursor: pointer; display: flex; gap: .6rem; align-items: baseline; flex-wrap: wrap; } .badge { border-radius: 4px; padding: 0 .5rem; font-size: .8rem; color: #fff; } - .breaking .badge { background: #c0392b; } - .ok .badge { background: #1e8449; } + .major .badge { background: #c0392b; } + .minor .badge { background: #1e8449; } + .patch .badge { background: #9aa5b1; } .msg { color: #52606d; } .rules { color: #9aa5b1; font-size: .85rem; } pre { background: #f5f7fa; padding: .75rem; border-radius: 6px; overflow-x: auto; } @@ -21,14 +22,16 @@

API diff

- 3 breaking - 1 non-breaking + 3 major + 1 minor + 1 patch + requires a major bump oas3_1 → oas3_1

-
+
- breaking + major removed #/paths/~1pets/delete Operation was removed. operation-removed @@ -37,7 +40,7 @@

API diff

"base": { "file": "base.yaml", "pointer": "#/paths/~1pets/delete", - "line": 16, + "line": 17, "col": 7, "value": { "summary": "<script>alert(1)</script>" @@ -46,9 +49,9 @@

API diff

}
-
+
- breaking + major modified #/paths/~1pets/get/parameters/{query:limit} · required Parameter became required. parameter-became-required @@ -57,22 +60,22 @@

API diff

"base": { "file": "base.yaml", "pointer": "#/paths/~1pets/get/parameters/0/required", - "line": 6, + "line": 7, "col": 11 }, "revision": { "file": "revision.yaml", "pointer": "#/paths/~1pets/get/parameters/0/required", - "line": 8, + "line": 9, "col": 21, "value": true } }
-
+
- breaking + major modified #/paths/~1pets/post/requestBody/content/application~1json/schema · pattern `pattern` changed from 'a' to 'a|b'. string-length-changed @@ -81,23 +84,48 @@

API diff

"base": { "file": "base.yaml", "pointer": "#/paths/~1pets/post/requestBody/content/application~1json/schema/pattern", - "line": 14, + "line": 15, "col": 24, "value": "a" }, "revision": { "file": "revision.yaml", "pointer": "#/paths/~1pets/post/requestBody/content/application~1json/schema/pattern", - "line": 15, + "line": 16, "col": 24, "value": "a|b" } }
-
+
+ + patch + modified + #/paths/~1pets/get · summary + + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1pets/get/summary",
+    "line": 5,
+    "col": 16,
+    "value": "List pets"
+  },
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/paths/~1pets/get/summary",
+    "line": 5,
+    "col": 16,
+    "value": "List all pets"
+  }
+}
+
+ +
- non-breaking + minor added #/components/schemas/Pet @@ -106,7 +134,7 @@

API diff

"revision": { "file": "revision.yaml", "pointer": "#/components/schemas/Pet", - "line": 19, + "line": 20, "col": 7, "value": { "type": "object" diff --git a/packages/core/src/diff/format/__tests__/format.test.ts b/packages/core/src/diff/format/__tests__/format.test.ts index 326c78228b..b1d4f0dd24 100644 --- a/packages/core/src/diff/format/__tests__/format.test.ts +++ b/packages/core/src/diff/format/__tests__/format.test.ts @@ -22,6 +22,7 @@ const base = new Source( paths: /pets: get: + summary: List pets parameters: - name: limit in: query @@ -44,6 +45,7 @@ const revision = new Source( paths: /pets: get: + summary: List all pets parameters: - name: limit in: query @@ -73,7 +75,8 @@ const at = (source: Source, pointer: string) => new Location(source, pointer); const RESULT: DiffResult = { version: '1', specVersions: { base: 'oas3_1', revision: 'oas3_1' }, - summary: { breaking: 3, nonBreaking: 1 }, + summary: { major: 3, minor: 1, patch: 1 }, + bump: 'major', changes: [ { key: '#/paths/~1pets/delete', @@ -83,10 +86,12 @@ const RESULT: DiffResult = { location: at(base, '#/paths/~1pets/delete'), value: { summary: '' }, }, - compat: 'breaking', + impact: 'major', + direction: 'neutral', verdicts: [ { ruleId: 'operation-removed', + impact: 'major', message: 'Operation was removed.', location: at(base, '#/paths/~1pets/delete'), }, @@ -105,10 +110,12 @@ const RESULT: DiffResult = { location: at(revision, '#/paths/~1pets/get/parameters/0/required'), value: true, }, - compat: 'breaking', + impact: 'major', + direction: 'request', verdicts: [ { ruleId: 'parameter-became-required', + impact: 'major', message: 'Parameter became required.', location: at(revision, '#/paths/~1pets/get/parameters/0/required'), }, @@ -133,10 +140,12 @@ const RESULT: DiffResult = { ), value: 'a|b', }, - compat: 'breaking', + impact: 'major', + direction: 'request', verdicts: [ { ruleId: 'string-length-changed', + impact: 'major', // 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'.", @@ -147,6 +156,23 @@ const RESULT: DiffResult = { }, ], }, + { + key: '#/paths/~1pets/get', + property: 'summary', + kind: 'modified', + typeName: 'Operation', + base: { + location: at(base, '#/paths/~1pets/get/summary'), + value: 'List pets', + }, + revision: { + location: at(revision, '#/paths/~1pets/get/summary'), + value: 'List all pets', + }, + impact: 'patch', + direction: 'neutral', + verdicts: [], + }, { key: '#/components/schemas/Pet', kind: 'added', @@ -155,7 +181,8 @@ const RESULT: DiffResult = { location: at(revision, '#/components/schemas/Pet'), value: { type: 'object' }, }, - compat: 'non-breaking', + impact: 'minor', + direction: 'neutral', verdicts: [], }, ], @@ -166,25 +193,27 @@ describe('stylishDiff', () => { // vitest.config.ts forces FORCE_COLOR=1, so the ANSI codes are stripped here. expect(stripColors(stylishDiff(RESULT))).toMatchInlineSnapshot(` "components - ✔ non-breaking added components/schemas/Pet - at revision.yaml:19:7 + ✔ minor added components/schemas/Pet + at revision.yaml:20:7 DELETE /pets - ✖ breaking removed paths · /pets · delete + ✖ major removed paths · /pets · delete Operation was removed. (operation-removed) - at base.yaml:16:7 + at base.yaml:17:7 GET /pets - ✖ breaking modified parameters/{query:limit} · required + ✖ major modified parameters/{query:limit} · required Parameter became required. (parameter-became-required) - at revision.yaml:8:21 + at revision.yaml:9:21 + · patch modified summary + at revision.yaml:5:16 POST /pets - ✖ breaking modified requestBody/content/application~1json/schema · pattern + ✖ major modified requestBody/content/application~1json/schema · pattern \`pattern\` changed from 'a' to 'a|b'. (string-length-changed) - at revision.yaml:15:24 + at revision.yaml:16:24 - 3 breaking, 1 non-breaking." + 3 major, 1 minor, 1 patch." `); }); }); @@ -194,14 +223,15 @@ describe('markdownDiff', () => { expect(markdownDiff(RESULT)).toMatchInlineSnapshot(` "## API diff - **3** breaking · **1** non-breaking + **3** major · **1** minor · **1** patch · requires a **major** bump | Impact | Change | Location | Details | | --- | --- | --- | --- | - | 🔴 breaking | removed | \`#/paths/~1pets/delete\` | Operation was removed. \`operation-removed\` | - | 🔴 breaking | modified | \`#/paths/~1pets/get/parameters/{query:limit} · required\` | Parameter became required. \`parameter-became-required\` | - | 🔴 breaking | modified | \`#/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\` | |" + | 🔴 major | removed | \`#/paths/~1pets/delete\` | Operation was removed. \`operation-removed\` | + | 🔴 major | modified | \`#/paths/~1pets/get/parameters/{query:limit} · required\` | Parameter became required. \`parameter-became-required\` | + | 🔴 major | modified | \`#/paths/~1pets/post/requestBody/content/application~1json/schema · pattern\` | \\\`pattern\\\` changed from 'a' to 'a\\|b'. \`string-length-changed\` | + | ⚪ patch | modified | \`#/paths/~1pets/get · summary\` | | + | 🟢 minor | added | \`#/components/schemas/Pet\` | |" `); }); }); diff --git a/packages/core/src/diff/format/__tests__/problems.test.ts b/packages/core/src/diff/format/__tests__/problems.test.ts index 6e77c569e7..2e133c5e43 100644 --- a/packages/core/src/diff/format/__tests__/problems.test.ts +++ b/packages/core/src/diff/format/__tests__/problems.test.ts @@ -1,7 +1,7 @@ import { Location } from '../../../ref-utils.js'; import { Source } from '../../../resolve.js'; import type { DiffResult } from '../../types.js'; -import { breakingChangesToProblems } from '../problems.js'; +import { diffToProblems } from '../problems.js'; const baseSource = new Source('base.yaml', 'openapi: 3.1.0\n'); const revisionSource = new Source('revision.yaml', 'openapi: 3.1.0\n'); @@ -10,22 +10,9 @@ const at = (source: Source, pointer: string) => new Location(source, pointer); const result: DiffResult = { version: '1', specVersions: { base: 'oas3_1', revision: 'oas3_1' }, - summary: { breaking: 2, nonBreaking: 1 }, + summary: { major: 1, minor: 1, patch: 1 }, + bump: 'major', changes: [ - { - key: '#/paths/~1pets/delete', - kind: 'removed', - typeName: 'Operation', - base: { location: at(baseSource, '#/paths/~1pets/delete'), value: undefined }, - compat: 'breaking', - verdicts: [ - { - ruleId: 'operation-removed', - message: 'Operation was removed.', - location: at(baseSource, '#/paths/~1pets/delete'), - }, - ], - }, { key: '#/paths/~1pets/get/parameters/{query:limit}', property: 'required', @@ -39,15 +26,35 @@ const result: DiffResult = { location: at(revisionSource, '#/paths/~1pets/get/parameters/0/required'), value: true, }, - compat: 'breaking', + impact: 'major', + direction: 'request', verdicts: [ { ruleId: 'parameter-became-required', + impact: 'major', message: 'Parameter became required.', location: at(revisionSource, '#/paths/~1pets/get/parameters/0/required'), }, + { + ruleId: 'parameter-serialization-changed', + impact: 'major', + message: 'Parameter serialization changed.', + location: at(revisionSource, '#/paths/~1pets/get/parameters/0/style'), + }, ], }, + { + key: '#/components/schemas/Pet', + kind: 'added', + typeName: 'Schema', + revision: { + location: at(revisionSource, '#/components/schemas/Pet'), + value: { type: 'object' }, + }, + impact: 'minor', + direction: 'neutral', + verdicts: [], + }, { key: '#/info', property: 'version', @@ -55,19 +62,21 @@ const result: DiffResult = { typeName: 'Info', base: { location: at(baseSource, '#/info/version'), value: '1.0.0' }, revision: { location: at(revisionSource, '#/info/version'), value: '1.0.1' }, - compat: 'non-breaking', + impact: 'patch', + direction: 'neutral', verdicts: [], }, ], }; -describe('breakingChangesToProblems', () => { - it('describes each breaking change the way a lint problem is described', () => { - const problems = breakingChangesToProblems(result); +describe('diffToProblems', () => { + it('maps a major impact to error and a minor impact to warn, and drops the patch', () => { + const problems = diffToProblems(result); - // 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. + // Both verdicts on the major change map to their own problem, at the verdict's own + // location, with the base side attached as `from`. The unjudged minor addition + // falls back to one problem for the whole change. The patch change carries no + // severity a lint problem can use, so nothing is reported for it. expect( problems.map((problem) => ({ severity: problem.severity, @@ -78,13 +87,6 @@ describe('breakingChangesToProblems', () => { })) ).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", @@ -92,6 +94,20 @@ describe('breakingChangesToProblems', () => { "ruleId": "parameter-became-required", "severity": "error", }, + { + "at": "revision.yaml#/paths/~1pets/get/parameters/0/style", + "from": "base.yaml#/paths/~1pets/get/parameters/0/required", + "message": "Parameter serialization changed.", + "ruleId": "parameter-serialization-changed", + "severity": "error", + }, + { + "at": "revision.yaml#/components/schemas/Pet", + "from": undefined, + "message": "added #/components/schemas/Pet", + "ruleId": "diff", + "severity": "warn", + }, ] `); }); diff --git a/packages/core/src/diff/format/html.ts b/packages/core/src/diff/format/html.ts index d404f9b361..466598cf26 100644 --- a/packages/core/src/diff/format/html.ts +++ b/packages/core/src/diff/format/html.ts @@ -1,4 +1,4 @@ -import type { Compat, DiffResult, JudgedChange } from '../types.js'; +import type { DiffResult, JudgedChange } from '../types.js'; import { toJsonChange } from './json.js'; function escapeHtml(value: unknown): string { @@ -9,19 +9,14 @@ function escapeHtml(value: unknown): string { .replace(/"/g, '"'); } -const IMPACT_CLASS: Record = { - breaking: 'breaking', - 'non-breaking': 'ok', -}; - function renderChange(change: JudgedChange): string { const location = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key; const { base, revision } = toJsonChange(change); const payload = { ...(base ? { base } : {}), ...(revision ? { revision } : {}) }; return ` -
+
- ${escapeHtml(change.compat)} + ${escapeHtml(change.impact)} ${escapeHtml(change.kind)} ${escapeHtml(location)} ${change.verdicts @@ -38,7 +33,10 @@ function renderChange(change: JudgedChange): string { } export function htmlDiff(result: DiffResult): string { - const { breaking, nonBreaking } = result.summary; + const { major, minor, patch } = result.summary; + const bumpSpan = result.bump + ? `requires a ${escapeHtml(result.bump)} bump` + : ''; return ` @@ -51,8 +49,9 @@ export function htmlDiff(result: DiffResult): string { .change { border: 1px solid #e0e4e8; border-radius: 6px; margin: .5rem 0; padding: .25rem .75rem; } .change summary { cursor: pointer; display: flex; gap: .6rem; align-items: baseline; flex-wrap: wrap; } .badge { border-radius: 4px; padding: 0 .5rem; font-size: .8rem; color: #fff; } - .breaking .badge { background: #c0392b; } - .ok .badge { background: #1e8449; } + .major .badge { background: #c0392b; } + .minor .badge { background: #1e8449; } + .patch .badge { background: #9aa5b1; } .msg { color: #52606d; } .rules { color: #9aa5b1; font-size: .85rem; } pre { background: #f5f7fa; padding: .75rem; border-radius: 6px; overflow-x: auto; } @@ -62,8 +61,10 @@ export function htmlDiff(result: DiffResult): string {

API diff

- ${breaking} breaking - ${nonBreaking} non-breaking + ${major} major + ${minor} minor + ${patch} patch + ${bumpSpan} ${escapeHtml(result.specVersions.base)} → ${escapeHtml( result.specVersions.revision )} diff --git a/packages/core/src/diff/format/json.ts b/packages/core/src/diff/format/json.ts index 3bcab77076..695c36e736 100644 --- a/packages/core/src/diff/format/json.ts +++ b/packages/core/src/diff/format/json.ts @@ -1,5 +1,6 @@ import { getLineColLocation } from '../../format/codeframes.js'; -import type { Change, Compat, DiffResult, JudgedChange, LocatedNode } from '../types.js'; +import type { Location } from '../../ref-utils.js'; +import type { Change, DiffResult, Direction, Impact, JudgedChange, LocatedNode } from '../types.js'; export interface JsonLocatedNode { file: string; @@ -11,7 +12,9 @@ export interface JsonLocatedNode { export interface JsonVerdict { ruleId: string; + impact: Impact; message: string; + location: Omit; } /** The wire shape: optional sides are the JSON reader's contract, the union stays internal. */ @@ -20,7 +23,8 @@ export interface JsonChange { typeName: string; kind: Change['kind']; property?: string; - compat: Compat; + impact: Impact; + direction: Direction; verdicts: JsonVerdict[]; base?: JsonLocatedNode; revision?: JsonLocatedNode; @@ -32,25 +36,34 @@ export interface JsonDiffResult extends Omit { // Nodes inlined by bundling do not exist in the root source AST; // getLineColLocation falls back to 1:1 for such pointers. -function toJsonNode({ location, value }: LocatedNode): JsonLocatedNode { +function toJsonLocation(location: Location): Omit { const { start } = getLineColLocation(location); return { file: location.source.absoluteRef, pointer: location.pointer, line: start.line, col: start.col, - value, }; } +function toJsonNode({ location, value }: LocatedNode): JsonLocatedNode { + return { ...toJsonLocation(location), value }; +} + export function toJsonChange(change: JudgedChange): JsonChange { - const { key, typeName, kind, compat, verdicts } = change; + const { key, typeName, kind, impact, direction, verdicts } = change; const common = { key, typeName, kind, - compat, - verdicts: verdicts.map(({ ruleId, message }) => ({ ruleId, message })), + impact, + direction, + verdicts: verdicts.map((verdict) => ({ + ruleId: verdict.ruleId, + impact: verdict.impact, + message: verdict.message, + location: toJsonLocation(verdict.location), + })), }; switch (change.kind) { case 'added': diff --git a/packages/core/src/diff/format/markdown.ts b/packages/core/src/diff/format/markdown.ts index 0cd8ad57d8..cc5de9eb0d 100644 --- a/packages/core/src/diff/format/markdown.ts +++ b/packages/core/src/diff/format/markdown.ts @@ -1,8 +1,9 @@ -import type { Compat, DiffResult } from '../types.js'; +import type { DiffResult, Impact } from '../types.js'; -const IMPACT_LABEL: Record = { - breaking: '🔴 breaking', - 'non-breaking': '🟢 non-breaking', +const IMPACT_LABELS: Record = { + major: '🔴 major', + minor: '🟢 minor', + patch: '⚪ patch', }; // A cell is rendered inside a code span, so a backtick from the description would @@ -16,11 +17,12 @@ function escapeCell(value: string): string { } export function markdownDiff(result: DiffResult): string { - const { breaking, nonBreaking } = result.summary; + const { major, minor, patch } = result.summary; + const required = result.bump ? ` · requires a **${result.bump}** bump` : ''; const lines = [ '## API diff', '', - `**${breaking}** breaking · **${nonBreaking}** non-breaking`, + `**${major}** major · **${minor}** minor · **${patch}** patch${required}`, '', '| Impact | Change | Location | Details |', '| --- | --- | --- | --- |', @@ -34,7 +36,7 @@ export function markdownDiff(result: DiffResult): string { .map((verdict) => `${escapeCell(verdict.message)} \`${verdict.ruleId}\``) .join('
'); lines.push( - `| ${IMPACT_LABEL[change.compat]} | ${change.kind} | \`${escapeCell(location)}\` | ${details} |` + `| ${IMPACT_LABELS[change.impact]} | ${change.kind} | \`${escapeCell(location)}\` | ${details} |` ); } diff --git a/packages/core/src/diff/format/problems.ts b/packages/core/src/diff/format/problems.ts index 5a5abaefbb..ecdcb558c3 100644 --- a/packages/core/src/diff/format/problems.ts +++ b/packages/core/src/diff/format/problems.ts @@ -1,24 +1,44 @@ -import type { NormalizedProblem } from '../../walk.js'; -import { displaySide, type DiffResult } from '../types.js'; +import type { NormalizedProblem, ProblemSeverity } from '../../walk.js'; +import { displaySide, type DiffResult, type Impact } from '../types.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. -export function breakingChangesToProblems(result: DiffResult): NormalizedProblem[] { - return result.changes - .filter((change) => change.compat === 'breaking') - .map((change) => { - // verdicts are worst-first, so the first one carries the breaking verdict. - const [verdict] = change.verdicts; - return { - message: verdict?.message ?? `${change.kind} ${change.key}`, - ruleId: verdict?.ruleId ?? 'diff', - severity: 'error' as const, - location: [displaySide(change).location], - // Point at the counterpart in the base document, so formats that render - // a `from` location show both sides of the change. - ...(change.kind === 'modified' ? { from: change.base.location } : {}), - suggest: [], - }; +// A lint problem carries one of two severities, so a patch has nowhere to go and is left +// out; this is what lets the github-actions format come from core's formatProblems. +const SEVERITIES: Partial> = { major: 'error', minor: 'warn' }; + +export function diffToProblems(result: DiffResult): NormalizedProblem[] { + return result.changes.flatMap((change) => { + const severity = SEVERITIES[change.impact]; + if (!severity) return []; + // Point at the counterpart in the base document, so formats that render + // a `from` location show both sides of the change. + const from = change.kind === 'modified' ? { from: change.base.location } : {}; + + if (!change.verdicts.length) { + return [ + { + message: `${change.kind} ${change.key}`, + ruleId: 'diff', + severity, + location: [displaySide(change).location], + ...from, + suggest: [], + }, + ]; + } + + return change.verdicts.flatMap((verdict) => { + const verdictSeverity = SEVERITIES[verdict.impact]; + if (!verdictSeverity) return []; + return [ + { + message: verdict.message, + ruleId: verdict.ruleId, + severity: verdictSeverity, + location: [verdict.location], + ...from, + suggest: [], + }, + ]; }); + }); } diff --git a/packages/core/src/diff/format/stylish.ts b/packages/core/src/diff/format/stylish.ts index 879a64fe29..5e0b4aff7a 100644 --- a/packages/core/src/diff/format/stylish.ts +++ b/packages/core/src/diff/format/stylish.ts @@ -5,17 +5,18 @@ import { getLineColLocation } from '../../format/codeframes.js'; import { colorize } from '../../logger.js'; import { isAbsoluteUrl, unescapePointerFragment } from '../../ref-utils.js'; import { - compatRank, displaySide, + impactRank, type Change, - type Compat, type DiffResult, + type Impact, type JudgedChange, } from '../types.js'; -const ICONS: Record = { - breaking: colorize.red('✖ breaking '), - 'non-breaking': colorize.green('✔ non-breaking'), +const IMPACT_GLYPHS: Record = { + major: colorize.red('✖ major'), + minor: colorize.green('✔ minor'), + patch: colorize.gray('· patch'), }; const HTTP_METHODS = new Set([ @@ -30,7 +31,6 @@ const HTTP_METHODS = new Set([ 'query', ]); -// Identity keys escape '/' (node-identity.ts), so plain splitting is safe. function segmentsOf(pointer: string): string[] { return pointer.replace(/^#\//, '').split('/'); } @@ -53,16 +53,16 @@ function labelSegments(segments: string[]): string[] { return segments.slice(underOperation ? 3 : 2); } -function labelOf(change: Change): string { - const segments = segmentsOf(change.key); - 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.kind === 'modified' ? change.property : change.key; - return change.kind === 'modified' ? `${label} · ${change.property}` : label; +// The group heading already names the endpoint, so the label starts after +// `paths//`. A change on the endpoint itself is labelled by its property; +// the endpoint node itself by its real path, each segment unescaped. +function labelOf(change: JudgedChange): string { + const named = labelSegments(segmentsOf(change.key)); + if (named.length) { + return change.kind === 'modified' ? `${named.join('/')} · ${change.property}` : named.join('/'); + } + if (change.kind === 'modified') return change.property; + return segmentsOf(displaySide(change).location.pointer).map(unescapePointerFragment).join(' · '); } function locationOf(change: Change, cwd: string): string { @@ -88,10 +88,13 @@ export function stylishDiff(result: DiffResult): string { for (const [key, changes] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) { lines.push(colorize.bold(colorize.blue(key))); const sorted = [...changes].sort( - (a, b) => compatRank(b.compat) - compatRank(a.compat) || a.key.localeCompare(b.key) + (left, right) => + impactRank(right.impact) - impactRank(left.impact) || left.key.localeCompare(right.key) ); for (const change of sorted) { - lines.push(` ${ICONS[change.compat]} ${colorize.bold(change.kind)} ${labelOf(change)}`); + lines.push( + ` ${IMPACT_GLYPHS[change.impact]} ${colorize.bold(change.kind.padEnd(8))} ${labelOf(change)}` + ); for (const verdict of change.verdicts) { lines.push(colorize.gray(` ${verdict.message} (${verdict.ruleId})`)); } @@ -100,9 +103,9 @@ export function stylishDiff(result: DiffResult): string { lines.push(''); } - const { breaking, nonBreaking } = result.summary; + const { major, minor, patch } = result.summary; lines.push( - `${colorize.red(`${breaking} breaking`)}, ${colorize.green(`${nonBreaking} non-breaking`)}.` + `${colorize.red(`${major} major`)}, ${colorize.green(`${minor} minor`)}, ${colorize.gray(`${patch} patch`)}.` ); return lines.join('\n'); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f19fb8461d..1b41a8212f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -130,9 +130,26 @@ export { mapTypeToComponent } from './bundle/bundle-visitor.js'; export { type Assertions, type Assertion } from './rules/common/assertions/index.js'; export { logger, type LoggerInterface } from './logger.js'; export { diffDocuments, DiffError } from './diff/index.js'; -export type { Change, DiffResult, DiffSummary } from './diff/types.js'; +export { + impactRank, + impacts, + type Change, + type DiffResult, + type DiffSummary, + type Impact, + type JudgedChange, +} from './diff/types.js'; +export type { DiffRuleMap } from './diff/rules/index.js'; export { diffReportFormats, type DiffReportFormat } from './diff/format/index.js'; -export { breakingChangesToProblems } from './diff/format/problems.js'; +export { diffToProblems } from './diff/format/problems.js'; +export { + bumpSemver, + formatSemver, + parseSemver, + semverBumpBetween, + type Semver, + type SemverPart, +} from './utils/semver.js'; export { HandledError, AbortFlowError } from './utils/error.js'; export { isSupportedExtension } from './utils/is-supported-extension.js'; export { isBrowser } from './env.js'; From 9720f909eb3692a8215ca972d3cb8516c2412770 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:05:59 +0300 Subject: [PATCH 33/57] feat(diff): rate changes by semver impact, add the --fail-on threshold and --check-version --- .changeset/purple-onions-shave.md | 2 +- .../diff/__tests__/check-version.test.ts | 50 +++++++++++++ .../commands/diff/__tests__/fail-on.test.ts | 24 +++++++ .../cli/src/commands/diff/check-version.ts | 59 +++++++++++++++ packages/cli/src/commands/diff/fail-on.ts | 21 +++--- packages/cli/src/commands/diff/index.ts | 72 ++++++++----------- packages/cli/src/commands/diff/types.ts | 16 +++++ packages/cli/src/index.ts | 20 +++--- .../snapshot.txt | 6 +- .../diff/async3-channel-removed/snapshot.txt | 8 +-- .../snapshot.txt | 6 +- .../diff/async3-message-removed/snapshot.txt | 6 +- .../snapshot.txt | 6 +- .../snapshot.txt | 6 +- .../snapshot.txt | 6 +- .../snapshot.txt | 4 +- .../diff/async3-server-removed/snapshot.txt | 6 +- tests/e2e/diff/cli.test.ts | 19 +++-- tests/e2e/diff/helpers.ts | 7 +- .../snapshot.txt | 6 +- .../snapshot.txt | 4 +- .../diff/oas3-enum-values-added/snapshot.txt | 6 +- .../oas3-enum-values-removed/snapshot.txt | 6 +- .../diff/oas3-media-type-removed/snapshot.txt | 6 +- .../oas3-nullability-changed/snapshot.txt | 6 +- .../snapshot.txt | 8 +-- .../oas3-numeric-range-changed/snapshot.txt | 6 +- .../diff/oas3-operation-removed/snapshot.txt | 6 +- .../snapshot.txt | 4 +- .../snapshot.txt | 6 +- .../snapshot.txt | 6 +- .../oas3-parameter-removed-last/snapshot.txt | 6 +- .../diff/oas3-parameter-removed/snapshot.txt | 6 +- .../snapshot.txt | 6 +- .../oas3-path-parameter-renamed/snapshot.txt | 6 +- tests/e2e/diff/oas3-path-removed/snapshot.txt | 6 +- .../snapshot.txt | 6 +- .../diff/oas3-ref-target-changed/snapshot.txt | 6 +- .../snapshot.txt | 6 +- .../oas3-request-body-removed/snapshot.txt | 6 +- .../snapshot.txt | 6 +- .../snapshot.txt | 6 +- .../oas3-response-header-removed/snapshot.txt | 6 +- .../diff/oas3-response-removed/snapshot.txt | 6 +- .../snapshot.txt | 6 +- .../oas3-schema-format-changed/snapshot.txt | 6 +- .../snapshot.txt | 4 +- .../snapshot.txt | 6 +- .../snapshot.txt | 6 +- .../oas3-security-scheme-changed/snapshot.txt | 12 ++-- .../oas3-security-scheme-removed/snapshot.txt | 6 +- .../oas3-security-scopes-added/snapshot.txt | 6 +- .../oas3-string-length-changed/snapshot.txt | 6 +- .../snapshot.txt | 6 +- tests/e2e/diff/rules.test.ts | 12 ++-- 55 files changed, 359 insertions(+), 209 deletions(-) create mode 100644 packages/cli/src/commands/diff/__tests__/check-version.test.ts create mode 100644 packages/cli/src/commands/diff/__tests__/fail-on.test.ts create mode 100644 packages/cli/src/commands/diff/check-version.ts create mode 100644 packages/cli/src/commands/diff/types.ts diff --git a/.changeset/purple-onions-shave.md b/.changeset/purple-onions-shave.md index b6ec507e82..c197713c37 100644 --- a/.changeset/purple-onions-shave.md +++ b/.changeset/purple-onions-shave.md @@ -3,4 +3,4 @@ '@redocly/openapi-core': minor --- -Added an experimental `diff` command that compares two API descriptions and reports what was added, removed, and changed. +Added an experimental `diff` command that compares two API descriptions, reports what was added, removed, and modified, and rates every change by the semver bump it requires (`patch`, `minor`, or `major`). `--fail-on` sets the impact that fails the run and `--check-version` verifies that `info.version` was bumped accordingly. diff --git a/packages/cli/src/commands/diff/__tests__/check-version.test.ts b/packages/cli/src/commands/diff/__tests__/check-version.test.ts new file mode 100644 index 0000000000..2f55a192b7 --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/check-version.test.ts @@ -0,0 +1,50 @@ +import { checkVersion } from '../check-version.js'; + +describe('checkVersion', () => { + it('passes when the declared bump matches or exceeds the required one', () => { + expect(checkVersion({ base: '1.2.0', revision: '1.3.0', required: 'minor' })).toEqual({ + status: 'passed', + }); + expect(checkVersion({ base: '1.2.0', revision: '2.0.0', required: 'patch' })).toEqual({ + status: 'passed', + }); + }); + + it('fails when the version moved less than the changes require', () => { + expect(checkVersion({ base: '1.2.0', revision: '1.3.0', required: 'major' })).toEqual({ + status: 'failed', + message: '✖ info.version went 1.2.0 → 1.3.0, but these changes require a major bump (2.0.0).', + }); + }); + + it('fails when the version did not move although something changed', () => { + expect(checkVersion({ base: '1.2.0', revision: '1.2.0', required: 'patch' })).toEqual({ + status: 'failed', + message: '✖ info.version stayed 1.2.0, but these changes require a patch bump (1.2.1).', + }); + }); + + it('lets a minor stand in for a major while below 1.0', () => { + expect(checkVersion({ base: '0.3.1', revision: '0.4.0', required: 'major' })).toEqual({ + status: 'passed', + }); + expect(checkVersion({ base: '0.3.1', revision: '0.3.2', required: 'major' })).toEqual({ + status: 'failed', + message: + '✖ info.version went 0.3.1 → 0.3.2, but these changes require a minor bump (0.4.0) while below 1.0.', + }); + }); + + it('skips with a warning when either version is not semver', () => { + expect(checkVersion({ base: 'v1', revision: '1.0.0', required: 'patch' })).toEqual({ + status: 'skipped', + message: '⚠️ info.version is not a semver string; --check-version was skipped.', + }); + }); + + it('passes when nothing changed', () => { + expect(checkVersion({ base: 'v1', revision: 'v1', required: undefined })).toEqual({ + status: 'passed', + }); + }); +}); diff --git a/packages/cli/src/commands/diff/__tests__/fail-on.test.ts b/packages/cli/src/commands/diff/__tests__/fail-on.test.ts new file mode 100644 index 0000000000..78697fcb36 --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/fail-on.test.ts @@ -0,0 +1,24 @@ +import { getDiffFailure } from '../fail-on.js'; + +const summary = { major: 2, minor: 1, patch: 3 }; + +describe('getDiffFailure', () => { + it.each([ + ['major', '❌ Diff failed with 2 major changes.'], + ['minor', '❌ Diff failed with 2 major and 1 minor changes.'], + ['patch', '❌ Diff failed with 2 major and 1 minor and 3 patch changes.'], + ['none', undefined], + ] as const)('with --fail-on=%s reports %s', (failOn, expected) => { + expect(getDiffFailure(summary, failOn)).toBe(expected); + }); + + it('passes when nothing reaches the threshold', () => { + expect(getDiffFailure({ major: 0, minor: 0, patch: 3 }, 'minor')).toBeUndefined(); + }); + + it('uses the singular for one change', () => { + expect(getDiffFailure({ major: 1, minor: 0, patch: 0 }, 'major')).toBe( + '❌ Diff failed with 1 major change.' + ); + }); +}); diff --git a/packages/cli/src/commands/diff/check-version.ts b/packages/cli/src/commands/diff/check-version.ts new file mode 100644 index 0000000000..d969a50e59 --- /dev/null +++ b/packages/cli/src/commands/diff/check-version.ts @@ -0,0 +1,59 @@ +import { + bumpSemver, + formatSemver, + impactRank, + isPlainObject, + parseSemver, + semverBumpBetween, + type Document, + type Impact, +} from '@redocly/openapi-core'; + +export type VersionCheck = + | { status: 'passed' } + | { status: 'skipped'; message: string } + | { status: 'failed'; message: string }; + +export function getDeclaredVersion(document: Document): unknown { + return isPlainObject(document.parsed) && isPlainObject(document.parsed.info) + ? document.parsed.info.version + : undefined; +} + +// Semver lets a 0.x line break in a minor release, so below 1.0 a declared minor +// satisfies a required major. +export function checkVersion(opts: { + base: unknown; + revision: unknown; + required: Impact | undefined; +}): VersionCheck { + const { required } = opts; + if (required === undefined) return { status: 'passed' }; + + const base = parseSemver(opts.base); + const revision = parseSemver(opts.revision); + if (!base || !revision) { + return { + status: 'skipped', + message: '⚠️ info.version is not a semver string; --check-version was skipped.', + }; + } + + const isRelaxed = base.major === 0 && required === 'major'; + const effectiveRequired: Impact = isRelaxed ? 'minor' : required; + const declared = semverBumpBetween(base, revision); + if (declared && impactRank(declared) >= impactRank(effectiveRequired)) + return { status: 'passed' }; + + const moved = + formatSemver(base) === formatSemver(revision) + ? `stayed ${formatSemver(base)}` + : `went ${formatSemver(base)} → ${formatSemver(revision)}`; + const next = formatSemver(bumpSemver(base, effectiveRequired)); + return { + status: 'failed', + message: `✖ info.version ${moved}, but these changes require a ${effectiveRequired} bump (${next})${ + isRelaxed ? ' while below 1.0' : '' + }.`, + }; +} diff --git a/packages/cli/src/commands/diff/fail-on.ts b/packages/cli/src/commands/diff/fail-on.ts index 4776880494..e13d6e1526 100644 --- a/packages/cli/src/commands/diff/fail-on.ts +++ b/packages/cli/src/commands/diff/fail-on.ts @@ -1,13 +1,16 @@ -import { pluralize, type DiffSummary } from '@redocly/openapi-core'; +import { impactRank, impacts, pluralize, type DiffSummary } from '@redocly/openapi-core'; -export type DiffFailOn = 'breaking' | 'none'; +import type { DiffFailOn } from './types.js'; 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; + if (failOn === 'none') return undefined; + + const failing = [...impacts] + .reverse() + .filter((impact) => impactRank(impact) >= impactRank(failOn) && summary[impact] > 0); + if (!failing.length) return undefined; + + const total = failing.reduce((sum, impact) => sum + summary[impact], 0); + const counted = failing.map((impact) => `${summary[impact]} ${impact}`).join(' and '); + return `❌ Diff failed with ${counted} ${pluralize('change', total)}.`; } diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts index 0bb9eef543..c2adf7962a 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -1,52 +1,27 @@ import { - breakingChangesToProblems, bundle, DiffError, diffDocuments, diffReportFormats, + diffToProblems, formatProblems, getTotals, logger, - type DiffReportFormat, type DiffResult, - 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 { getDiffFailure, type DiffFailOn } from './fail-on.js'; - -/** - * Formats delegated to core's lint formatters. They describe breaking changes - * only, because a lint problem always carries a severity (see core diff/format/problems.ts). - */ -export type DiffProblemFormat = Extract< - OutputFormat, - 'codeframe' | 'checkstyle' | 'codeclimate' | 'summary' | 'github-actions' | 'junit' ->; - -export type DiffOutputFormat = DiffReportFormat | DiffProblemFormat; -export type { DiffFailOn }; - -export type DiffArgv = { - base: string; - revision: string; - format: DiffOutputFormat; - output?: string; - 'fail-on': DiffFailOn; -} & VerifyConfigOptions; - -function isReportFormat(format: DiffOutputFormat): format is DiffReportFormat { - return format in diffReportFormats; -} +import { checkVersion, getDeclaredVersion } from './check-version.js'; +import { getDiffFailure } from './fail-on.js'; +import type { DiffArgv } from './types.js'; export async function handleDiff({ argv, config, collectSpecData }: CommandArgs) { - if (argv.output && !isReportFormat(argv.format)) { + if (argv.output && argv.format === 'github-actions') { return exitWithError( - `The ${argv.format} format prints to stdout only. To write a report to a file, use one of these formats: ${Object.keys(diffReportFormats).join(', ')}.` + `The github-actions format prints to stdout only. To write a report to a file, use one of these formats: ${Object.keys(diffReportFormats).join(', ')}.` ); } @@ -68,7 +43,14 @@ export async function handleDiff({ argv, config, collectSpecData }: CommandArgs< throw error; } - if (isReportFormat(argv.format)) { + if (argv.format === 'github-actions') { + const problems = diffToProblems(result); + formatProblems(problems, { + format: 'github-actions', + totals: getTotals(problems), + maxProblems: problems.length, + }); + } else { const output = diffReportFormats[argv.format](result); if (argv.output) { writeFileSync(argv.output, output); @@ -76,20 +58,26 @@ export async function handleDiff({ argv, config, collectSpecData }: CommandArgs< } else { logger.output(output + '\n'); } - } else { - const problems = breakingChangesToProblems(result); - 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`); + const failures: string[] = []; + const thresholdFailure = getDiffFailure(result.summary, argv['fail-on']); + if (thresholdFailure) failures.push(thresholdFailure); + + if (argv['check-version']) { + const versionCheck = checkVersion({ + base: getDeclaredVersion(baseDocument), + revision: getDeclaredVersion(revisionDocument), + required: result.bump, + }); + if (versionCheck.status === 'skipped') logger.warn(`${versionCheck.message}\n`); + if (versionCheck.status === 'failed') failures.push(versionCheck.message); + } + + if (failures.length) { + for (const failure of failures) logger.error(`${failure}\n`); throw new AbortFlowError('Diff failed.'); } } diff --git a/packages/cli/src/commands/diff/types.ts b/packages/cli/src/commands/diff/types.ts new file mode 100644 index 0000000000..06bb1018a3 --- /dev/null +++ b/packages/cli/src/commands/diff/types.ts @@ -0,0 +1,16 @@ +import type { DiffReportFormat, Impact } from '@redocly/openapi-core'; + +import type { VerifyConfigOptions } from '../../types.js'; + +export type DiffOutputFormat = DiffReportFormat | 'github-actions'; + +export type DiffFailOn = Impact | 'none'; + +export type DiffArgv = { + base: string; + revision: string; + format: DiffOutputFormat; + output?: string; + 'fail-on': DiffFailOn; + 'check-version': boolean; +} & VerifyConfigOptions; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index cd2697b5ba..6ced5887db 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,7 +13,7 @@ import yargs, { type Arguments } from 'yargs'; import { hideBin } from 'yargs/helpers'; import type { BuildDocsArgv } from './commands/build-docs/types.js'; -import type { DiffArgv, DiffOutputFormat } from './commands/diff/index.js'; +import type { DiffArgv, DiffFailOn, DiffOutputFormat } from './commands/diff/types.js'; import type { ReportFormat } from './commands/drift/engine/reporter.js'; import { type DriftArgv } from './commands/drift/index.js'; import type { FindingSeverity, MatchMode, TrafficFormat } from './commands/drift/types/index.js'; @@ -157,12 +157,7 @@ yargs(hideBin(process.argv)) 'json', 'markdown', 'html', - 'codeframe', - 'checkstyle', - 'codeclimate', - 'summary', 'github-actions', - 'junit', ] as ReadonlyArray, default: 'stylish' as const, }, @@ -172,9 +167,16 @@ yargs(hideBin(process.argv)) 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, + description: + 'Exit with a non-zero code when changes of this impact or higher are found.', + choices: ['major', 'minor', 'patch', 'none'] as ReadonlyArray, + default: 'major' as const, + }, + 'check-version': { + description: + 'Fail when info.version was not bumped to match the impact of the changes.', + type: 'boolean', + default: false, }, }), async (argv) => { diff --git a/tests/e2e/diff/async3-channel-address-changed/snapshot.txt b/tests/e2e/diff/async3-channel-address-changed/snapshot.txt index c3dc0f3813..a4e5afb702 100644 --- a/tests/e2e/diff/async3-channel-address-changed/snapshot.txt +++ b/tests/e2e/diff/async3-channel-address-changed/snapshot.txt @@ -1,11 +1,11 @@ channels - ✖ breaking modified channels/userSignedUp · address + ✖ major modified 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. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/async3-channel-removed/snapshot.txt b/tests/e2e/diff/async3-channel-removed/snapshot.txt index ed90427a63..a0d02e8d75 100644 --- a/tests/e2e/diff/async3-channel-removed/snapshot.txt +++ b/tests/e2e/diff/async3-channel-removed/snapshot.txt @@ -1,16 +1,16 @@ channels - ✖ breaking removed channels/userDeleted + ✖ major removed channels/userDeleted The channel was removed. (channel-removed) at base.yaml:21:5 operations - ✖ breaking removed operations/onUserDeleted + ✖ major removed operations/onUserDeleted Operation was removed. (operation-removed) at base.yaml:34:5 -2 breaking, 0 non-breaking. +2 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 2 breaking changes. +❌ Diff failed with 2 major changes. diff --git a/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt b/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt index b59e513b83..f8f7961ff8 100644 --- a/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt +++ b/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt @@ -1,11 +1,11 @@ channels - ✖ breaking modified channels/userSignedUp/messages/signup · contentType + ✖ major modified 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. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/async3-message-removed/snapshot.txt b/tests/e2e/diff/async3-message-removed/snapshot.txt index c42a726004..d3d748f257 100644 --- a/tests/e2e/diff/async3-message-removed/snapshot.txt +++ b/tests/e2e/diff/async3-message-removed/snapshot.txt @@ -1,11 +1,11 @@ channels - ✖ breaking removed channels/userSignedUp/messages/signout + ✖ major removed channels/userSignedUp/messages/signout The message was removed. (message-removed) at base.yaml:21:9 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/async3-operation-action-changed/snapshot.txt b/tests/e2e/diff/async3-operation-action-changed/snapshot.txt index 60ee9bd014..2c8cfe8153 100644 --- a/tests/e2e/diff/async3-operation-action-changed/snapshot.txt +++ b/tests/e2e/diff/async3-operation-action-changed/snapshot.txt @@ -1,11 +1,11 @@ operations - ✖ breaking modified operations/onUserSignedUp · action + ✖ major modified operations/onUserSignedUp · action The operation action changed from 'receive' to 'send'. (operation-action-changed) at revision.yaml:22:13 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/async3-payload-property-removed/snapshot.txt b/tests/e2e/diff/async3-payload-property-removed/snapshot.txt index 9f88ee366d..149593c291 100644 --- a/tests/e2e/diff/async3-payload-property-removed/snapshot.txt +++ b/tests/e2e/diff/async3-payload-property-removed/snapshot.txt @@ -1,11 +1,11 @@ channels - ✖ breaking removed channels/userSignedUp/messages/signup/payload/properties/plan + ✖ major 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. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/async3-payload-required-added/snapshot.txt b/tests/e2e/diff/async3-payload-required-added/snapshot.txt index 738f9b1fa9..4f5ae5075c 100644 --- a/tests/e2e/diff/async3-payload-required-added/snapshot.txt +++ b/tests/e2e/diff/async3-payload-required-added/snapshot.txt @@ -1,11 +1,11 @@ channels - ✖ breaking modified channels/userSignedUp/messages/signup/payload · required + ✖ major modified channels/userSignedUp/messages/signup/payload · required Properties became required: plan. (required-properties-added) at revision.yaml:14:13 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt b/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt index 1c1a5d12bd..5506beb71f 100644 --- a/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt +++ b/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt @@ -1,8 +1,8 @@ channels - ✔ non-breaking modified channels/userSignedUp/messages/signup/payload · required + · patch modified channels/userSignedUp/messages/signup/payload · required at revision.yaml:14:13 -0 breaking, 1 non-breaking. +0 major, 0 minor, 1 patch. base.yaml vs revision.yaml: diff processed in ms diff --git a/tests/e2e/diff/async3-server-removed/snapshot.txt b/tests/e2e/diff/async3-server-removed/snapshot.txt index 0d7bed1488..d096dcacba 100644 --- a/tests/e2e/diff/async3-server-removed/snapshot.txt +++ b/tests/e2e/diff/async3-server-removed/snapshot.txt @@ -1,11 +1,11 @@ servers - ✖ breaking removed servers/staging + ✖ major removed servers/staging The server was removed. (server-removed) at base.yaml:10:5 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/cli.test.ts b/tests/e2e/diff/cli.test.ts index e0bb02d0ae..d099392372 100644 --- a/tests/e2e/diff/cli.test.ts +++ b/tests/e2e/diff/cli.test.ts @@ -17,7 +17,7 @@ function diffExitCode(fixture: string, ...args: string[]): number | null { } describe('diff command', () => { - test('exits 1 on breaking changes with the default --fail-on=breaking', () => { + test('exits 1 on major changes with the default --fail-on=major', () => { expect(diffExitCode('oas3-breaking-changes')).toBe(1); }); @@ -25,15 +25,26 @@ describe('diff command', () => { expect(diffExitCode('oas3-breaking-changes', '--fail-on=none')).toBe(0); }); - test('exits 0 when the only changes are non-breaking', () => { + test('exits 0 when the only changes are minor or patch', () => { 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'); + test('exits 1 with --fail-on=minor when an optional parameter is added', () => { + expect(diffExitCode('oas3-parameter-added-optional', '--fail-on=minor')).toBe(1); + }); + + test('rejects --output for the github-actions format', () => { + const output = runDiff('oas3-breaking-changes', '--format=github-actions', '-o', 'out.txt'); expect(output).toContain('prints to stdout only'); }); + test('warns that --check-version was skipped when info.version is not semver', () => { + // Every diff fixture keeps a two-part `info.version` (e.g. '1.0'), so --check-version + // has nothing to compare and skips with a warning rather than passing judgment. + const output = runDiff('oas3-parameter-removed', '--check-version', '--fail-on=none'); + expect(output).toContain('--check-version was skipped'); + }); + test('refuses to compare across specification families', () => { const output = runDiff('cross-family'); expect(output).toContain('different specification families'); diff --git a/tests/e2e/diff/helpers.ts b/tests/e2e/diff/helpers.ts index 4ceaa440be..4b32f76f4b 100644 --- a/tests/e2e/diff/helpers.ts +++ b/tests/e2e/diff/helpers.ts @@ -21,13 +21,14 @@ export function runDiff(fixture: string, ...args: string[]): string { * the document is cut out of the captured output. */ export function runJsonDiff(fixture: string): { - summary: { breaking: number; nonBreaking: number }; + summary: { major: number; minor: number; patch: number }; + bump?: 'patch' | 'minor' | 'major'; changes: { key: string; property?: string; kind: string; - compat: string; - verdicts: { ruleId: string; compat: string; message: string }[]; + impact: string; + verdicts: { ruleId: string; impact: string; message: string }[]; }[]; } { const output = runDiff(fixture, '--format=json'); diff --git a/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt b/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt index a4fe5e91e6..1402e91c68 100644 --- a/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt @@ -1,11 +1,11 @@ POST /pets - ✖ breaking modified requestBody/content/application~1json/schema · additionalProperties + ✖ major modified 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. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. 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 index ef63315cde..d3592f78be 100644 --- a/tests/e2e/diff/oas3-enum-values-added-to-request/snapshot.txt +++ b/tests/e2e/diff/oas3-enum-values-added-to-request/snapshot.txt @@ -1,8 +1,8 @@ POST /pets - ✔ non-breaking modified requestBody/content/application~1json/schema · enum + · patch modified requestBody/content/application~1json/schema · enum at revision.yaml:13:21 -0 breaking, 1 non-breaking. +0 major, 0 minor, 1 patch. base.yaml vs revision.yaml: diff processed in ms diff --git a/tests/e2e/diff/oas3-enum-values-added/snapshot.txt b/tests/e2e/diff/oas3-enum-values-added/snapshot.txt index 758212a268..bd5bbec8fa 100644 --- a/tests/e2e/diff/oas3-enum-values-added/snapshot.txt +++ b/tests/e2e/diff/oas3-enum-values-added/snapshot.txt @@ -1,11 +1,11 @@ GET /pets - ✖ breaking modified responses/200/content/application~1json/schema · enum + ✖ major modified responses/200/content/application~1json/schema · enum Enum values added: sold. (enum-values-added) at revision.yaml:15:23 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt b/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt index f12139d18e..fe5dfd7ee7 100644 --- a/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt @@ -1,11 +1,11 @@ GET /pets - ✖ breaking modified parameters/{query:sort}/schema · enum + ✖ major modified parameters/{query:sort}/schema · enum Enum values removed: desc. (enum-values-removed) at revision.yaml:14:15 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-media-type-removed/snapshot.txt b/tests/e2e/diff/oas3-media-type-removed/snapshot.txt index faba51c3ab..8ed5bfc09a 100644 --- a/tests/e2e/diff/oas3-media-type-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-media-type-removed/snapshot.txt @@ -1,11 +1,11 @@ GET /pets - ✖ breaking removed responses/200/content/application~1xml + ✖ major removed responses/200/content/application~1xml Media type was removed. (media-type-removed) at base.yaml:15:15 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-nullability-changed/snapshot.txt b/tests/e2e/diff/oas3-nullability-changed/snapshot.txt index caf696a69c..39d4dffc9b 100644 --- a/tests/e2e/diff/oas3-nullability-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-nullability-changed/snapshot.txt @@ -1,11 +1,11 @@ POST /pets - ✖ breaking modified requestBody/content/application~1json/schema/properties/name · type + ✖ major modified 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. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt index 749dd7c9f3..dff5c58103 100644 --- a/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt +++ b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt @@ -1,14 +1,14 @@ openapi - ✔ non-breaking modified openapi + · patch modified · openapi at revision.yaml:1:10 POST /pets - ✔ non-breaking modified requestBody/content/application~1json/schema/properties/name · nullable + · patch modified requestBody/content/application~1json/schema/properties/name · nullable at revision.yaml:15:19 - ✔ non-breaking modified requestBody/content/application~1json/schema/properties/name · type + · patch modified requestBody/content/application~1json/schema/properties/name · type at revision.yaml:16:21 -0 breaking, 3 non-breaking. +0 major, 0 minor, 3 patch. base.yaml vs revision.yaml: diff processed in ms diff --git a/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt b/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt index 7ccb5c1b44..47c8da4b87 100644 --- a/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt @@ -1,11 +1,11 @@ POST /pets - ✖ breaking modified requestBody/content/application~1json/schema/properties/age · minimum + ✖ major modified 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. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-operation-removed/snapshot.txt b/tests/e2e/diff/oas3-operation-removed/snapshot.txt index 4da15359f1..43c538de1c 100644 --- a/tests/e2e/diff/oas3-operation-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-operation-removed/snapshot.txt @@ -1,11 +1,11 @@ DELETE /pets - ✖ breaking removed paths · /pets · delete + ✖ major removed paths · /pets · delete Operation was removed. (operation-removed) at base.yaml:12:7 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-parameter-added-optional/snapshot.txt b/tests/e2e/diff/oas3-parameter-added-optional/snapshot.txt index fb73842e72..4848dc299b 100644 --- a/tests/e2e/diff/oas3-parameter-added-optional/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-added-optional/snapshot.txt @@ -1,8 +1,8 @@ GET /pets - ✔ non-breaking added parameters + ✔ minor added parameters at revision.yaml:9:9 -0 breaking, 1 non-breaking. +0 major, 1 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms diff --git a/tests/e2e/diff/oas3-parameter-added-required/snapshot.txt b/tests/e2e/diff/oas3-parameter-added-required/snapshot.txt index f393eed44c..c0a2e03385 100644 --- a/tests/e2e/diff/oas3-parameter-added-required/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-added-required/snapshot.txt @@ -1,11 +1,11 @@ GET /pets - ✖ breaking added parameters + ✖ major added parameters A new required parameter was added. (parameter-added-required) at revision.yaml:9:9 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt b/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt index 39be4ceb67..f81f490298 100644 --- a/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt @@ -1,11 +1,11 @@ GET /pets - ✖ breaking modified parameters/{query:limit} · required + ✖ major modified parameters/{query:limit} · required Parameter became required. (parameter-became-required) at revision.yaml:11:21 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-parameter-removed-last/snapshot.txt b/tests/e2e/diff/oas3-parameter-removed-last/snapshot.txt index 814803d611..9692671a25 100644 --- a/tests/e2e/diff/oas3-parameter-removed-last/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-removed-last/snapshot.txt @@ -1,11 +1,11 @@ GET /pets - ✖ breaking removed parameters + ✖ major removed parameters Every parameter was removed. (parameter-removed) at base.yaml:9:9 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-parameter-removed/snapshot.txt b/tests/e2e/diff/oas3-parameter-removed/snapshot.txt index bb1a437685..a9ad006048 100644 --- a/tests/e2e/diff/oas3-parameter-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-removed/snapshot.txt @@ -1,11 +1,11 @@ GET /pets - ✖ breaking removed parameters/{query:limit} + ✖ major removed parameters/{query:limit} Parameter was removed. (parameter-removed) at base.yaml:9:11 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt b/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt index 41de4982d9..dcdc68559f 100644 --- a/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt @@ -1,11 +1,11 @@ GET /pets - ✖ breaking modified parameters/{query:tags} · style + ✖ major modified parameters/{query:tags} · style Parameter `style` changed from 'form' to 'spaceDelimited'. (parameter-serialization-changed) at revision.yaml:11:18 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt b/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt index d37bbda3ff..caaacb50ba 100644 --- a/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt +++ b/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt @@ -1,12 +1,12 @@ /pets/{petId} - ✔ non-breaking modified paths · /pets/{0} · path + · patch modified path at revision.yaml:7:5 GET /pets/{petId} - ✔ non-breaking modified parameters/{path:0} · name + · patch modified parameters/{path:0} · name at revision.yaml:9:17 -0 breaking, 2 non-breaking. +0 major, 0 minor, 2 patch. base.yaml vs revision.yaml: diff processed in ms diff --git a/tests/e2e/diff/oas3-path-removed/snapshot.txt b/tests/e2e/diff/oas3-path-removed/snapshot.txt index f18b91ed0b..4b52ab3bfc 100644 --- a/tests/e2e/diff/oas3-path-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-path-removed/snapshot.txt @@ -1,11 +1,11 @@ /pets/{petId} - ✖ breaking removed paths · /pets/{0} + ✖ major removed paths · /pets/{petId} Path was removed. (path-removed) at base.yaml:11:5 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-property-removed-from-response/snapshot.txt b/tests/e2e/diff/oas3-property-removed-from-response/snapshot.txt index 65960b1c20..a47ae2ac5a 100644 --- a/tests/e2e/diff/oas3-property-removed-from-response/snapshot.txt +++ b/tests/e2e/diff/oas3-property-removed-from-response/snapshot.txt @@ -1,11 +1,11 @@ GET /pets - ✖ breaking removed responses/200/content/application~1json/schema/properties/tag + ✖ major 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. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt b/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt index 2db27a4340..b431680327 100644 --- a/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt @@ -1,11 +1,11 @@ GET /pets - ✖ breaking modified responses/200/content/application~1json · schema + ✖ major modified 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. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt b/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt index c0b33950dd..310c52a458 100644 --- a/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt +++ b/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt @@ -1,11 +1,11 @@ POST /pets - ✖ breaking modified requestBody · required + ✖ major modified requestBody · required The request body became required. (request-body-became-required) at revision.yaml:9:19 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-request-body-removed/snapshot.txt b/tests/e2e/diff/oas3-request-body-removed/snapshot.txt index 390a6396b5..b0f72ac448 100644 --- a/tests/e2e/diff/oas3-request-body-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-request-body-removed/snapshot.txt @@ -1,11 +1,11 @@ POST /pets - ✖ breaking removed requestBody + ✖ major removed requestBody The request body was removed. (request-body-removed) at base.yaml:9:9 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-required-properties-added/snapshot.txt b/tests/e2e/diff/oas3-required-properties-added/snapshot.txt index aa7d0edbaf..6a1693a8f5 100644 --- a/tests/e2e/diff/oas3-required-properties-added/snapshot.txt +++ b/tests/e2e/diff/oas3-required-properties-added/snapshot.txt @@ -1,11 +1,11 @@ POST /pets - ✖ breaking modified requestBody/content/application~1json/schema · required + ✖ major modified requestBody/content/application~1json/schema · required Properties became required: name. (required-properties-added) at revision.yaml:13:25 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt b/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt index 2a111c6e33..9ab8db8acf 100644 --- a/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt @@ -1,11 +1,11 @@ GET /pets - ✖ breaking modified responses/200/content/application~1json/schema · required + ✖ major modified 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. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-response-header-removed/snapshot.txt b/tests/e2e/diff/oas3-response-header-removed/snapshot.txt index 0c043103b6..6b56a015b7 100644 --- a/tests/e2e/diff/oas3-response-header-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-response-header-removed/snapshot.txt @@ -1,11 +1,11 @@ GET /pets - ✖ breaking removed responses/200/headers + ✖ major removed responses/200/headers The response headers were removed. (response-header-removed) at base.yaml:12:13 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-response-removed/snapshot.txt b/tests/e2e/diff/oas3-response-removed/snapshot.txt index c18e0fb18c..6f1c0fe39f 100644 --- a/tests/e2e/diff/oas3-response-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-response-removed/snapshot.txt @@ -1,11 +1,11 @@ GET /pets - ✖ breaking removed responses/404 + ✖ major removed responses/404 Response was removed. (response-removed) at base.yaml:10:16 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt b/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt index c31e906f42..c80d645b5e 100644 --- a/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt @@ -1,11 +1,11 @@ POST /pets - ✖ breaking removed requestBody/content/application~1json/schema/oneOf/1 + ✖ major 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. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt b/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt index a0896b29a9..e2c341fbd0 100644 --- a/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt @@ -1,11 +1,11 @@ POST /pets - ✖ breaking modified requestBody/content/application~1json/schema/properties/id · format + ✖ major modified 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. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. 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 index be84f51ae0..89b31555f3 100644 --- a/tests/e2e/diff/oas3-schema-type-widened-in-request/snapshot.txt +++ b/tests/e2e/diff/oas3-schema-type-widened-in-request/snapshot.txt @@ -1,8 +1,8 @@ POST /pets - ✔ non-breaking modified requestBody/content/application~1json/schema/properties/id · type + · patch modified requestBody/content/application~1json/schema/properties/id · type at revision.yaml:16:21 -0 breaking, 1 non-breaking. +0 major, 0 minor, 1 patch. base.yaml vs revision.yaml: diff processed in ms 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 index c634e3c3fa..8ccc0cad60 100644 --- 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 @@ -1,11 +1,11 @@ GET /pets - ✖ breaking added security/{apiKey} + ✖ major added security/{apiKey} The API now requires authentication. (security-requirement-added) at revision.yaml:9:11 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-security-requirement-added/snapshot.txt b/tests/e2e/diff/oas3-security-requirement-added/snapshot.txt index ec246f8fd7..635507d7bb 100644 --- a/tests/e2e/diff/oas3-security-requirement-added/snapshot.txt +++ b/tests/e2e/diff/oas3-security-requirement-added/snapshot.txt @@ -1,11 +1,11 @@ GET /pets - ✖ breaking added security + ✖ major added security The API now requires authentication. (security-requirement-added) at revision.yaml:9:9 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt b/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt index 82bbf5f8d4..4c8c5d19e5 100644 --- a/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt @@ -1,17 +1,17 @@ components - ✖ breaking modified components/securitySchemes/apiKey · type + ✖ major modified components/securitySchemes/apiKey · type Security scheme `type` changed from 'apiKey' to 'http'. (security-scheme-changed) at revision.yaml:16:13 - ✔ non-breaking modified components/securitySchemes/apiKey · in + · patch modified components/securitySchemes/apiKey · in at revision.yaml:16:7 - ✔ non-breaking modified components/securitySchemes/apiKey · name + · patch modified components/securitySchemes/apiKey · name at revision.yaml:16:7 - ✔ non-breaking modified components/securitySchemes/apiKey · scheme + · patch modified components/securitySchemes/apiKey · scheme at revision.yaml:17:15 -1 breaking, 3 non-breaking. +1 major, 0 minor, 3 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt b/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt index 1d75f18e55..fb25470c8f 100644 --- a/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt @@ -1,11 +1,11 @@ components - ✖ breaking removed components/securitySchemes/basicAuth + ✖ major removed components/securitySchemes/basicAuth A security scheme was removed. (security-scheme-removed) at base.yaml:17:7 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt b/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt index c950c4f090..ef6858f203 100644 --- a/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt +++ b/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt @@ -1,11 +1,11 @@ GET /pets - ✖ breaking modified security/{oauth} · oauth + ✖ major modified security/{oauth} · oauth Scheme `oauth` requires new scopes: pets:write. (security-scopes-added) at revision.yaml:10:13 -1 breaking, 0 non-breaking. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-string-length-changed/snapshot.txt b/tests/e2e/diff/oas3-string-length-changed/snapshot.txt index a8f4a24549..2125c97029 100644 --- a/tests/e2e/diff/oas3-string-length-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-string-length-changed/snapshot.txt @@ -1,11 +1,11 @@ POST /pets - ✖ breaking modified requestBody/content/application~1json/schema/properties/name · maxLength + ✖ major modified 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. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt b/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt index 747856e588..95fd112e42 100644 --- a/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt @@ -1,11 +1,11 @@ webhooks - ✖ breaking removed webhooks/newPet/post/requestBody/content/application~1json/schema/properties/tag + ✖ major 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. +1 major, 0 minor, 0 patch. base.yaml vs revision.yaml: diff processed in ms -❌ Diff failed with 1 breaking change. +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/rules.test.ts b/tests/e2e/diff/rules.test.ts index 9ebbb318b8..c46979416b 100644 --- a/tests/e2e/diff/rules.test.ts +++ b/tests/e2e/diff/rules.test.ts @@ -209,7 +209,7 @@ const ASYNCAPI: RuleCase[] = [ ]; /** - * Edits that must NOT be reported as breaking. Each one is the mirror of a rule above, + * Edits that must NOT be reported as a major. 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. */ @@ -245,12 +245,8 @@ describe('diff rules', () => { 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 - ); + const breaking = runJsonDiff(fixture).changes.filter((change) => change.impact === 'major'); + expect(breaking.flatMap((change) => change.verdicts).map((v) => v.ruleId)).toContain(ruleId); await expect(runDiff(fixture)).toMatchFileSnapshot( join(fixturePath(fixture), 'snapshot.txt') @@ -260,7 +256,7 @@ describe('diff rules', () => { for (const { fixture, describes } of NON_BREAKING) { test(`no breaking change when ${describes}`, async () => { - expect(runJsonDiff(fixture).summary.breaking).toBe(0); + expect(runJsonDiff(fixture).summary.major).toBe(0); await expect(runDiff(fixture)).toMatchFileSnapshot( join(fixturePath(fixture), 'snapshot.txt') From 68a21a5d2ded22e57c2ef1e211a3458058733f65 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:59:04 +0300 Subject: [PATCH 34/57] feat(config): configure diff rule impact through a diff block and the recommended-diff preset --- .changeset/purple-onions-shave.md | 2 + packages/cli/src/__tests__/fixtures/config.ts | 1 + .../config-resolvers.test.ts.snap | 2 + .../__snapshots__/config.test.ts.snap | 1 + .../__tests__/__snapshots__/load.test.ts.snap | 2 + .../core/src/config/__tests__/config.test.ts | 2 + .../core/src/config/__tests__/load.test.ts | 6 ++ packages/core/src/config/builtIn.ts | 2 + packages/core/src/config/config.ts | 4 + packages/core/src/config/recommended-diff.ts | 8 ++ packages/core/src/config/types.ts | 4 + packages/core/src/config/utils.ts | 2 + .../src/diff/__tests__/diff-config.test.ts | 73 +++++++++++++++++++ packages/core/src/diff/index.ts | 7 +- packages/core/src/types/redocly-yaml.ts | 11 +++ 15 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/config/recommended-diff.ts create mode 100644 packages/core/src/diff/__tests__/diff-config.test.ts diff --git a/.changeset/purple-onions-shave.md b/.changeset/purple-onions-shave.md index c197713c37..e5fe64dd4e 100644 --- a/.changeset/purple-onions-shave.md +++ b/.changeset/purple-onions-shave.md @@ -4,3 +4,5 @@ --- Added an experimental `diff` command that compares two API descriptions, reports what was added, removed, and modified, and rates every change by the semver bump it requires (`patch`, `minor`, or `major`). `--fail-on` sets the impact that fails the run and `--check-version` verifies that `info.version` was bumped accordingly. + +Configure the impact per rule in `redocly.yaml` with `extends: [recommended-diff]` and a `diff` block, for example `diff: { operation-removed: minor }`. diff --git a/packages/cli/src/__tests__/fixtures/config.ts b/packages/cli/src/__tests__/fixtures/config.ts index a871382e36..8037f919bb 100644 --- a/packages/cli/src/__tests__/fixtures/config.ts +++ b/packages/cli/src/__tests__/fixtures/config.ts @@ -17,6 +17,7 @@ export const configFixture: Config = { saveIgnore: vi.fn(), skipDecorators: vi.fn(), ignore: {}, + diff: {}, decorators: { oas2: {}, oas3_0: {}, diff --git a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap index 7d7c3f1a73..283f927af5 100644 --- a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap @@ -101,6 +101,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "warn", "type-description": "off", @@ -556,6 +557,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "warn", "type-description": "off", diff --git a/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap index 764a4fb8cc..953319884f 100644 --- a/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap @@ -19,6 +19,7 @@ Config { "openrpc1": {}, "overlay1": {}, }, + "diff": {}, "doNotResolveExamples": false, "document": undefined, "ignore": { diff --git a/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap index 449604b428..ffd9d559e7 100644 --- a/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap @@ -19,6 +19,7 @@ Config { "openrpc1": {}, "overlay1": {}, }, + "diff": {}, "doNotResolveExamples": false, "document": undefined, "ignore": {}, @@ -61,6 +62,7 @@ Config { "async3Preprocessors": {}, "async3Rules": {}, "decorators": {}, + "diff": {}, "graphqlRules": {}, "oas2Decorators": {}, "oas2Preprocessors": {}, diff --git a/packages/core/src/config/__tests__/config.test.ts b/packages/core/src/config/__tests__/config.test.ts index 5b4b2b2ecf..0381d31807 100644 --- a/packages/core/src/config/__tests__/config.test.ts +++ b/packages/core/src/config/__tests__/config.test.ts @@ -43,6 +43,7 @@ describe('Config.forAlias', () => { "openrpc1": {}, "overlay1": {}, }, + "diff": {}, "doNotResolveExamples": false, "document": { "parsed": { @@ -109,6 +110,7 @@ describe('Config.forAlias', () => { "async3Preprocessors": {}, "async3Rules": {}, "decorators": {}, + "diff": {}, "graphqlRules": {}, "oas2Decorators": {}, "oas2Preprocessors": {}, diff --git a/packages/core/src/config/__tests__/load.test.ts b/packages/core/src/config/__tests__/load.test.ts index b40067b24c..a1b958e0a2 100644 --- a/packages/core/src/config/__tests__/load.test.ts +++ b/packages/core/src/config/__tests__/load.test.ts @@ -224,6 +224,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "off", "type-description": "off", @@ -616,6 +617,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "warn", "type-description": "off", @@ -1013,6 +1015,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "off", "type-description": "off", @@ -1494,6 +1497,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "off", "type-description": "off", @@ -1886,6 +1890,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "warn", "type-description": "off", @@ -2283,6 +2288,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "off", "type-description": "off", diff --git a/packages/core/src/config/builtIn.ts b/packages/core/src/config/builtIn.ts index 6141c1df8d..28e7b020d4 100644 --- a/packages/core/src/config/builtIn.ts +++ b/packages/core/src/config/builtIn.ts @@ -30,6 +30,7 @@ import { } from '../rules/overlay1/index.js'; import all from './all.js'; import minimal from './minimal.js'; +import recommendedDiff from './recommended-diff.js'; import recommendedStrict from './recommended-strict.js'; import recommended from './recommended.js'; import spec from './spec.js'; @@ -38,6 +39,7 @@ import { type Plugin, type RawGovernanceConfig } from './types.js'; export const builtInConfigs: Record = { recommended, 'recommended-strict': recommendedStrict, + 'recommended-diff': recommendedDiff, minimal, all, spec, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ebe4ba0be0..9e49e88fca 100755 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2,6 +2,7 @@ import type { RecheckConfig } from '@redocly/config'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import type { Impact } from '../diff/types.js'; import { stringifyYaml } from '../js-yaml/index.js'; import { type Oas2RuleSet, @@ -52,6 +53,7 @@ export class Config { preprocessors: Record>; decorators: Record>; recheck: RecheckConfig; + diff: Record; private _usedRules: Set = new Set(); private _usedVersions: Set = new Set(); @@ -161,6 +163,8 @@ export class Config { }; this.recheck = resolvedConfig.recheck ?? { rules: {} }; + this.diff = resolvedConfig.diff ?? {}; + this.ignore = opts.ignore ?? {}; } diff --git a/packages/core/src/config/recommended-diff.ts b/packages/core/src/config/recommended-diff.ts new file mode 100644 index 0000000000..d8acbd80e9 --- /dev/null +++ b/packages/core/src/config/recommended-diff.ts @@ -0,0 +1,8 @@ +import { recommendedDiffRules } from '../diff/rules/index.js'; +import { type RawGovernanceConfig } from './types.js'; + +const recommendedDiff: RawGovernanceConfig<'built-in'> = { + diff: recommendedDiffRules, +}; + +export default recommendedDiff; diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 5cd93f7faa..131362d3d1 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -1,6 +1,8 @@ import type { ApiConfig, RecheckConfig, RedoclyConfig } from '@redocly/config'; import type { JSONSchema } from 'json-schema-to-ts'; +import type { DiffRuleId } from '../diff/rules/index.js'; +import type { Impact } from '../diff/types.js'; import type { SpecMajorVersion, Oas3DecoratorsSet, @@ -97,6 +99,8 @@ export type RawGovernanceConfig = overlay1Rules?: RuleMap; openrpc1Rules?: RuleMap; graphqlRules?: RuleMap; + /** Semver impact per diff rule; `off` skips the rule. */ + diff?: RuleMap; preprocessors?: Record; oas2Preprocessors?: Record< diff --git a/packages/core/src/config/utils.ts b/packages/core/src/config/utils.ts index e3429b1fd7..e6c430d167 100644 --- a/packages/core/src/config/utils.ts +++ b/packages/core/src/config/utils.ts @@ -60,6 +60,7 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { overlay1Rules: {}, openrpc1Rules: {}, graphqlRules: {}, + diff: {}, preprocessors: {}, oas2Preprocessors: {}, @@ -118,6 +119,7 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { assignOnlyExistingConfig(result.openrpc1Rules, rulesConf.rules); assignConfig(result.graphqlRules, rulesConf.graphqlRules); assignOnlyExistingConfig(result.graphqlRules, rulesConf.rules); + assignConfig(result.diff, rulesConf.diff); assignConfig(result.preprocessors, rulesConf.preprocessors); assignConfig(result.oas2Preprocessors, rulesConf.oas2Preprocessors); diff --git a/packages/core/src/diff/__tests__/diff-config.test.ts b/packages/core/src/diff/__tests__/diff-config.test.ts new file mode 100644 index 0000000000..a5283bceec --- /dev/null +++ b/packages/core/src/diff/__tests__/diff-config.test.ts @@ -0,0 +1,73 @@ +import { outdent } from 'outdent'; + +import { createConfig } from '../../config/index.js'; +import { makeDocumentFromString } from '../../resolve.js'; +import { diffDocuments } from '../index.js'; + +const BASE = outdent` + openapi: 3.1.0 + info: { title: T, version: '1.0.0' } + paths: + /pets: + get: + responses: { '200': { description: OK } } + delete: + responses: { '204': { description: Gone } } +`; + +const REVISION = outdent` + openapi: 3.1.0 + info: { title: T, version: '1.0.0' } + paths: + /pets: + get: + responses: { '200': { description: OK } } +`; + +function diffWith(config: Awaited>) { + return diffDocuments({ + base: makeDocumentFromString(BASE, 'base.yaml'), + revision: makeDocumentFromString(REVISION, 'revision.yaml'), + config, + }); +} + +describe('diff configuration', () => { + it('merges the recommended-diff preset with the user map, later wins', async () => { + const config = await createConfig({ + extends: ['recommended-diff'], + diff: { 'operation-removed': 'minor', 'enum-values-added': 'off' }, + }); + + expect(config.diff['operation-removed']).toBe('minor'); + expect(config.diff['enum-values-added']).toBe('off'); + expect(config.diff['path-removed']).toBe('major'); + }); + + it('uses the configured impact for a verdict', async () => { + const config = await createConfig({ + extends: ['recommended-diff'], + diff: { 'operation-removed': 'minor' }, + }); + + const result = diffWith(config); + + expect(result.changes.map((change) => [change.key, change.impact])).toEqual([ + ['#/paths/~1pets/delete', 'minor'], + ]); + expect(result.bump).toBe('minor'); + }); + + it('falls back to recommended-diff when the config says nothing about diff', async () => { + const config = await createConfig({ extends: ['recommended'] }); + + expect(diffWith(config).changes[0].impact).toBe('major'); + }); + + it('runs only the listed rules when a diff map is given without the preset', async () => { + const config = await createConfig({ diff: { 'path-removed': 'major' } }); + + // operation-removed is not in the map, so the removal is an unjudged patch + expect(diffWith(config).changes[0].impact).toBe('patch'); + }); +}); diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index ecac69b6f4..d36030f2e3 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -4,6 +4,7 @@ import { collectNodeMap } from '../node-map/collect.js'; import { getTypes, type SpecVersion } from '../oas-types.js'; import type { Document } from '../resolve.js'; import { normalizeTypes } from '../types/index.js'; +import { isEmptyObject } from '../utils/is-empty-object.js'; import { compareMaps } from './compare.js'; import { detectBreakingChanges } from './detect.js'; import { identityOf } from './identity.js'; @@ -59,13 +60,17 @@ export function diffDocuments(opts: { const nodeAt = (key: string) => revisionMap.entries.get(key) ?? baseMap.entries.get(key); const usage = new UsageIndex([...baseMap.usageEdges, ...revisionMap.usageEdges], nodeAt); + // A config that says nothing about diff gets the built-in preset, the way lint falls + // back to `recommended` when there is no config at all. + const ruleMap = isEmptyObject(config.diff) ? recommendedDiffRules : config.diff; + const changes = detectBreakingChanges({ changes: compareMaps(baseMap.entries, revisionMap.entries), specVersion: revisionVersion, base: baseMap.entries, revision: revisionMap.entries, usage, - ruleMap: recommendedDiffRules, + ruleMap, }); const summary: DiffSummary = { major: 0, minor: 0, patch: 0 }; diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index ecab4839f2..7f41a22a9f 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -3,6 +3,7 @@ import type { JSONSchema } from 'json-schema-to-ts'; import path from 'node:path'; import type { Config, RawGovernanceConfig } from '../config/index.js'; +import { diffRuleIds } from '../diff/rules/index.js'; import { graphqlNodeKinds } from '../graphql/node-kinds.js'; import { specVersions, getTypes } from '../oas-types.js'; import { isAbsoluteUrl } from '../ref-utils.js'; @@ -297,6 +298,7 @@ const configGovernanceProperties: Record< overlay1Rules: 'Rules', openrpc1Rules: 'Rules', graphqlRules: 'Rules', + diff: 'DiffRules', preprocessors: 'Preprocessors', oas2Preprocessors: 'Preprocessors', oas3_0Preprocessors: 'Preprocessors', @@ -455,6 +457,14 @@ const Rules: NodeType = { }, }; +const DiffRules: NodeType = { + properties: {}, + description: + 'The `diff` block sets the semver impact of each diff rule: `off`, `patch`, `minor`, or `major`.', + additionalProperties: (_value: unknown, key: string) => + diffRuleIds.includes(key) ? { enum: ['off', 'patch', 'minor', 'major'] } : undefined, +}; + const BuiltinRule: NodeType = { properties: { severity: { enum: ['error', 'warn', 'off'] }, @@ -833,6 +843,7 @@ const CoreConfigTypes: Record = { ExternalDocs: Oas3_1Types.ExternalDocs, Xml: Oas3_1Types.Xml, Rules, + DiffRules, Decorators, Preprocessors, Assertions, From 5f92b41da0e7dfd5bb51ffc53787974de7147cd3 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:30:53 +0300 Subject: [PATCH 35/57] refactor(diff): tidy labels, comments and the command description after review --- packages/cli/src/index.ts | 2 +- packages/core/src/diff/compare.ts | 1 + .../src/diff/format/__tests__/format.test.ts | 1 - packages/core/src/diff/format/html.ts | 6 ++--- packages/core/src/diff/format/stylish.ts | 4 +-- packages/core/src/diff/index.ts | 1 - .../diff/oas3-breaking-changes/snapshot.txt | 25 ------------------- .../snapshot.txt | 2 +- tests/e2e/diff/rules.test.ts | 8 ++++-- 9 files changed, 13 insertions(+), 37 deletions(-) delete mode 100644 tests/e2e/diff/oas3-breaking-changes/snapshot.txt diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6ced5887db..bd425bfb2d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -137,7 +137,7 @@ yargs(hideBin(process.argv)) ) .command( 'diff ', - 'Compare two API descriptions and detect breaking changes [experimental].', + 'Compare two API descriptions and rate every change by the semver bump it requires [experimental].', (yargs) => yargs .env('REDOCLY_CLI_DIFF') diff --git a/packages/core/src/diff/compare.ts b/packages/core/src/diff/compare.ts index ffe44c4e24..46d75e29ab 100644 --- a/packages/core/src/diff/compare.ts +++ b/packages/core/src/diff/compare.ts @@ -86,6 +86,7 @@ export function compareMaps( return changes; } +// A property the node does not carry itself came from its identity (the path template), so it is located at the node. function propertyLocation(entry: NodeEntry, property: string) { const isOwnProperty = isPlainObject(entry.raw) && property in entry.raw; return isOwnProperty ? entry.location.child([property]) : entry.location; diff --git a/packages/core/src/diff/format/__tests__/format.test.ts b/packages/core/src/diff/format/__tests__/format.test.ts index b1d4f0dd24..4584dfd04d 100644 --- a/packages/core/src/diff/format/__tests__/format.test.ts +++ b/packages/core/src/diff/format/__tests__/format.test.ts @@ -190,7 +190,6 @@ const RESULT: DiffResult = { 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(stripColors(stylishDiff(RESULT))).toMatchInlineSnapshot(` "components ✔ minor added components/schemas/Pet diff --git a/packages/core/src/diff/format/html.ts b/packages/core/src/diff/format/html.ts index 466598cf26..2fdd6d0041 100644 --- a/packages/core/src/diff/format/html.ts +++ b/packages/core/src/diff/format/html.ts @@ -21,9 +21,9 @@ function renderChange(change: JudgedChange): string { ${escapeHtml(location)} ${change.verdicts .map( - (v) => - `${escapeHtml(v.message)} ${escapeHtml( - v.ruleId + (verdict) => + `${escapeHtml(verdict.message)} ${escapeHtml( + verdict.ruleId )}` ) .join(' ')} diff --git a/packages/core/src/diff/format/stylish.ts b/packages/core/src/diff/format/stylish.ts index 5e0b4aff7a..f2fff6d2ac 100644 --- a/packages/core/src/diff/format/stylish.ts +++ b/packages/core/src/diff/format/stylish.ts @@ -32,7 +32,7 @@ const HTTP_METHODS = new Set([ ]); function segmentsOf(pointer: string): string[] { - return pointer.replace(/^#\//, '').split('/'); + return pointer.replace(/^#\//, '').split('/').filter(Boolean); } function groupOf(change: Change): string { @@ -45,8 +45,6 @@ function groupOf(change: Change): string { 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]); diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index d36030f2e3..d16a278384 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -56,7 +56,6 @@ export function diffDocuments(opts: { const baseMap = collect(base, baseVersion); const revisionMap = collect(revision, revisionVersion); - // A removed node only exists in the base, an added one only in the revision. const nodeAt = (key: string) => revisionMap.entries.get(key) ?? baseMap.entries.get(key); const usage = new UsageIndex([...baseMap.usageEdges, ...revisionMap.usageEdges], nodeAt); diff --git a/tests/e2e/diff/oas3-breaking-changes/snapshot.txt b/tests/e2e/diff/oas3-breaking-changes/snapshot.txt deleted file mode 100644 index e6e12ab774..0000000000 --- a/tests/e2e/diff/oas3-breaking-changes/snapshot.txt +++ /dev/null @@ -1,25 +0,0 @@ -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-nullable-equivalence-across-versions/snapshot.txt b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt index dff5c58103..ec158bac99 100644 --- a/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt +++ b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt @@ -1,5 +1,5 @@ openapi - · patch modified · openapi + · patch modified openapi at revision.yaml:1:10 POST /pets diff --git a/tests/e2e/diff/rules.test.ts b/tests/e2e/diff/rules.test.ts index c46979416b..82b0a9797e 100644 --- a/tests/e2e/diff/rules.test.ts +++ b/tests/e2e/diff/rules.test.ts @@ -245,8 +245,12 @@ describe('diff rules', () => { 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.impact === 'major'); - expect(breaking.flatMap((change) => change.verdicts).map((v) => v.ruleId)).toContain(ruleId); + const majorChanges = runJsonDiff(fixture).changes.filter( + (change) => change.impact === 'major' + ); + expect(majorChanges.flatMap((change) => change.verdicts).map((v) => v.ruleId)).toContain( + ruleId + ); await expect(runDiff(fixture)).toMatchFileSnapshot( join(fixturePath(fixture), 'snapshot.txt') From 0d17edbb9cbe3f7d21258bbed7b70d812f58329a Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:44:21 +0300 Subject: [PATCH 36/57] test(core): regenerate the config schema snapshot with the diff block --- .../__tests__/__snapshots__/redocly-yaml.test.ts.snap | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap index 2d43b299e4..eb18a50e25 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -402,6 +402,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "type": "string", }, "decorators": "Decorators", + "diff": "DiffRules", "extends": { "description": "Use extends to inherit rules and their configurations from other rulesets.", "documentationLink": "https://redocly.com/docs/cli/configuration/reference/extends#extends", @@ -474,6 +475,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "async3Preprocessors": "Preprocessors", "async3Rules": "Rules", "decorators": "Decorators", + "diff": "DiffRules", "extends": { "description": "Use extends to inherit rules and their configurations from other rulesets.", "documentationLink": "https://redocly.com/docs/cli/configuration/reference/extends#extends", @@ -559,6 +561,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "corsProxy": "rootRedoclyConfigSchema.corsProxy", "decorators": "Decorators", "developerOnboarding": "rootRedoclyConfigSchema.developerOnboarding", + "diff": "DiffRules", "entitiesCatalog": "rootRedoclyConfigSchema.entitiesCatalog", "env": "rootRedoclyConfigSchema.env", "extends": { @@ -771,6 +774,11 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] }, "properties": {}, }, + "DiffRules": { + "additionalProperties": [Function], + "description": "The \`diff\` block sets the semver impact of each diff rule: \`off\`, \`patch\`, \`minor\`, or \`major\`.", + "properties": {}, + }, "Discriminator": { "description": "When request bodies or response payloads may be one of a number of different schemas, a discriminator object can be used to aid in serialization, deserialization, and validation. The discriminator is a specific object in a schema which is used to inform the consumer of the document of an alternative schema based on the value associated with it.", "documentationLink": "https://redocly.com/learn/openapi/openapi-visual-reference/discriminator#discriminator-object", @@ -1357,6 +1365,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "type": "string", }, "decorators": "Decorators", + "diff": "DiffRules", "extends": { "description": "Use extends to inherit rules and their configurations from other rulesets.", "documentationLink": "https://redocly.com/docs/cli/configuration/reference/extends#extends", From d691b26d8cdb04ea15dd0a26e730ce744ec51e79 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:45:18 +0300 Subject: [PATCH 37/57] fix(diff): read the schema combinator from the AllOf, AnyOf and OneOf node types --- packages/core/src/diff/rules/schema.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/core/src/diff/rules/schema.ts b/packages/core/src/diff/rules/schema.ts index ff0c7b8f90..fe852fae22 100644 --- a/packages/core/src/diff/rules/schema.ts +++ b/packages/core/src/diff/rules/schema.ts @@ -122,9 +122,13 @@ export const SchemaFormatChanged = constraintRule(['format']); export const AdditionalPropertiesChanged = constraintRule(['additionalProperties']); // `oneOf`/`anyOf` list alternatives, so dropping one accepts less; `allOf` combines -// constraints, so adding one accepts less. A `SchemaList` keeps the walker's key, so -// the combinator is the last segment of its key. -const ALTERNATIVE_COMBINATORS = new Set(['oneOf', 'anyOf']); +// constraints, so adding one accepts less. The type tree names each list after its keyword, +// which is what tells a combinator apart from any other list of schemas. +const COMBINATOR_KEYWORDS: Record = { + AllOf: 'allOf', + AnyOf: 'anyOf', + OneOf: 'oneOf', +}; export const SchemaCombinatorChanged: DiffRule = () => ({ Schema(change, { report, direction, nodeAt }) { @@ -132,10 +136,8 @@ export const SchemaCombinatorChanged: DiffRule = () => ({ const parentKey = nodeAt(change.key)?.parentKey; const parent = parentKey ? nodeAt(parentKey) : undefined; - if (parent?.typeName !== 'SchemaList') return; - - const combinator = parent.key.slice(parent.key.lastIndexOf('/') + 1); - if (combinator !== 'allOf' && !ALTERNATIVE_COMBINATORS.has(combinator)) return; + const combinator = parent && COMBINATOR_KEYWORDS[parent.typeName]; + if (!combinator) return; const removed = change.kind === 'removed'; const acceptsLess = combinator === 'allOf' ? !removed : removed; From b82265ca16078f32b3bbd8303a149f28572d8373 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:22:23 +0300 Subject: [PATCH 38/57] feat(config): layer diff rule impacts per specification, fall back to recommended-diff and add --skip-rule --- .changeset/purple-onions-shave.md | 2 +- packages/cli/src/__tests__/fixtures/config.ts | 8 +++- packages/cli/src/commands/diff/index.ts | 15 +++++-- packages/cli/src/commands/diff/types.ts | 1 + packages/cli/src/index.ts | 5 +++ .../__snapshots__/redocly-yaml.test.ts.snap | 16 +++++++ .../config-resolvers.test.ts.snap | 8 ++++ .../__snapshots__/config.test.ts.snap | 7 ++- .../__tests__/__snapshots__/load.test.ts.snap | 11 ++++- .../core/src/config/__tests__/config.test.ts | 11 ++++- .../core/src/config/__tests__/load.test.ts | 24 ++++++++++ packages/core/src/config/config.ts | 36 +++++++++++++-- packages/core/src/config/constants.ts | 2 +- packages/core/src/config/recommended-diff.ts | 41 ++++++++++++++++- packages/core/src/config/types.ts | 4 ++ packages/core/src/config/utils.ts | 12 +++++ .../src/diff/__tests__/diff-config.test.ts | 44 ++++++++++++++++--- .../src/diff/__tests__/diff-documents.test.ts | 8 ++-- packages/core/src/diff/rules/index.ts | 37 ---------------- packages/core/src/types/redocly-yaml.ts | 4 ++ 20 files changed, 234 insertions(+), 62 deletions(-) diff --git a/.changeset/purple-onions-shave.md b/.changeset/purple-onions-shave.md index e5fe64dd4e..2062999db8 100644 --- a/.changeset/purple-onions-shave.md +++ b/.changeset/purple-onions-shave.md @@ -5,4 +5,4 @@ Added an experimental `diff` command that compares two API descriptions, reports what was added, removed, and modified, and rates every change by the semver bump it requires (`patch`, `minor`, or `major`). `--fail-on` sets the impact that fails the run and `--check-version` verifies that `info.version` was bumped accordingly. -Configure the impact per rule in `redocly.yaml` with `extends: [recommended-diff]` and a `diff` block, for example `diff: { operation-removed: minor }`. +Configure the impact per rule in `redocly.yaml` with `extends: [recommended-diff]` and a `diff` block, for example `diff: { operation-removed: minor }`. A `oas3_0Diff`, `oas3_1Diff`, `oas3_2Diff` or `async3Diff` block sets the impact for one specification, the way the per-specification rule blocks work for `lint`. `--skip-rule` turns a rule off for a single run. diff --git a/packages/cli/src/__tests__/fixtures/config.ts b/packages/cli/src/__tests__/fixtures/config.ts index 8037f919bb..63e6432fbf 100644 --- a/packages/cli/src/__tests__/fixtures/config.ts +++ b/packages/cli/src/__tests__/fixtures/config.ts @@ -16,8 +16,14 @@ export const configFixture: Config = { skipPreprocessors: vi.fn(), saveIgnore: vi.fn(), skipDecorators: vi.fn(), + skipDiffRules: vi.fn(), ignore: {}, - diff: {}, + diff: { + oas3_0: {}, + oas3_1: {}, + oas3_2: {}, + async3: {}, + }, decorators: { oas2: {}, oas3_0: {}, diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts index c2adf7962a..517292fde5 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -27,15 +27,22 @@ 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 [{ path: revisionPath, alias }] = await getFallbackApisOrExit([argv.revision], config); + // The revision is the version being released, so its `apis` entry configures the comparison. + const revisionConfig = config.forAlias(alias); + revisionConfig.skipDiffRules(argv['skip-rule']); - const { bundle: baseDocument } = await bundle({ config, ref: basePath }); - const { bundle: revisionDocument } = await bundle({ config, ref: revisionPath }); + const { bundle: baseDocument } = await bundle({ config: revisionConfig, ref: basePath }); + const { bundle: revisionDocument } = await bundle({ config: revisionConfig, ref: revisionPath }); collectSpecData?.(revisionDocument); let result: DiffResult; try { - result = diffDocuments({ base: baseDocument, revision: revisionDocument, config }); + result = diffDocuments({ + base: baseDocument, + revision: revisionDocument, + config: revisionConfig, + }); } catch (error) { if (error instanceof DiffError) { return exitWithError(error.message); diff --git a/packages/cli/src/commands/diff/types.ts b/packages/cli/src/commands/diff/types.ts index 06bb1018a3..4bfe838604 100644 --- a/packages/cli/src/commands/diff/types.ts +++ b/packages/cli/src/commands/diff/types.ts @@ -13,4 +13,5 @@ export type DiffArgv = { output?: string; 'fail-on': DiffFailOn; 'check-version': boolean; + 'skip-rule'?: string[]; } & VerifyConfigOptions; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bd425bfb2d..493d93ca49 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -178,6 +178,11 @@ yargs(hideBin(process.argv)) type: 'boolean', default: false, }, + 'skip-rule': { + description: 'Ignore certain rules.', + array: true, + type: 'string', + }, }), async (argv) => { const { handleDiff } = await import('./commands/diff/index.js'); diff --git a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap index eb18a50e25..9a4f3f478a 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -394,6 +394,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "async2Preprocessors": "Preprocessors", "async2Rules": "Rules", "async3Decorators": "Decorators", + "async3Diff": "DiffRules", "async3Preprocessors": "Preprocessors", "async3Rules": "Rules", "asyncapi": "rootRedoclyConfigSchema.apis_additionalProperties.asyncapi", @@ -419,12 +420,15 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "oas2Preprocessors": "Preprocessors", "oas2Rules": "Rules", "oas3_0Decorators": "Decorators", + "oas3_0Diff": "DiffRules", "oas3_0Preprocessors": "Preprocessors", "oas3_0Rules": "Rules", "oas3_1Decorators": "Decorators", + "oas3_1Diff": "DiffRules", "oas3_1Preprocessors": "Preprocessors", "oas3_1Rules": "Rules", "oas3_2Decorators": "Decorators", + "oas3_2Diff": "DiffRules", "oas3_2Preprocessors": "Preprocessors", "oas3_2Rules": "Rules", "openapi": "rootRedoclyConfigSchema.apis_additionalProperties.openapi", @@ -472,6 +476,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "async2Preprocessors": "Preprocessors", "async2Rules": "Rules", "async3Decorators": "Decorators", + "async3Diff": "DiffRules", "async3Preprocessors": "Preprocessors", "async3Rules": "Rules", "decorators": "Decorators", @@ -488,12 +493,15 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "oas2Preprocessors": "Preprocessors", "oas2Rules": "Rules", "oas3_0Decorators": "Decorators", + "oas3_0Diff": "DiffRules", "oas3_0Preprocessors": "Preprocessors", "oas3_0Rules": "Rules", "oas3_1Decorators": "Decorators", + "oas3_1Diff": "DiffRules", "oas3_1Preprocessors": "Preprocessors", "oas3_1Rules": "Rules", "oas3_2Decorators": "Decorators", + "oas3_2Diff": "DiffRules", "oas3_2Preprocessors": "Preprocessors", "oas3_2Rules": "Rules", "openrpc1Decorators": "Decorators", @@ -543,6 +551,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "async2Preprocessors": "Preprocessors", "async2Rules": "Rules", "async3Decorators": "Decorators", + "async3Diff": "DiffRules", "async3Preprocessors": "Preprocessors", "async3Rules": "Rules", "asyncapi": "rootRedoclyConfigSchema.asyncapi", @@ -612,12 +621,15 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "oas2Preprocessors": "Preprocessors", "oas2Rules": "Rules", "oas3_0Decorators": "Decorators", + "oas3_0Diff": "DiffRules", "oas3_0Preprocessors": "Preprocessors", "oas3_0Rules": "Rules", "oas3_1Decorators": "Decorators", + "oas3_1Diff": "DiffRules", "oas3_1Preprocessors": "Preprocessors", "oas3_1Rules": "Rules", "oas3_2Decorators": "Decorators", + "oas3_2Diff": "DiffRules", "oas3_2Preprocessors": "Preprocessors", "oas3_2Rules": "Rules", "openapi": "rootRedoclyConfigSchema.openapi", @@ -1359,6 +1371,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "async2Preprocessors": "Preprocessors", "async2Rules": "Rules", "async3Decorators": "Decorators", + "async3Diff": "DiffRules", "async3Preprocessors": "Preprocessors", "async3Rules": "Rules", "color": { @@ -1381,12 +1394,15 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "oas2Preprocessors": "Preprocessors", "oas2Rules": "Rules", "oas3_0Decorators": "Decorators", + "oas3_0Diff": "DiffRules", "oas3_0Preprocessors": "Preprocessors", "oas3_0Rules": "Rules", "oas3_1Decorators": "Decorators", + "oas3_1Diff": "DiffRules", "oas3_1Preprocessors": "Preprocessors", "oas3_1Rules": "Rules", "oas3_2Decorators": "Decorators", + "oas3_2Diff": "DiffRules", "oas3_2Preprocessors": "Preprocessors", "oas3_2Rules": "Rules", "openrpc1Decorators": "Decorators", diff --git a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap index 283f927af5..1678eac532 100644 --- a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap @@ -80,6 +80,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -157,6 +158,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -225,6 +227,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -290,6 +293,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", @@ -536,6 +540,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -613,6 +618,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -681,6 +687,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -746,6 +753,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", diff --git a/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap index 953319884f..af326c6d63 100644 --- a/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap @@ -19,7 +19,12 @@ Config { "openrpc1": {}, "overlay1": {}, }, - "diff": {}, + "diff": { + "async3": {}, + "oas3_0": {}, + "oas3_1": {}, + "oas3_2": {}, + }, "doNotResolveExamples": false, "document": undefined, "ignore": { diff --git a/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap index ffd9d559e7..fd001f14b1 100644 --- a/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap @@ -19,7 +19,12 @@ Config { "openrpc1": {}, "overlay1": {}, }, - "diff": {}, + "diff": { + "async3": {}, + "oas3_0": {}, + "oas3_1": {}, + "oas3_2": {}, + }, "doNotResolveExamples": false, "document": undefined, "ignore": {}, @@ -59,6 +64,7 @@ Config { "async2Preprocessors": {}, "async2Rules": {}, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": {}, "decorators": {}, @@ -68,12 +74,15 @@ Config { "oas2Preprocessors": {}, "oas2Rules": {}, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": {}, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": {}, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": {}, "openrpc1Decorators": {}, diff --git a/packages/core/src/config/__tests__/config.test.ts b/packages/core/src/config/__tests__/config.test.ts index 0381d31807..46b2688f34 100644 --- a/packages/core/src/config/__tests__/config.test.ts +++ b/packages/core/src/config/__tests__/config.test.ts @@ -43,7 +43,12 @@ describe('Config.forAlias', () => { "openrpc1": {}, "overlay1": {}, }, - "diff": {}, + "diff": { + "async3": {}, + "oas3_0": {}, + "oas3_1": {}, + "oas3_2": {}, + }, "doNotResolveExamples": false, "document": { "parsed": { @@ -107,6 +112,7 @@ describe('Config.forAlias', () => { "async2Preprocessors": {}, "async2Rules": {}, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": {}, "decorators": {}, @@ -116,12 +122,15 @@ describe('Config.forAlias', () => { "oas2Preprocessors": {}, "oas2Rules": {}, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": {}, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": {}, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": {}, "openrpc1Decorators": {}, diff --git a/packages/core/src/config/__tests__/load.test.ts b/packages/core/src/config/__tests__/load.test.ts index a1b958e0a2..47b194a432 100644 --- a/packages/core/src/config/__tests__/load.test.ts +++ b/packages/core/src/config/__tests__/load.test.ts @@ -203,6 +203,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -283,6 +284,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -353,6 +355,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -420,6 +423,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", @@ -596,6 +600,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -674,6 +679,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -742,6 +748,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -807,6 +814,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", @@ -994,6 +1002,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -1072,6 +1081,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -1140,6 +1150,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -1205,6 +1216,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", @@ -1476,6 +1488,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -1556,6 +1569,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -1626,6 +1640,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -1693,6 +1708,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", @@ -1869,6 +1885,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -1947,6 +1964,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -2015,6 +2033,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -2080,6 +2099,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", @@ -2267,6 +2287,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -2345,6 +2366,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -2413,6 +2435,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -2478,6 +2501,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9e49e88fca..cc346e1edc 100755 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2,7 +2,12 @@ import type { RecheckConfig } from '@redocly/config'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import type { Impact } from '../diff/types.js'; +import { + diffSpecVersions, + isImpactSetting, + type DiffSpecVersion, + type Impact, +} from '../diff/types.js'; import { stringifyYaml } from '../js-yaml/index.js'; import { type Oas2RuleSet, @@ -53,7 +58,7 @@ export class Config { preprocessors: Record>; decorators: Record>; recheck: RecheckConfig; - diff: Record; + diff: Record>; private _usedRules: Set = new Set(); private _usedVersions: Set = new Set(); @@ -163,7 +168,22 @@ export class Config { }; this.recheck = resolvedConfig.recheck ?? { rules: {} }; - this.diff = resolvedConfig.diff ?? {}; + // The common `diff` block is the base for every specification, and a value outside the + // impact ladder is linted as a warning, so it is read as `off` rather than reported. + const impactsOf = (specConfig?: Record) => { + const impacts: Record = {}; + for (const [ruleId, impact] of Object.entries({ ...resolvedConfig.diff, ...specConfig })) { + impacts[ruleId] = isImpactSetting(impact) ? impact : 'off'; + } + return impacts; + }; + + this.diff = { + oas3_0: impactsOf(resolvedConfig.oas3_0Diff), + oas3_1: impactsOf(resolvedConfig.oas3_1Diff), + oas3_2: impactsOf(resolvedConfig.oas3_2Diff), + async3: impactsOf(resolvedConfig.async3Diff), + }; this.ignore = opts.ignore ?? {}; } @@ -457,6 +477,16 @@ export class Config { } } + skipDiffRules(rules?: string[]) { + for (const ruleId of rules || []) { + for (const version of diffSpecVersions) { + if (this.diff[version][ruleId]) { + this.diff[version][ruleId] = 'off'; + } + } + } + } + skipPreprocessors(preprocessors?: string[]) { for (const preprocessorId of preprocessors || []) { for (const version of specVersions) { diff --git a/packages/core/src/config/constants.ts b/packages/core/src/config/constants.ts index aa8853a751..700e7bd7c0 100644 --- a/packages/core/src/config/constants.ts +++ b/packages/core/src/config/constants.ts @@ -1,6 +1,6 @@ export const CONFIG_FILE_NAME = 'redocly.yaml'; -export const DEFAULT_CONFIG = { extends: ['recommended'] }; +export const DEFAULT_CONFIG = { extends: ['recommended', 'recommended-diff'] }; export const IGNORE_FILE = '.redocly.lint-ignore.yaml'; export const IGNORE_BANNER = diff --git a/packages/core/src/config/recommended-diff.ts b/packages/core/src/config/recommended-diff.ts index d8acbd80e9..22df887de8 100644 --- a/packages/core/src/config/recommended-diff.ts +++ b/packages/core/src/config/recommended-diff.ts @@ -1,8 +1,45 @@ -import { recommendedDiffRules } from '../diff/rules/index.js'; +import type { DiffRuleId } from '../diff/rules/index.js'; +import type { Impact } from '../diff/types.js'; import { type RawGovernanceConfig } from './types.js'; +export const defaultDiffRules: Record = { + 'additional-properties-changed': 'major', + 'channel-address-changed': 'major', + 'channel-removed': 'major', + 'enum-values-added': 'major', + 'enum-values-removed': 'major', + 'media-type-removed': 'major', + 'message-content-type-changed': 'major', + 'message-removed': 'major', + 'numeric-range-changed': 'major', + 'operation-action-changed': 'major', + 'operation-removed': 'major', + 'parameter-added-required': 'major', + 'parameter-became-required': 'major', + 'parameter-removed': 'major', + 'parameter-serialization-changed': 'major', + 'path-removed': 'major', + 'property-removed-from-response': 'major', + 'ref-target-changed': 'major', + 'request-body-became-required': 'major', + 'request-body-removed': 'major', + 'required-properties-added': 'major', + 'required-properties-removed': 'major', + 'response-header-removed': 'major', + 'response-removed': 'major', + 'schema-combinator-changed': 'major', + 'schema-format-changed': 'major', + 'schema-type-changed': 'major', + 'security-requirement-added': 'major', + 'security-scheme-changed': 'major', + 'security-scheme-removed': 'major', + 'security-scopes-added': 'major', + 'server-removed': 'major', + 'string-length-changed': 'major', +}; + const recommendedDiff: RawGovernanceConfig<'built-in'> = { - diff: recommendedDiffRules, + diff: defaultDiffRules, }; export default recommendedDiff; diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 131362d3d1..7c05a4ed16 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -101,6 +101,10 @@ export type RawGovernanceConfig = graphqlRules?: RuleMap; /** Semver impact per diff rule; `off` skips the rule. */ diff?: RuleMap; + oas3_0Diff?: RuleMap; + oas3_1Diff?: RuleMap; + oas3_2Diff?: RuleMap; + async3Diff?: RuleMap; preprocessors?: Record; oas2Preprocessors?: Record< diff --git a/packages/core/src/config/utils.ts b/packages/core/src/config/utils.ts index e6c430d167..7a4637c7dc 100644 --- a/packages/core/src/config/utils.ts +++ b/packages/core/src/config/utils.ts @@ -61,6 +61,10 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { openrpc1Rules: {}, graphqlRules: {}, diff: {}, + oas3_0Diff: {}, + oas3_1Diff: {}, + oas3_2Diff: {}, + async3Diff: {}, preprocessors: {}, oas2Preprocessors: {}, @@ -120,6 +124,14 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { assignConfig(result.graphqlRules, rulesConf.graphqlRules); assignOnlyExistingConfig(result.graphqlRules, rulesConf.rules); assignConfig(result.diff, rulesConf.diff); + assignConfig(result.oas3_0Diff, rulesConf.oas3_0Diff); + assignOnlyExistingConfig(result.oas3_0Diff, rulesConf.diff); + assignConfig(result.oas3_1Diff, rulesConf.oas3_1Diff); + assignOnlyExistingConfig(result.oas3_1Diff, rulesConf.diff); + assignConfig(result.oas3_2Diff, rulesConf.oas3_2Diff); + assignOnlyExistingConfig(result.oas3_2Diff, rulesConf.diff); + assignConfig(result.async3Diff, rulesConf.async3Diff); + assignOnlyExistingConfig(result.async3Diff, rulesConf.diff); assignConfig(result.preprocessors, rulesConf.preprocessors); assignConfig(result.oas2Preprocessors, rulesConf.oas2Preprocessors); diff --git a/packages/core/src/diff/__tests__/diff-config.test.ts b/packages/core/src/diff/__tests__/diff-config.test.ts index a5283bceec..164a0a7c72 100644 --- a/packages/core/src/diff/__tests__/diff-config.test.ts +++ b/packages/core/src/diff/__tests__/diff-config.test.ts @@ -39,9 +39,9 @@ describe('diff configuration', () => { diff: { 'operation-removed': 'minor', 'enum-values-added': 'off' }, }); - expect(config.diff['operation-removed']).toBe('minor'); - expect(config.diff['enum-values-added']).toBe('off'); - expect(config.diff['path-removed']).toBe('major'); + expect(config.diff.oas3_1['operation-removed']).toBe('minor'); + expect(config.diff.oas3_1['enum-values-added']).toBe('off'); + expect(config.diff.oas3_1['path-removed']).toBe('major'); }); it('uses the configured impact for a verdict', async () => { @@ -58,16 +58,48 @@ describe('diff configuration', () => { expect(result.bump).toBe('minor'); }); - it('falls back to recommended-diff when the config says nothing about diff', async () => { - const config = await createConfig({ extends: ['recommended'] }); + it('takes the impacts from the preset when the config says nothing about diff', async () => { + const config = await createConfig({ extends: ['recommended-diff'] }); expect(diffWith(config).changes[0].impact).toBe('major'); }); - it('runs only the listed rules when a diff map is given without the preset', async () => { + it('lets a spec block override the impact a preset set', async () => { + const config = await createConfig({ + extends: ['recommended-diff'], + oas3_1Diff: { 'operation-removed': 'minor' }, + }); + + expect(diffWith(config).changes[0].impact).toBe('minor'); + }); + + it('runs only the listed rules when a diff map is given without a ruleset', async () => { const config = await createConfig({ diff: { 'path-removed': 'major' } }); // operation-removed is not in the map, so the removal is an unjudged patch expect(diffWith(config).changes[0].impact).toBe('patch'); }); + + it('reads an impact outside the ladder as off', async () => { + // The config lint reports the value as a warning; the report must not carry it. + const config = await createConfig({ + extends: ['recommended-diff'], + diff: { 'operation-removed': 'blocker' }, + } as never); + + const result = diffWith(config); + + expect(result.changes[0].impact).toBe('patch'); + expect(result.summary).toEqual({ major: 0, minor: 0, patch: 1 }); + }); + + it('turns a rule off when the config says so', async () => { + const config = await createConfig({ + extends: ['recommended-diff'], + diff: { 'operation-removed': 'off' }, + }); + + // No rule speaks for the removal any more, so it falls back to an unjudged patch. + expect(diffWith(config).changes[0].impact).toBe('patch'); + }); }); diff --git a/packages/core/src/diff/__tests__/diff-documents.test.ts b/packages/core/src/diff/__tests__/diff-documents.test.ts index 704d35e3d1..e426a8739a 100644 --- a/packages/core/src/diff/__tests__/diff-documents.test.ts +++ b/packages/core/src/diff/__tests__/diff-documents.test.ts @@ -63,7 +63,7 @@ function report(result: DiffResult): string { describe('diffDocuments', () => { it('matches reordered parameters by identity and judges what actually changed', async () => { - const config = await createConfig({}); + const config = await createConfig({ extends: ['recommended-diff'] }); const result = diffDocuments({ base: makeDocumentFromString(BASE, 'base.yaml'), revision: makeDocumentFromString(REVISION, 'rev.yaml'), @@ -88,7 +88,7 @@ describe('diffDocuments', () => { }); it('throws DiffError for different spec families', async () => { - const config = await createConfig({}); + const config = await createConfig({ extends: ['recommended-diff'] }); const oas2 = makeDocumentFromString( outdent` swagger: '2.0' @@ -103,7 +103,7 @@ describe('diffDocuments', () => { }); it('matches renamed path parameters instead of remove+add', async () => { - const config = await createConfig({}); + const config = await createConfig({ extends: ['recommended-diff'] }); const makeSpec = (param: string) => outdent` openapi: 3.1.0 info: { title: T, version: '1' } @@ -138,7 +138,7 @@ describe('diffDocuments', () => { }); it('keys two templates of the same shape in document order, suffixing the second', async () => { - const config = await createConfig({}); + const config = await createConfig({ extends: ['recommended-diff'] }); const base = makeDocumentFromString( outdent` openapi: 3.1.0 diff --git a/packages/core/src/diff/rules/index.ts b/packages/core/src/diff/rules/index.ts index b864679c82..62cd8cd829 100644 --- a/packages/core/src/diff/rules/index.ts +++ b/packages/core/src/diff/rules/index.ts @@ -11,40 +11,3 @@ export const diffRuleIds: string[] = [ ]; export type DiffRuleMap = Partial>; - -/** The built-in preset: every rule is a major, which is what "breaking" meant. */ -export const recommendedDiffRules: Record = { - 'additional-properties-changed': 'major', - 'channel-address-changed': 'major', - 'channel-removed': 'major', - 'enum-values-added': 'major', - 'enum-values-removed': 'major', - 'media-type-removed': 'major', - 'message-content-type-changed': 'major', - 'message-removed': 'major', - 'numeric-range-changed': 'major', - 'operation-action-changed': 'major', - 'operation-removed': 'major', - 'parameter-added-required': 'major', - 'parameter-became-required': 'major', - 'parameter-removed': 'major', - 'parameter-serialization-changed': 'major', - 'path-removed': 'major', - 'property-removed-from-response': 'major', - 'ref-target-changed': 'major', - 'request-body-became-required': 'major', - 'request-body-removed': 'major', - 'required-properties-added': 'major', - 'required-properties-removed': 'major', - 'response-header-removed': 'major', - 'response-removed': 'major', - 'schema-combinator-changed': 'major', - 'schema-format-changed': 'major', - 'schema-type-changed': 'major', - 'security-requirement-added': 'major', - 'security-scheme-changed': 'major', - 'security-scheme-removed': 'major', - 'security-scopes-added': 'major', - 'server-removed': 'major', - 'string-length-changed': 'major', -}; diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index 7f41a22a9f..3b9c724ac6 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -299,6 +299,10 @@ const configGovernanceProperties: Record< openrpc1Rules: 'Rules', graphqlRules: 'Rules', diff: 'DiffRules', + oas3_0Diff: 'DiffRules', + oas3_1Diff: 'DiffRules', + oas3_2Diff: 'DiffRules', + async3Diff: 'DiffRules', preprocessors: 'Preprocessors', oas2Preprocessors: 'Preprocessors', oas3_0Preprocessors: 'Preprocessors', From 61aba67d7b42e3c1cec4d3aa7d8a2901c987b98e Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:29:19 +0300 Subject: [PATCH 39/57] feat(config): take diff rules from plugins, rename the preset to diff-recommended and configure diff once per project --- .changeset/purple-onions-shave.md | 4 +- packages/cli/src/__tests__/fixtures/config.ts | 2 + packages/cli/src/commands/diff/index.ts | 16 ++---- .../__snapshots__/redocly-yaml.test.ts.snap | 5 -- packages/core/src/config/builtIn.ts | 9 +++- packages/core/src/config/config-resolvers.ts | 12 +++++ packages/core/src/config/config.ts | 53 ++++++++++--------- packages/core/src/config/constants.ts | 2 +- ...ecommended-diff.ts => diff-recommended.ts} | 4 +- packages/core/src/config/types.ts | 18 +++++-- packages/core/src/config/utils.ts | 1 + .../src/diff/__tests__/diff-config.test.ts | 31 ++++------- .../src/diff/__tests__/diff-documents.test.ts | 8 +-- packages/core/src/diff/rules/index.ts | 7 ++- packages/core/src/index.ts | 1 - packages/core/src/types/redocly-yaml.ts | 20 ++++++- 16 files changed, 111 insertions(+), 82 deletions(-) rename packages/core/src/config/{recommended-diff.ts => diff-recommended.ts} (94%) diff --git a/.changeset/purple-onions-shave.md b/.changeset/purple-onions-shave.md index 2062999db8..ce1594648b 100644 --- a/.changeset/purple-onions-shave.md +++ b/.changeset/purple-onions-shave.md @@ -5,4 +5,6 @@ Added an experimental `diff` command that compares two API descriptions, reports what was added, removed, and modified, and rates every change by the semver bump it requires (`patch`, `minor`, or `major`). `--fail-on` sets the impact that fails the run and `--check-version` verifies that `info.version` was bumped accordingly. -Configure the impact per rule in `redocly.yaml` with `extends: [recommended-diff]` and a `diff` block, for example `diff: { operation-removed: minor }`. A `oas3_0Diff`, `oas3_1Diff`, `oas3_2Diff` or `async3Diff` block sets the impact for one specification, the way the per-specification rule blocks work for `lint`. `--skip-rule` turns a rule off for a single run. +Configure the impact per rule in `redocly.yaml` with `extends: [diff-recommended]` and a `diff` block, for example `diff: { operation-removed: minor }`. A `oas3_0Diff`, `oas3_1Diff`, `oas3_2Diff` or `async3Diff` block sets the impact for one specification, the way the per-specification rule blocks work for `lint`. `--skip-rule` turns a rule off for a single run. + +A plugin can add diff rules through a `diff` block with `oas3` or `async3` rules, the way it adds lint rules; its rules are named `/` and are off until the `diff` block gives them an impact. diff --git a/packages/cli/src/__tests__/fixtures/config.ts b/packages/cli/src/__tests__/fixtures/config.ts index 63e6432fbf..8c138c29bc 100644 --- a/packages/cli/src/__tests__/fixtures/config.ts +++ b/packages/cli/src/__tests__/fixtures/config.ts @@ -17,6 +17,8 @@ export const configFixture: Config = { saveIgnore: vi.fn(), skipDecorators: vi.fn(), skipDiffRules: vi.fn(), + getDiffRulesForSpecVersion: vi.fn(() => []), + getDiffImpact: vi.fn(() => 'off' as const), ignore: {}, diff: { oas3_0: {}, diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts index 517292fde5..b554c27bd2 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -27,22 +27,16 @@ export async function handleDiff({ argv, config, collectSpecData }: CommandArgs< const startedAt = performance.now(); const [{ path: basePath }] = await getFallbackApisOrExit([argv.base], config); - const [{ path: revisionPath, alias }] = await getFallbackApisOrExit([argv.revision], config); - // The revision is the version being released, so its `apis` entry configures the comparison. - const revisionConfig = config.forAlias(alias); - revisionConfig.skipDiffRules(argv['skip-rule']); + const [{ path: revisionPath }] = await getFallbackApisOrExit([argv.revision], config); + config.skipDiffRules(argv['skip-rule']); - const { bundle: baseDocument } = await bundle({ config: revisionConfig, ref: basePath }); - const { bundle: revisionDocument } = await bundle({ config: revisionConfig, ref: revisionPath }); + 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: revisionConfig, - }); + result = diffDocuments({ base: baseDocument, revision: revisionDocument, config }); } catch (error) { if (error instanceof DiffError) { return exitWithError(error.message); diff --git a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap index 9a4f3f478a..3218db61af 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -394,7 +394,6 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "async2Preprocessors": "Preprocessors", "async2Rules": "Rules", "async3Decorators": "Decorators", - "async3Diff": "DiffRules", "async3Preprocessors": "Preprocessors", "async3Rules": "Rules", "asyncapi": "rootRedoclyConfigSchema.apis_additionalProperties.asyncapi", @@ -403,7 +402,6 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "type": "string", }, "decorators": "Decorators", - "diff": "DiffRules", "extends": { "description": "Use extends to inherit rules and their configurations from other rulesets.", "documentationLink": "https://redocly.com/docs/cli/configuration/reference/extends#extends", @@ -420,15 +418,12 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "oas2Preprocessors": "Preprocessors", "oas2Rules": "Rules", "oas3_0Decorators": "Decorators", - "oas3_0Diff": "DiffRules", "oas3_0Preprocessors": "Preprocessors", "oas3_0Rules": "Rules", "oas3_1Decorators": "Decorators", - "oas3_1Diff": "DiffRules", "oas3_1Preprocessors": "Preprocessors", "oas3_1Rules": "Rules", "oas3_2Decorators": "Decorators", - "oas3_2Diff": "DiffRules", "oas3_2Preprocessors": "Preprocessors", "oas3_2Rules": "Rules", "openapi": "rootRedoclyConfigSchema.apis_additionalProperties.openapi", diff --git a/packages/core/src/config/builtIn.ts b/packages/core/src/config/builtIn.ts index 28e7b020d4..a84c7bac35 100644 --- a/packages/core/src/config/builtIn.ts +++ b/packages/core/src/config/builtIn.ts @@ -5,6 +5,7 @@ import { decorators as oas2Decorators } from '../decorators/oas2/index.js'; import { decorators as oas3Decorators } from '../decorators/oas3/index.js'; import { decorators as openrpc1Decorators } from '../decorators/openrpc/index.js'; import { decorators as overlay1Decorators } from '../decorators/overlay1/index.js'; +import { async3Rules as async3DiffRules, oas3Rules as oas3DiffRules } from '../diff/rules/index.js'; import { rules as arazzo1Rules, preprocessors as arazzoPreprocessors, @@ -29,8 +30,8 @@ import { preprocessors as overlay1Preprocessors, } from '../rules/overlay1/index.js'; import all from './all.js'; +import diffRecommended from './diff-recommended.js'; import minimal from './minimal.js'; -import recommendedDiff from './recommended-diff.js'; import recommendedStrict from './recommended-strict.js'; import recommended from './recommended.js'; import spec from './spec.js'; @@ -39,7 +40,7 @@ import { type Plugin, type RawGovernanceConfig } from './types.js'; export const builtInConfigs: Record = { recommended, 'recommended-strict': recommendedStrict, - 'recommended-diff': recommendedDiff, + 'diff-recommended': diffRecommended, minimal, all, spec, @@ -75,5 +76,9 @@ export const defaultPlugin: Plugin<'built-in'> = { overlay1: overlay1Decorators, openrpc1: openrpc1Decorators, }, + diff: { + oas3: oas3DiffRules, + async3: async3DiffRules, + }, configs: builtInConfigs, }; diff --git a/packages/core/src/config/config-resolvers.ts b/packages/core/src/config/config-resolvers.ts index 0830b21f46..4ae14122fb 100644 --- a/packages/core/src/config/config-resolvers.ts +++ b/packages/core/src/config/config-resolvers.ts @@ -410,6 +410,18 @@ export async function resolvePlugins( plugin.rules.graphql = prefixRules(pluginInstance.rules.graphql, id); } } + if (pluginInstance.diff) { + if (!pluginInstance.diff.oas3 && !pluginInstance.diff.async3) { + throw new Error(`Plugin diff rules must have \`oas3\` or \`async3\` rules "${p}.`); + } + plugin.diff = {}; + if (pluginInstance.diff.oas3) { + plugin.diff.oas3 = prefixRules(pluginInstance.diff.oas3, id); + } + if (pluginInstance.diff.async3) { + plugin.diff.async3 = prefixRules(pluginInstance.diff.async3, id); + } + } if (pluginInstance.preprocessors) { if ( !pluginInstance.preprocessors.oas3 && diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index cc346e1edc..583f65f559 100755 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2,12 +2,7 @@ import type { RecheckConfig } from '@redocly/config'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { - diffSpecVersions, - isImpactSetting, - type DiffSpecVersion, - type Impact, -} from '../diff/types.js'; +import type { DiffRule, Impact } from '../diff/types.js'; import { stringifyYaml } from '../js-yaml/index.js'; import { type Oas2RuleSet, @@ -25,6 +20,7 @@ import { import { isAbsoluteUrl } from '../ref-utils.js'; import type { Document, ResolvedRefMap } from '../resolve.js'; import type { NodeType } from '../types/index.js'; +import { isDefined } from '../utils/is-defined.js'; import { isPlainObject } from '../utils/is-plain-object.js'; import { omit } from '../utils/omit.js'; import { slash } from '../utils/slash.js'; @@ -58,7 +54,8 @@ export class Config { preprocessors: Record>; decorators: Record>; recheck: RecheckConfig; - diff: Record>; + /** Only the specifications the diff rules cover; the rest are absent, like in `DIRECTIONS`. */ + diff: Partial>>; private _usedRules: Set = new Set(); private _usedVersions: Set = new Set(); @@ -168,21 +165,11 @@ export class Config { }; this.recheck = resolvedConfig.recheck ?? { rules: {} }; - // The common `diff` block is the base for every specification, and a value outside the - // impact ladder is linted as a warning, so it is read as `off` rather than reported. - const impactsOf = (specConfig?: Record) => { - const impacts: Record = {}; - for (const [ruleId, impact] of Object.entries({ ...resolvedConfig.diff, ...specConfig })) { - impacts[ruleId] = isImpactSetting(impact) ? impact : 'off'; - } - return impacts; - }; - this.diff = { - oas3_0: impactsOf(resolvedConfig.oas3_0Diff), - oas3_1: impactsOf(resolvedConfig.oas3_1Diff), - oas3_2: impactsOf(resolvedConfig.oas3_2Diff), - async3: impactsOf(resolvedConfig.async3Diff), + oas3_0: { ...resolvedConfig.diff, ...resolvedConfig.oas3_0Diff }, + oas3_1: { ...resolvedConfig.diff, ...resolvedConfig.oas3_1Diff }, + oas3_2: { ...resolvedConfig.diff, ...resolvedConfig.oas3_2Diff }, + async3: { ...resolvedConfig.diff, ...resolvedConfig.async3Diff }, }; this.ignore = opts.ignore ?? {}; @@ -370,6 +357,24 @@ export class Config { } // TODO: add rules for redocly.yaml / entities? + /** The diff rule sets every plugin offers for that specification family. */ + getDiffRulesForSpecVersion(version: SpecMajorVersion): Record[] { + switch (version) { + case 'oas3': + return this.plugins.map((plugin) => plugin.diff?.oas3).filter(isDefined); + case 'async3': + return this.plugins.map((plugin) => plugin.diff?.async3).filter(isDefined); + default: + return []; + } + } + + getDiffImpact(ruleId: string, specVersion: SpecVersion): Impact | 'off' { + this._usedRules.add(ruleId); + this._usedVersions.add(specVersion); + return this.diff[specVersion]?.[ruleId] || 'off'; + } + getRulesForSpecVersion(version: SpecMajorVersion) { switch (version) { case 'oas3': { @@ -479,9 +484,9 @@ export class Config { skipDiffRules(rules?: string[]) { for (const ruleId of rules || []) { - for (const version of diffSpecVersions) { - if (this.diff[version][ruleId]) { - this.diff[version][ruleId] = 'off'; + for (const impacts of Object.values(this.diff)) { + if (impacts[ruleId]) { + impacts[ruleId] = 'off'; } } } diff --git a/packages/core/src/config/constants.ts b/packages/core/src/config/constants.ts index 700e7bd7c0..c35144bd65 100644 --- a/packages/core/src/config/constants.ts +++ b/packages/core/src/config/constants.ts @@ -1,6 +1,6 @@ export const CONFIG_FILE_NAME = 'redocly.yaml'; -export const DEFAULT_CONFIG = { extends: ['recommended', 'recommended-diff'] }; +export const DEFAULT_CONFIG = { extends: ['recommended', 'diff-recommended'] }; export const IGNORE_FILE = '.redocly.lint-ignore.yaml'; export const IGNORE_BANNER = diff --git a/packages/core/src/config/recommended-diff.ts b/packages/core/src/config/diff-recommended.ts similarity index 94% rename from packages/core/src/config/recommended-diff.ts rename to packages/core/src/config/diff-recommended.ts index 22df887de8..cf0adde6dd 100644 --- a/packages/core/src/config/recommended-diff.ts +++ b/packages/core/src/config/diff-recommended.ts @@ -38,8 +38,8 @@ export const defaultDiffRules: Record = { 'string-length-changed': 'major', }; -const recommendedDiff: RawGovernanceConfig<'built-in'> = { +const diffRecommended: RawGovernanceConfig<'built-in'> = { diff: defaultDiffRules, }; -export default recommendedDiff; +export default diffRecommended; diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 7c05a4ed16..b521220d90 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -1,8 +1,8 @@ import type { ApiConfig, RecheckConfig, RedoclyConfig } from '@redocly/config'; import type { JSONSchema } from 'json-schema-to-ts'; -import type { DiffRuleId } from '../diff/rules/index.js'; -import type { Impact } from '../diff/types.js'; +import type { Async3DiffRuleId, DiffRuleId, Oas3DiffRuleId } from '../diff/rules/index.js'; +import type { DiffRule, Impact } from '../diff/types.js'; import type { SpecMajorVersion, Oas3DecoratorsSet, @@ -99,7 +99,12 @@ export type RawGovernanceConfig = overlay1Rules?: RuleMap; openrpc1Rules?: RuleMap; graphqlRules?: RuleMap; - /** Semver impact per diff rule; `off` skips the rule. */ + /** + * Semver impact per diff rule; `off` skips the rule. + * TODO: once `@redocly/config` declares the diff blocks, drop these five fields — + * `RawUniversalConfig` already spreads `RedoclyConfig`, so keeping both would make the + * upstream declaration unreachable. The `DiffRule`/`DiffRuleId` imports go with them. + */ diff?: RuleMap; oas3_0Diff?: RuleMap; oas3_1Diff?: RuleMap; @@ -199,6 +204,12 @@ export type RulesConfig = { export type CustomRulesConfig = RulesConfig; +/** Diff rules are keyed by specification family, the way the other plugin slices are. */ +export type DiffRulesConfig = { + oas3?: RuleMap; + async3?: RuleMap; +}; + export type AssertionContext = Partial & SkipFunctionContext & { node: any }; export type AssertResult = { message?: string; location?: Location }; @@ -217,6 +228,7 @@ export type Plugin = { rules?: RulesConfig; preprocessors?: PreprocessorsConfig; decorators?: DecoratorsConfig; + diff?: DiffRulesConfig; typeExtension?: TypeExtensionsConfig; assertions?: AssertionsConfig; diff --git a/packages/core/src/config/utils.ts b/packages/core/src/config/utils.ts index 7a4637c7dc..eb553e6f65 100644 --- a/packages/core/src/config/utils.ts +++ b/packages/core/src/config/utils.ts @@ -123,6 +123,7 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { assignOnlyExistingConfig(result.openrpc1Rules, rulesConf.rules); assignConfig(result.graphqlRules, rulesConf.graphqlRules); assignOnlyExistingConfig(result.graphqlRules, rulesConf.rules); + assignConfig(result.diff, rulesConf.diff); assignConfig(result.oas3_0Diff, rulesConf.oas3_0Diff); assignOnlyExistingConfig(result.oas3_0Diff, rulesConf.diff); diff --git a/packages/core/src/diff/__tests__/diff-config.test.ts b/packages/core/src/diff/__tests__/diff-config.test.ts index 164a0a7c72..f5a9adff06 100644 --- a/packages/core/src/diff/__tests__/diff-config.test.ts +++ b/packages/core/src/diff/__tests__/diff-config.test.ts @@ -33,20 +33,20 @@ function diffWith(config: Awaited>) { } describe('diff configuration', () => { - it('merges the recommended-diff preset with the user map, later wins', async () => { + it('merges the diff-recommended preset with the user map, later wins', async () => { const config = await createConfig({ - extends: ['recommended-diff'], + extends: ['diff-recommended'], diff: { 'operation-removed': 'minor', 'enum-values-added': 'off' }, }); - expect(config.diff.oas3_1['operation-removed']).toBe('minor'); - expect(config.diff.oas3_1['enum-values-added']).toBe('off'); - expect(config.diff.oas3_1['path-removed']).toBe('major'); + expect(config.diff.oas3_1?.['operation-removed']).toBe('minor'); + expect(config.diff.oas3_1?.['enum-values-added']).toBe('off'); + expect(config.diff.oas3_1?.['path-removed']).toBe('major'); }); it('uses the configured impact for a verdict', async () => { const config = await createConfig({ - extends: ['recommended-diff'], + extends: ['diff-recommended'], diff: { 'operation-removed': 'minor' }, }); @@ -59,14 +59,14 @@ describe('diff configuration', () => { }); it('takes the impacts from the preset when the config says nothing about diff', async () => { - const config = await createConfig({ extends: ['recommended-diff'] }); + const config = await createConfig({ extends: ['diff-recommended'] }); expect(diffWith(config).changes[0].impact).toBe('major'); }); it('lets a spec block override the impact a preset set', async () => { const config = await createConfig({ - extends: ['recommended-diff'], + extends: ['diff-recommended'], oas3_1Diff: { 'operation-removed': 'minor' }, }); @@ -80,22 +80,9 @@ describe('diff configuration', () => { expect(diffWith(config).changes[0].impact).toBe('patch'); }); - it('reads an impact outside the ladder as off', async () => { - // The config lint reports the value as a warning; the report must not carry it. - const config = await createConfig({ - extends: ['recommended-diff'], - diff: { 'operation-removed': 'blocker' }, - } as never); - - const result = diffWith(config); - - expect(result.changes[0].impact).toBe('patch'); - expect(result.summary).toEqual({ major: 0, minor: 0, patch: 1 }); - }); - it('turns a rule off when the config says so', async () => { const config = await createConfig({ - extends: ['recommended-diff'], + extends: ['diff-recommended'], diff: { 'operation-removed': 'off' }, }); diff --git a/packages/core/src/diff/__tests__/diff-documents.test.ts b/packages/core/src/diff/__tests__/diff-documents.test.ts index e426a8739a..b3a7ff26a7 100644 --- a/packages/core/src/diff/__tests__/diff-documents.test.ts +++ b/packages/core/src/diff/__tests__/diff-documents.test.ts @@ -63,7 +63,7 @@ function report(result: DiffResult): string { describe('diffDocuments', () => { it('matches reordered parameters by identity and judges what actually changed', async () => { - const config = await createConfig({ extends: ['recommended-diff'] }); + const config = await createConfig({ extends: ['diff-recommended'] }); const result = diffDocuments({ base: makeDocumentFromString(BASE, 'base.yaml'), revision: makeDocumentFromString(REVISION, 'rev.yaml'), @@ -88,7 +88,7 @@ describe('diffDocuments', () => { }); it('throws DiffError for different spec families', async () => { - const config = await createConfig({ extends: ['recommended-diff'] }); + const config = await createConfig({ extends: ['diff-recommended'] }); const oas2 = makeDocumentFromString( outdent` swagger: '2.0' @@ -103,7 +103,7 @@ describe('diffDocuments', () => { }); it('matches renamed path parameters instead of remove+add', async () => { - const config = await createConfig({ extends: ['recommended-diff'] }); + const config = await createConfig({ extends: ['diff-recommended'] }); const makeSpec = (param: string) => outdent` openapi: 3.1.0 info: { title: T, version: '1' } @@ -138,7 +138,7 @@ describe('diffDocuments', () => { }); it('keys two templates of the same shape in document order, suffixing the second', async () => { - const config = await createConfig({ extends: ['recommended-diff'] }); + const config = await createConfig({ extends: ['diff-recommended'] }); const base = makeDocumentFromString( outdent` openapi: 3.1.0 diff --git a/packages/core/src/diff/rules/index.ts b/packages/core/src/diff/rules/index.ts index 62cd8cd829..2f70a1b240 100644 --- a/packages/core/src/diff/rules/index.ts +++ b/packages/core/src/diff/rules/index.ts @@ -1,13 +1,12 @@ -import type { Impact } from '../types.js'; import { async3Rules } from './async3.js'; import { oas3Rules } from './oas3.js'; export { async3Rules, oas3Rules }; -export type DiffRuleId = keyof typeof oas3Rules | keyof typeof async3Rules; +export type Oas3DiffRuleId = keyof typeof oas3Rules; +export type Async3DiffRuleId = keyof typeof async3Rules; +export type DiffRuleId = Oas3DiffRuleId | Async3DiffRuleId; export const diffRuleIds: string[] = [ ...new Set([...Object.keys(oas3Rules), ...Object.keys(async3Rules)]), ]; - -export type DiffRuleMap = Partial>; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1b41a8212f..b9348cf8ef 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -139,7 +139,6 @@ export { type Impact, type JudgedChange, } from './diff/types.js'; -export type { DiffRuleMap } from './diff/rules/index.js'; export { diffReportFormats, type DiffReportFormat } from './diff/format/index.js'; export { diffToProblems } from './diff/format/problems.js'; export { diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index 3b9c724ac6..1192ce1fd2 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -298,6 +298,8 @@ const configGovernanceProperties: Record< overlay1Rules: 'Rules', openrpc1Rules: 'Rules', graphqlRules: 'Rules', + // TODO: move the diff blocks into the Redocly config schema (@redocly/config); they are + // declared here because that schema does not know about them yet. diff: 'DiffRules', oas3_0Diff: 'DiffRules', oas3_1Diff: 'DiffRules', @@ -361,7 +363,17 @@ const createConfigApisProperties = (nodeTypes: Record): NodeTy ...nodeTypes['rootRedoclyConfigSchema.apis_additionalProperties'], properties: { ...nodeTypes['rootRedoclyConfigSchema.apis_additionalProperties']?.properties, - ...omit(ConfigGovernance.properties, ['plugins']), // plugins are not allowed in apis + // plugins are not allowed in apis, and diff compares two apis, so it is configured once. + // TODO: when the diff blocks move to @redocly/config, this exclusion has to move with + // them — otherwise the upstream schema will allow them per api again. + ...omit(ConfigGovernance.properties, [ + 'plugins', + 'diff', + 'oas3_0Diff', + 'oas3_1Diff', + 'oas3_2Diff', + 'async3Diff', + ]), // TODO: move `client` and `clientOutput` into the Redocly config schema (@redocly/config). client: 'Client', clientOutput: { type: 'string' }, @@ -461,12 +473,16 @@ const Rules: NodeType = { }, }; +// TODO: this node hand-writes what the Redocly config schema will generate for the diff +// blocks; delete it, its `configTypes` entry and the `diffRuleIds` import once it does. const DiffRules: NodeType = { properties: {}, description: 'The `diff` block sets the semver impact of each diff rule: `off`, `patch`, `minor`, or `major`.', additionalProperties: (_value: unknown, key: string) => - diffRuleIds.includes(key) ? { enum: ['off', 'patch', 'minor', 'major'] } : undefined, + diffRuleIds.includes(key) || isCustomRuleId(key) + ? { enum: ['off', 'patch', 'minor', 'major'] } + : undefined, }; const BuiltinRule: NodeType = { From 9b05255e0879882c47818c2cd1b8f5ce7fc47a27 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:32:04 +0300 Subject: [PATCH 40/57] refactor(config): drop the unused-rule bookkeeping from getDiffImpact --- packages/core/src/config/config.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 583f65f559..4dc9999068 100755 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -370,8 +370,6 @@ export class Config { } getDiffImpact(ruleId: string, specVersion: SpecVersion): Impact | 'off' { - this._usedRules.add(ruleId); - this._usedVersions.add(specVersion); return this.diff[specVersion]?.[ruleId] || 'off'; } From b5165105b0f939a65a0321fbe1ac6c45ee346db9 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:36:09 +0300 Subject: [PATCH 41/57] chore: refactor config --- packages/core/src/config/builtIn.ts | 8 +++++- packages/core/src/config/config-resolvers.ts | 25 +++++++++-------- packages/core/src/config/config.ts | 28 +++++++++---------- packages/core/src/config/types.ts | 19 ++++--------- packages/core/src/config/utils.ts | 29 ++++++++++---------- packages/core/src/types/redocly-yaml.ts | 19 +++++-------- 6 files changed, 61 insertions(+), 67 deletions(-) diff --git a/packages/core/src/config/builtIn.ts b/packages/core/src/config/builtIn.ts index a84c7bac35..5a2fa92e91 100644 --- a/packages/core/src/config/builtIn.ts +++ b/packages/core/src/config/builtIn.ts @@ -38,12 +38,18 @@ import spec from './spec.js'; import { type Plugin, type RawGovernanceConfig } from './types.js'; export const builtInConfigs: Record = { + /** + * lint presets + */ recommended, 'recommended-strict': recommendedStrict, - 'diff-recommended': diffRecommended, minimal, all, spec, + /** + * diff presets + */ + 'diff-recommended': diffRecommended, }; export const defaultPlugin: Plugin<'built-in'> = { diff --git a/packages/core/src/config/config-resolvers.ts b/packages/core/src/config/config-resolvers.ts index 4ae14122fb..ed07aeee63 100644 --- a/packages/core/src/config/config-resolvers.ts +++ b/packages/core/src/config/config-resolvers.ts @@ -410,18 +410,6 @@ export async function resolvePlugins( plugin.rules.graphql = prefixRules(pluginInstance.rules.graphql, id); } } - if (pluginInstance.diff) { - if (!pluginInstance.diff.oas3 && !pluginInstance.diff.async3) { - throw new Error(`Plugin diff rules must have \`oas3\` or \`async3\` rules "${p}.`); - } - plugin.diff = {}; - if (pluginInstance.diff.oas3) { - plugin.diff.oas3 = prefixRules(pluginInstance.diff.oas3, id); - } - if (pluginInstance.diff.async3) { - plugin.diff.async3 = prefixRules(pluginInstance.diff.async3, id); - } - } if (pluginInstance.preprocessors) { if ( !pluginInstance.preprocessors.oas3 && @@ -511,6 +499,19 @@ export async function resolvePlugins( plugin.assertions = pluginInstance.assertions; } + if (pluginInstance.diff) { + if (!pluginInstance.diff.oas3 && !pluginInstance.diff.async3) { + throw new Error(`Plugin diff rules must have \`oas3\` or \`async3\` rules "${p}.`); + } + plugin.diff = {}; + if (pluginInstance.diff.oas3) { + plugin.diff.oas3 = prefixRules(pluginInstance.diff.oas3, id); + } + if (pluginInstance.diff.async3) { + plugin.diff.async3 = prefixRules(pluginInstance.diff.async3, id); + } + } + return { ...pluginInstance, ...plugin, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4dc9999068..042513b99d 100755 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -369,10 +369,6 @@ export class Config { } } - getDiffImpact(ruleId: string, specVersion: SpecVersion): Impact | 'off' { - return this.diff[specVersion]?.[ruleId] || 'off'; - } - getRulesForSpecVersion(version: SpecMajorVersion) { switch (version) { case 'oas3': { @@ -463,6 +459,10 @@ export class Config { } } + getDiffImpact(ruleId: string, specVersion: SpecVersion): Impact | 'off' { + return this.diff[specVersion]?.[ruleId] || 'off'; + } + skipRules(rules?: string[]) { for (const ruleId of rules || []) { for (const version of specVersions) { @@ -480,16 +480,6 @@ export class Config { } } - skipDiffRules(rules?: string[]) { - for (const ruleId of rules || []) { - for (const impacts of Object.values(this.diff)) { - if (impacts[ruleId]) { - impacts[ruleId] = 'off'; - } - } - } - } - skipPreprocessors(preprocessors?: string[]) { for (const preprocessorId of preprocessors || []) { for (const version of specVersions) { @@ -509,4 +499,14 @@ export class Config { } } } + + skipDiffRules(rules?: string[]) { + for (const ruleId of rules || []) { + for (const impacts of Object.values(this.diff)) { + if (impacts[ruleId]) { + impacts[ruleId] = 'off'; + } + } + } + } } diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index b521220d90..32ef0849f1 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -99,17 +99,6 @@ export type RawGovernanceConfig = overlay1Rules?: RuleMap; openrpc1Rules?: RuleMap; graphqlRules?: RuleMap; - /** - * Semver impact per diff rule; `off` skips the rule. - * TODO: once `@redocly/config` declares the diff blocks, drop these five fields — - * `RawUniversalConfig` already spreads `RedoclyConfig`, so keeping both would make the - * upstream declaration unreachable. The `DiffRule`/`DiffRuleId` imports go with them. - */ - diff?: RuleMap; - oas3_0Diff?: RuleMap; - oas3_1Diff?: RuleMap; - oas3_2Diff?: RuleMap; - async3Diff?: RuleMap; preprocessors?: Record; oas2Preprocessors?: Record< @@ -157,6 +146,11 @@ export type RawGovernanceConfig = openrpc1Decorators?: Record; recheck?: RecheckConfig; + diff?: RuleMap; + oas3_0Diff?: RuleMap; + oas3_1Diff?: RuleMap; + oas3_2Diff?: RuleMap; + async3Diff?: RuleMap; }; export type ResolvedGovernanceConfig = Omit; @@ -204,7 +198,6 @@ export type RulesConfig = { export type CustomRulesConfig = RulesConfig; -/** Diff rules are keyed by specification family, the way the other plugin slices are. */ export type DiffRulesConfig = { oas3?: RuleMap; async3?: RuleMap; @@ -226,11 +219,11 @@ export type Plugin = { configs?: Record; rules?: RulesConfig; + assertions?: AssertionsConfig; preprocessors?: PreprocessorsConfig; decorators?: DecoratorsConfig; diff?: DiffRulesConfig; typeExtension?: TypeExtensionsConfig; - assertions?: AssertionsConfig; // Realm properties path?: string; diff --git a/packages/core/src/config/utils.ts b/packages/core/src/config/utils.ts index eb553e6f65..9bb845bddd 100644 --- a/packages/core/src/config/utils.ts +++ b/packages/core/src/config/utils.ts @@ -60,11 +60,6 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { overlay1Rules: {}, openrpc1Rules: {}, graphqlRules: {}, - diff: {}, - oas3_0Diff: {}, - oas3_1Diff: {}, - oas3_2Diff: {}, - async3Diff: {}, preprocessors: {}, oas2Preprocessors: {}, @@ -91,6 +86,11 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { openrpc1Decorators: {}, recheck, + diff: {}, + oas3_0Diff: {}, + oas3_1Diff: {}, + oas3_2Diff: {}, + async3Diff: {}, }; for (const rulesConf of rulesConfList) { @@ -124,16 +124,6 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { assignConfig(result.graphqlRules, rulesConf.graphqlRules); assignOnlyExistingConfig(result.graphqlRules, rulesConf.rules); - assignConfig(result.diff, rulesConf.diff); - assignConfig(result.oas3_0Diff, rulesConf.oas3_0Diff); - assignOnlyExistingConfig(result.oas3_0Diff, rulesConf.diff); - assignConfig(result.oas3_1Diff, rulesConf.oas3_1Diff); - assignOnlyExistingConfig(result.oas3_1Diff, rulesConf.diff); - assignConfig(result.oas3_2Diff, rulesConf.oas3_2Diff); - assignOnlyExistingConfig(result.oas3_2Diff, rulesConf.diff); - assignConfig(result.async3Diff, rulesConf.async3Diff); - assignOnlyExistingConfig(result.async3Diff, rulesConf.diff); - assignConfig(result.preprocessors, rulesConf.preprocessors); assignConfig(result.oas2Preprocessors, rulesConf.oas2Preprocessors); assignOnlyExistingConfig(result.oas2Preprocessors, rulesConf.preprocessors); @@ -179,6 +169,15 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { assignOnlyExistingConfig(result.openrpc1Decorators, rulesConf.decorators); recheck = mergeRecheckBlocks(recheck, rulesConf.recheck); + assignConfig(result.diff, rulesConf.diff); + assignConfig(result.oas3_0Diff, rulesConf.oas3_0Diff); + assignOnlyExistingConfig(result.oas3_0Diff, rulesConf.diff); + assignConfig(result.oas3_1Diff, rulesConf.oas3_1Diff); + assignOnlyExistingConfig(result.oas3_1Diff, rulesConf.diff); + assignConfig(result.oas3_2Diff, rulesConf.oas3_2Diff); + assignOnlyExistingConfig(result.oas3_2Diff, rulesConf.diff); + assignConfig(result.async3Diff, rulesConf.async3Diff); + assignOnlyExistingConfig(result.async3Diff, rulesConf.diff); } result.recheck = recheck; diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index 1192ce1fd2..f9c2b5a07f 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -298,13 +298,6 @@ const configGovernanceProperties: Record< overlay1Rules: 'Rules', openrpc1Rules: 'Rules', graphqlRules: 'Rules', - // TODO: move the diff blocks into the Redocly config schema (@redocly/config); they are - // declared here because that schema does not know about them yet. - diff: 'DiffRules', - oas3_0Diff: 'DiffRules', - oas3_1Diff: 'DiffRules', - oas3_2Diff: 'DiffRules', - async3Diff: 'DiffRules', preprocessors: 'Preprocessors', oas2Preprocessors: 'Preprocessors', oas3_0Preprocessors: 'Preprocessors', @@ -327,6 +320,12 @@ const configGovernanceProperties: Record< arazzo1_1Decorators: 'Decorators', overlay1Decorators: 'Decorators', openrpc1Decorators: 'Decorators', + + diff: 'DiffRules', + oas3_0Diff: 'DiffRules', + oas3_1Diff: 'DiffRules', + oas3_2Diff: 'DiffRules', + async3Diff: 'DiffRules', }; const ConfigGovernance: NodeType = { @@ -364,8 +363,6 @@ const createConfigApisProperties = (nodeTypes: Record): NodeTy properties: { ...nodeTypes['rootRedoclyConfigSchema.apis_additionalProperties']?.properties, // plugins are not allowed in apis, and diff compares two apis, so it is configured once. - // TODO: when the diff blocks move to @redocly/config, this exclusion has to move with - // them — otherwise the upstream schema will allow them per api again. ...omit(ConfigGovernance.properties, [ 'plugins', 'diff', @@ -473,8 +470,6 @@ const Rules: NodeType = { }, }; -// TODO: this node hand-writes what the Redocly config schema will generate for the diff -// blocks; delete it, its `configTypes` entry and the `diffRuleIds` import once it does. const DiffRules: NodeType = { properties: {}, description: @@ -863,10 +858,10 @@ const CoreConfigTypes: Record = { ExternalDocs: Oas3_1Types.ExternalDocs, Xml: Oas3_1Types.Xml, Rules, - DiffRules, Decorators, Preprocessors, Assertions, + DiffRules, }; // FIXME: remove this once we remove `theme` from the schema From 54bf9436a9c2cd110c91dd73dbbb6b45854db8be Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:40:47 +0300 Subject: [PATCH 42/57] refactor(diff): compare documents by node pairs and keep the engine specification-agnostic Match the two node maps up into a tree of pairs instead of computing a string key per node and joining the two key sets. A pair is the identity, so the report key becomes a label, the collision suffix stops being part of matching, and a change is addressed by its pair rather than by a key and a node. Everything a specification knows now sits behind `DiffSpec` in `diff/specs/`, so the comparison itself names no node type. Rules gain lint's nested visitor keys: `SchemaProperties: { Schema }` replaces a parent check in the rule body. The usage index becomes one fixed-point pass over the reference graph instead of a class that recursed per change. The report formats move to the CLI, which is their only consumer, and the semver helper gives way to the `semver` package already in its dependencies. --- .../cli/src/commands/diff/check-version.ts | 56 +++-- .../__tests__/__snapshots__/html-report.html | 0 .../diff/format/__tests__/format.test.ts | 22 +- .../commands/diff/format/__tests__/pair.ts | 8 + .../diff/format/__tests__/problems.test.ts | 12 +- .../src/commands}/diff/format/html.ts | 11 +- .../src/commands}/diff/format/index.ts | 3 +- .../src/commands}/diff/format/json.ts | 18 +- .../src/commands}/diff/format/markdown.ts | 10 +- .../src/commands}/diff/format/problems.ts | 9 +- .../src/commands}/diff/format/stylish.ts | 55 ++--- packages/cli/src/commands/diff/index.ts | 7 +- packages/cli/src/commands/diff/types.ts | 3 +- packages/cli/src/index.ts | 1 + .../core/src/diff/__tests__/changes.test.ts | 109 ++++++++++ .../core/src/diff/__tests__/compare.test.ts | 88 -------- .../core/src/diff/__tests__/detect.test.ts | 195 +++++++++++++----- .../src/diff/__tests__/diff-documents.test.ts | 43 ++++ .../core/src/diff/__tests__/direction.test.ts | 166 +++++++-------- .../core/src/diff/__tests__/identity.test.ts | 90 -------- .../core/src/diff/__tests__/impact.test.ts | 2 +- .../core/src/diff/__tests__/pairs.test.ts | 134 ++++++++++++ .../core/src/diff/__tests__/rules.test.ts | 15 +- packages/core/src/diff/__tests__/tree.ts | 67 ++++-- packages/core/src/diff/changes.ts | 70 +++++++ packages/core/src/diff/compare.ts | 93 --------- packages/core/src/diff/detect.ts | 146 +++++++------ packages/core/src/diff/direction.ts | 110 ---------- packages/core/src/diff/identity.ts | 62 ------ packages/core/src/diff/impact.ts | 16 ++ packages/core/src/diff/index.ts | 67 +++--- packages/core/src/diff/pairs.ts | 84 ++++++++ .../{ => rules}/__tests__/constraints.test.ts | 2 +- packages/core/src/diff/rules/async3.ts | 22 -- .../core/src/diff/{ => rules}/constraints.ts | 0 packages/core/src/diff/rules/index.ts | 90 +++++++- packages/core/src/diff/rules/oas3.ts | 38 ---- packages/core/src/diff/rules/parameter.ts | 2 +- packages/core/src/diff/rules/request-body.ts | 2 +- packages/core/src/diff/rules/schema.ts | 71 +++---- packages/core/src/diff/rules/security.ts | 41 ++-- packages/core/src/diff/specs/async3.ts | 40 ++++ packages/core/src/diff/specs/direction.ts | 14 ++ packages/core/src/diff/specs/index.ts | 16 ++ packages/core/src/diff/specs/oas3.ts | 109 ++++++++++ packages/core/src/diff/types.ts | 70 ++++--- packages/core/src/diff/usage.ts | 74 ++++--- packages/core/src/index.ts | 21 +- .../src/node-map/__tests__/collect.test.ts | 144 ++++++------- packages/core/src/node-map/access.ts | 14 ++ packages/core/src/node-map/chain.ts | 32 --- packages/core/src/node-map/collect.ts | 125 ++++------- packages/core/src/node-map/types.ts | 50 +---- .../core/src/utils/__tests__/semver.test.ts | 31 --- packages/core/src/utils/path-template.ts | 12 ++ packages/core/src/utils/semver.ts | 35 ---- .../snapshot.txt | 2 +- .../diff/async3-channel-removed/snapshot.txt | 4 +- .../snapshot.txt | 2 +- .../diff/async3-message-removed/snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../diff/async3-server-removed/snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../diff/oas3-enum-values-added/snapshot.txt | 2 +- .../diff/oas3-media-type-removed/snapshot.txt | 2 +- .../oas3-nullability-changed/snapshot.txt | 2 +- .../snapshot.txt | 4 +- .../oas3-numeric-range-changed/snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../diff/oas3-ref-target-changed/snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../oas3-schema-format-changed/snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../oas3-security-scheme-changed/snapshot.txt | 8 +- .../oas3-security-scheme-removed/snapshot.txt | 2 +- .../oas3-string-length-changed/snapshot.txt | 2 +- .../snapshot.txt | 2 +- 83 files changed, 1555 insertions(+), 1336 deletions(-) rename packages/{core/src => cli/src/commands}/diff/format/__tests__/__snapshots__/html-report.html (100%) rename packages/{core/src => cli/src/commands}/diff/format/__tests__/format.test.ts (91%) create mode 100644 packages/cli/src/commands/diff/format/__tests__/pair.ts rename packages/{core/src => cli/src/commands}/diff/format/__tests__/problems.test.ts (91%) rename packages/{core/src => cli/src/commands}/diff/format/html.ts (93%) rename packages/{core/src => cli/src/commands}/diff/format/index.ts (87%) rename packages/{core/src => cli/src/commands}/diff/format/json.ts (87%) rename packages/{core/src => cli/src/commands}/diff/format/markdown.ts (88%) rename packages/{core/src => cli/src/commands}/diff/format/problems.ts (90%) rename packages/{core/src => cli/src/commands}/diff/format/stylish.ts (64%) create mode 100644 packages/core/src/diff/__tests__/changes.test.ts delete mode 100644 packages/core/src/diff/__tests__/compare.test.ts delete mode 100644 packages/core/src/diff/__tests__/identity.test.ts create mode 100644 packages/core/src/diff/__tests__/pairs.test.ts create mode 100644 packages/core/src/diff/changes.ts delete mode 100644 packages/core/src/diff/compare.ts delete mode 100644 packages/core/src/diff/direction.ts delete mode 100644 packages/core/src/diff/identity.ts create mode 100644 packages/core/src/diff/impact.ts create mode 100644 packages/core/src/diff/pairs.ts rename packages/core/src/diff/{ => rules}/__tests__/constraints.test.ts (98%) delete mode 100644 packages/core/src/diff/rules/async3.ts rename packages/core/src/diff/{ => rules}/constraints.ts (100%) delete mode 100644 packages/core/src/diff/rules/oas3.ts create mode 100644 packages/core/src/diff/specs/async3.ts create mode 100644 packages/core/src/diff/specs/direction.ts create mode 100644 packages/core/src/diff/specs/index.ts create mode 100644 packages/core/src/diff/specs/oas3.ts create mode 100644 packages/core/src/node-map/access.ts delete mode 100644 packages/core/src/node-map/chain.ts delete mode 100644 packages/core/src/utils/__tests__/semver.test.ts create mode 100644 packages/core/src/utils/path-template.ts delete mode 100644 packages/core/src/utils/semver.ts diff --git a/packages/cli/src/commands/diff/check-version.ts b/packages/cli/src/commands/diff/check-version.ts index d969a50e59..c6ba8364d4 100644 --- a/packages/cli/src/commands/diff/check-version.ts +++ b/packages/cli/src/commands/diff/check-version.ts @@ -1,19 +1,41 @@ -import { - bumpSemver, - formatSemver, - impactRank, - isPlainObject, - parseSemver, - semverBumpBetween, - type Document, - type Impact, -} from '@redocly/openapi-core'; +import { impactRank, isPlainObject, type Document, type Impact } from '@redocly/openapi-core'; +import semver from 'semver'; export type VersionCheck = | { status: 'passed' } | { status: 'skipped'; message: string } | { status: 'failed'; message: string }; +interface Version { + major: number; + minor: number; + patch: number; +} + +// Pre-release and build metadata are accepted and ignored: only the three numbers are compared. +function parseVersion(version: unknown): Version | undefined { + const parsed = typeof version === 'string' ? semver.parse(version) : null; + return parsed ? { major: parsed.major, minor: parsed.minor, patch: parsed.patch } : undefined; +} + +function format({ major, minor, patch }: Version): string { + return `${major}.${minor}.${patch}`; +} + +/** The first part that grew from `from` to `to`; `undefined` when nothing grew. */ +function bumpBetween(from: Version, to: Version): Impact | undefined { + if (to.major > from.major) return 'major'; + if (to.major === from.major && to.minor > from.minor) return 'minor'; + if (to.major === from.major && to.minor === from.minor && to.patch > from.patch) return 'patch'; + return undefined; +} + +function bump(version: Version, part: Impact): Version { + if (part === 'major') return { major: version.major + 1, minor: 0, patch: 0 }; + if (part === 'minor') return { ...version, minor: version.minor + 1, patch: 0 }; + return { ...version, patch: version.patch + 1 }; +} + export function getDeclaredVersion(document: Document): unknown { return isPlainObject(document.parsed) && isPlainObject(document.parsed.info) ? document.parsed.info.version @@ -30,8 +52,8 @@ export function checkVersion(opts: { const { required } = opts; if (required === undefined) return { status: 'passed' }; - const base = parseSemver(opts.base); - const revision = parseSemver(opts.revision); + const base = parseVersion(opts.base); + const revision = parseVersion(opts.revision); if (!base || !revision) { return { status: 'skipped', @@ -41,15 +63,15 @@ export function checkVersion(opts: { const isRelaxed = base.major === 0 && required === 'major'; const effectiveRequired: Impact = isRelaxed ? 'minor' : required; - const declared = semverBumpBetween(base, revision); + const declared = bumpBetween(base, revision); if (declared && impactRank(declared) >= impactRank(effectiveRequired)) return { status: 'passed' }; const moved = - formatSemver(base) === formatSemver(revision) - ? `stayed ${formatSemver(base)}` - : `went ${formatSemver(base)} → ${formatSemver(revision)}`; - const next = formatSemver(bumpSemver(base, effectiveRequired)); + format(base) === format(revision) + ? `stayed ${format(base)}` + : `went ${format(base)} → ${format(revision)}`; + const next = format(bump(base, effectiveRequired)); return { status: 'failed', message: `✖ info.version ${moved}, but these changes require a ${effectiveRequired} bump (${next})${ diff --git a/packages/core/src/diff/format/__tests__/__snapshots__/html-report.html b/packages/cli/src/commands/diff/format/__tests__/__snapshots__/html-report.html similarity index 100% rename from packages/core/src/diff/format/__tests__/__snapshots__/html-report.html rename to packages/cli/src/commands/diff/format/__tests__/__snapshots__/html-report.html diff --git a/packages/core/src/diff/format/__tests__/format.test.ts b/packages/cli/src/commands/diff/format/__tests__/format.test.ts similarity index 91% rename from packages/core/src/diff/format/__tests__/format.test.ts rename to packages/cli/src/commands/diff/format/__tests__/format.test.ts index 4584dfd04d..3857a370f1 100644 --- a/packages/core/src/diff/format/__tests__/format.test.ts +++ b/packages/cli/src/commands/diff/format/__tests__/format.test.ts @@ -1,13 +1,12 @@ +import { Location, Source, type DiffResult } from '@redocly/openapi-core'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { outdent } from 'outdent'; -import { Location } from '../../../ref-utils.js'; -import { Source } from '../../../resolve.js'; -import type { DiffResult } from '../../types.js'; import { htmlDiff } from '../html.js'; import { markdownDiff } from '../markdown.js'; import { stylishDiff } from '../stylish.js'; +import { lonePair } from './pair.js'; // format.test.ts — vitest runs with FORCE_COLOR=1, so the codes are stripped before snapshotting function stripColors(output: string): string { @@ -81,7 +80,7 @@ const RESULT: DiffResult = { { key: '#/paths/~1pets/delete', kind: 'removed', - typeName: 'Operation', + pair: lonePair('Operation', at(base, '#/paths/~1pets/delete')), base: { location: at(base, '#/paths/~1pets/delete'), value: { summary: '' }, @@ -101,7 +100,7 @@ const RESULT: DiffResult = { key: '#/paths/~1pets/get/parameters/{query:limit}', property: 'required', kind: 'modified', - typeName: 'Parameter', + pair: lonePair('Parameter', at(base, '#/paths/~1pets/get/parameters/{query:limit}')), base: { location: at(base, '#/paths/~1pets/get/parameters/0/required'), value: undefined, @@ -125,7 +124,10 @@ const RESULT: DiffResult = { key: '#/paths/~1pets/post/requestBody/content/application~1json/schema', property: 'pattern', kind: 'modified', - typeName: 'Schema', + pair: lonePair( + 'Schema', + at(base, '#/paths/~1pets/post/requestBody/content/application~1json/schema') + ), base: { location: at( base, @@ -160,7 +162,7 @@ const RESULT: DiffResult = { key: '#/paths/~1pets/get', property: 'summary', kind: 'modified', - typeName: 'Operation', + pair: lonePair('Operation', at(base, '#/paths/~1pets/get')), base: { location: at(base, '#/paths/~1pets/get/summary'), value: 'List pets', @@ -176,7 +178,7 @@ const RESULT: DiffResult = { { key: '#/components/schemas/Pet', kind: 'added', - typeName: 'Schema', + pair: lonePair('Schema', at(base, '#/components/schemas/Pet')), revision: { location: at(revision, '#/components/schemas/Pet'), value: { type: 'object' }, @@ -192,7 +194,7 @@ describe('stylishDiff', () => { it('groups changes per operation, worst first, each with its verdicts and location', () => { expect(stripColors(stylishDiff(RESULT))).toMatchInlineSnapshot(` "components - ✔ minor added components/schemas/Pet + ✔ minor added schemas/Pet at revision.yaml:20:7 DELETE /pets @@ -208,7 +210,7 @@ describe('stylishDiff', () => { at revision.yaml:5:16 POST /pets - ✖ major modified requestBody/content/application~1json/schema · pattern + ✖ major modified requestBody/content/application/json/schema · pattern \`pattern\` changed from 'a' to 'a|b'. (string-length-changed) at revision.yaml:16:24 diff --git a/packages/cli/src/commands/diff/format/__tests__/pair.ts b/packages/cli/src/commands/diff/format/__tests__/pair.ts new file mode 100644 index 0000000000..1c97b57259 --- /dev/null +++ b/packages/cli/src/commands/diff/format/__tests__/pair.ts @@ -0,0 +1,8 @@ +import type { Location, Pair } from '@redocly/openapi-core'; + +/** A pair for a node that stands alone, for tests that only need its type and location. */ +export function lonePair(type: string, location: Location): Pair { + const key = location.pointer.slice(location.pointer.lastIndexOf('/') + 1); + const node = { type, key, location, value: {}, parent: null, children: [] }; + return { base: node, revision: node, parent: null, children: [], occurrence: 1 }; +} diff --git a/packages/core/src/diff/format/__tests__/problems.test.ts b/packages/cli/src/commands/diff/format/__tests__/problems.test.ts similarity index 91% rename from packages/core/src/diff/format/__tests__/problems.test.ts rename to packages/cli/src/commands/diff/format/__tests__/problems.test.ts index 2e133c5e43..2705c2c4d8 100644 --- a/packages/core/src/diff/format/__tests__/problems.test.ts +++ b/packages/cli/src/commands/diff/format/__tests__/problems.test.ts @@ -1,7 +1,7 @@ -import { Location } from '../../../ref-utils.js'; -import { Source } from '../../../resolve.js'; -import type { DiffResult } from '../../types.js'; +import { Location, Source, type DiffResult } from '@redocly/openapi-core'; + import { diffToProblems } from '../problems.js'; +import { lonePair } from './pair.js'; const baseSource = new Source('base.yaml', 'openapi: 3.1.0\n'); const revisionSource = new Source('revision.yaml', 'openapi: 3.1.0\n'); @@ -17,7 +17,7 @@ const result: DiffResult = { key: '#/paths/~1pets/get/parameters/{query:limit}', property: 'required', kind: 'modified', - typeName: 'Parameter', + pair: lonePair('Parameter', at(baseSource, '#/paths/~1pets/get/parameters/{query:limit}')), base: { location: at(baseSource, '#/paths/~1pets/get/parameters/0/required'), value: undefined, @@ -46,7 +46,7 @@ const result: DiffResult = { { key: '#/components/schemas/Pet', kind: 'added', - typeName: 'Schema', + pair: lonePair('Schema', at(baseSource, '#/components/schemas/Pet')), revision: { location: at(revisionSource, '#/components/schemas/Pet'), value: { type: 'object' }, @@ -59,7 +59,7 @@ const result: DiffResult = { key: '#/info', property: 'version', kind: 'modified', - typeName: 'Info', + pair: lonePair('Info', at(baseSource, '#/info')), base: { location: at(baseSource, '#/info/version'), value: '1.0.0' }, revision: { location: at(revisionSource, '#/info/version'), value: '1.0.1' }, impact: 'patch', diff --git a/packages/core/src/diff/format/html.ts b/packages/cli/src/commands/diff/format/html.ts similarity index 93% rename from packages/core/src/diff/format/html.ts rename to packages/cli/src/commands/diff/format/html.ts index 2fdd6d0041..e774221075 100644 --- a/packages/core/src/diff/format/html.ts +++ b/packages/cli/src/commands/diff/format/html.ts @@ -1,12 +1,13 @@ -import type { DiffResult, JudgedChange } from '../types.js'; +import type { DiffResult, JudgedChange } from '@redocly/openapi-core'; + import { toJsonChange } from './json.js'; function escapeHtml(value: unknown): string { return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); } function renderChange(change: JudgedChange): string { diff --git a/packages/core/src/diff/format/index.ts b/packages/cli/src/commands/diff/format/index.ts similarity index 87% rename from packages/core/src/diff/format/index.ts rename to packages/cli/src/commands/diff/format/index.ts index 104df5cd1f..c88ed2e58f 100644 --- a/packages/core/src/diff/format/index.ts +++ b/packages/cli/src/commands/diff/format/index.ts @@ -1,4 +1,5 @@ -import type { DiffResult } from '../types.js'; +import type { DiffResult } from '@redocly/openapi-core'; + import { htmlDiff } from './html.js'; import { jsonDiff } from './json.js'; import { markdownDiff } from './markdown.js'; diff --git a/packages/core/src/diff/format/json.ts b/packages/cli/src/commands/diff/format/json.ts similarity index 87% rename from packages/core/src/diff/format/json.ts rename to packages/cli/src/commands/diff/format/json.ts index 695c36e736..24882107ef 100644 --- a/packages/core/src/diff/format/json.ts +++ b/packages/cli/src/commands/diff/format/json.ts @@ -1,6 +1,14 @@ -import { getLineColLocation } from '../../format/codeframes.js'; -import type { Location } from '../../ref-utils.js'; -import type { Change, DiffResult, Direction, Impact, JudgedChange, LocatedNode } from '../types.js'; +import { + getLineColLocation, + typeOf, + type Change, + type DiffResult, + type Direction, + type Impact, + type JudgedChange, + type Location, + type LocatedNode, +} from '@redocly/openapi-core'; export interface JsonLocatedNode { file: string; @@ -51,10 +59,10 @@ function toJsonNode({ location, value }: LocatedNode): JsonLocatedNode { } export function toJsonChange(change: JudgedChange): JsonChange { - const { key, typeName, kind, impact, direction, verdicts } = change; + const { key, pair, kind, impact, direction, verdicts } = change; const common = { key, - typeName, + typeName: typeOf(pair), kind, impact, direction, diff --git a/packages/core/src/diff/format/markdown.ts b/packages/cli/src/commands/diff/format/markdown.ts similarity index 88% rename from packages/core/src/diff/format/markdown.ts rename to packages/cli/src/commands/diff/format/markdown.ts index cc5de9eb0d..752d71caba 100644 --- a/packages/core/src/diff/format/markdown.ts +++ b/packages/cli/src/commands/diff/format/markdown.ts @@ -1,4 +1,4 @@ -import type { DiffResult, Impact } from '../types.js'; +import type { DiffResult, Impact } from '@redocly/openapi-core'; const IMPACT_LABELS: Record = { major: '🔴 major', @@ -11,9 +11,11 @@ const IMPACT_LABELS: Record = { // would break out of the row itself. function escapeCell(value: string): string { return value - .replace(/\|/g, '\\|') - .replace(/`/g, '\\`') - .replace(/[\r\n]+/g, ' '); + .replaceAll('|', '\\|') + .replaceAll('`', '\\`') + .split('\n') + .map((line) => line.trim()) + .join(' '); } export function markdownDiff(result: DiffResult): string { diff --git a/packages/core/src/diff/format/problems.ts b/packages/cli/src/commands/diff/format/problems.ts similarity index 90% rename from packages/core/src/diff/format/problems.ts rename to packages/cli/src/commands/diff/format/problems.ts index ecdcb558c3..e604e462dd 100644 --- a/packages/core/src/diff/format/problems.ts +++ b/packages/cli/src/commands/diff/format/problems.ts @@ -1,5 +1,10 @@ -import type { NormalizedProblem, ProblemSeverity } from '../../walk.js'; -import { displaySide, type DiffResult, type Impact } from '../types.js'; +import { + displaySide, + type DiffResult, + type Impact, + type NormalizedProblem, + type ProblemSeverity, +} from '@redocly/openapi-core'; // A lint problem carries one of two severities, so a patch has nowhere to go and is left // out; this is what lets the github-actions format come from core's formatProblems. diff --git a/packages/core/src/diff/format/stylish.ts b/packages/cli/src/commands/diff/format/stylish.ts similarity index 64% rename from packages/core/src/diff/format/stylish.ts rename to packages/cli/src/commands/diff/format/stylish.ts index f2fff6d2ac..7f945a715d 100644 --- a/packages/core/src/diff/format/stylish.ts +++ b/packages/cli/src/commands/diff/format/stylish.ts @@ -1,24 +1,30 @@ -import * as path from 'node:path'; - -import { isBrowser } from '../../env.js'; -import { getLineColLocation } from '../../format/codeframes.js'; -import { colorize } from '../../logger.js'; -import { isAbsoluteUrl, unescapePointerFragment } from '../../ref-utils.js'; import { displaySide, + getLineColLocation, impactRank, + isAbsoluteUrl, + isBrowser, + parsePointer, type Change, type DiffResult, type Impact, type JudgedChange, -} from '../types.js'; +} from '@redocly/openapi-core'; +import { blue, bold, gray, green, red } from 'colorette'; +import * as path from 'node:path'; const IMPACT_GLYPHS: Record = { - major: colorize.red('✖ major'), - minor: colorize.green('✔ minor'), - patch: colorize.gray('· patch'), + major: red('✖ major'), + minor: green('✔ minor'), + patch: gray('· patch'), }; +// `parsePointer` unescapes every segment and keeps the leading `#`, which names the document. +function segmentsOf(pointer: string): string[] { + const [, ...segments] = parsePointer(pointer); + return segments; +} + const HTTP_METHODS = new Set([ 'get', 'put', @@ -31,36 +37,33 @@ const HTTP_METHODS = new Set([ 'query', ]); -function segmentsOf(pointer: string): string[] { - return pointer.replace(/^#\//, '').split('/').filter(Boolean); -} - function groupOf(change: Change): string { const segments = segmentsOf(displaySide(change).location.pointer); if (segments[0] === 'paths' && segments.length > 1) { - const pathKey = unescapePointerFragment(segments[1]); + const pathKey = segments[1]; const method = segments[2]; return method && HTTP_METHODS.has(method) ? `${method.toUpperCase()} ${pathKey}` : pathKey; } return segments[0] || 'document'; } +// The group heading already names what the label would repeat: the endpoint under +// `paths`, the section anywhere else. function labelSegments(segments: string[]): string[] { - if (segments[0] !== 'paths') return segments; + if (segments[0] !== 'paths') return segments.slice(1); const underOperation = segments.length > 2 && HTTP_METHODS.has(segments[2]); return segments.slice(underOperation ? 3 : 2); } -// The group heading already names the endpoint, so the label starts after -// `paths//`. A change on the endpoint itself is labelled by its property; -// the endpoint node itself by its real path, each segment unescaped. +// A change on the group node itself is labelled by its property; the node itself by its +// real path, each segment unescaped so a `/` inside a name reads as one. function labelOf(change: JudgedChange): string { const named = labelSegments(segmentsOf(change.key)); if (named.length) { return change.kind === 'modified' ? `${named.join('/')} · ${change.property}` : named.join('/'); } if (change.kind === 'modified') return change.property; - return segmentsOf(displaySide(change).location.pointer).map(unescapePointerFragment).join(' · '); + return segmentsOf(displaySide(change).location.pointer).join(' · '); } function locationOf(change: Change, cwd: string): string { @@ -84,26 +87,24 @@ export function stylishDiff(result: DiffResult): string { const lines: string[] = []; for (const [key, changes] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) { - lines.push(colorize.bold(colorize.blue(key))); + lines.push(bold(blue(key))); const sorted = [...changes].sort( (left, right) => impactRank(right.impact) - impactRank(left.impact) || left.key.localeCompare(right.key) ); for (const change of sorted) { lines.push( - ` ${IMPACT_GLYPHS[change.impact]} ${colorize.bold(change.kind.padEnd(8))} ${labelOf(change)}` + ` ${IMPACT_GLYPHS[change.impact]} ${bold(change.kind.padEnd(8))} ${labelOf(change)}` ); for (const verdict of change.verdicts) { - lines.push(colorize.gray(` ${verdict.message} (${verdict.ruleId})`)); + lines.push(gray(` ${verdict.message} (${verdict.ruleId})`)); } - lines.push(colorize.gray(` at ${locationOf(change, cwd)}`)); + lines.push(gray(` at ${locationOf(change, cwd)}`)); } lines.push(''); } const { major, minor, patch } = result.summary; - lines.push( - `${colorize.red(`${major} major`)}, ${colorize.green(`${minor} minor`)}, ${colorize.gray(`${patch} patch`)}.` - ); + lines.push(`${red(`${major} major`)}, ${green(`${minor} minor`)}, ${gray(`${patch} patch`)}.`); return lines.join('\n'); } diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts index b554c27bd2..9f3ded211e 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -2,8 +2,6 @@ import { bundle, DiffError, diffDocuments, - diffReportFormats, - diffToProblems, formatProblems, getTotals, logger, @@ -16,6 +14,8 @@ import { getFallbackApisOrExit, printExecutionTime } from '../../utils/miscellan import type { CommandArgs } from '../../wrapper.js'; import { checkVersion, getDeclaredVersion } from './check-version.js'; import { getDiffFailure } from './fail-on.js'; +import { diffReportFormats } from './format/index.js'; +import { diffToProblems } from './format/problems.js'; import type { DiffArgv } from './types.js'; export async function handleDiff({ argv, config, collectSpecData }: CommandArgs) { @@ -26,12 +26,15 @@ 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); + config.skipDiffRules(argv['skip-rule']); const { bundle: baseDocument } = await bundle({ config, ref: basePath }); const { bundle: revisionDocument } = await bundle({ config, ref: revisionPath }); + collectSpecData?.(revisionDocument); let result: DiffResult; diff --git a/packages/cli/src/commands/diff/types.ts b/packages/cli/src/commands/diff/types.ts index 4bfe838604..11dfe31fca 100644 --- a/packages/cli/src/commands/diff/types.ts +++ b/packages/cli/src/commands/diff/types.ts @@ -1,6 +1,7 @@ -import type { DiffReportFormat, Impact } from '@redocly/openapi-core'; +import type { Impact } from '@redocly/openapi-core'; import type { VerifyConfigOptions } from '../../types.js'; +import type { DiffReportFormat } from './format/index.js'; export type DiffOutputFormat = DiffReportFormat | 'github-actions'; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 493d93ca49..c10ffa9563 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -158,6 +158,7 @@ yargs(hideBin(process.argv)) 'markdown', 'html', 'github-actions', + // TODO: 'next-version' ] as ReadonlyArray, default: 'stylish' as const, }, diff --git a/packages/core/src/diff/__tests__/changes.test.ts b/packages/core/src/diff/__tests__/changes.test.ts new file mode 100644 index 0000000000..99745d5f34 --- /dev/null +++ b/packages/core/src/diff/__tests__/changes.test.ts @@ -0,0 +1,109 @@ +import type { NodeEntry, NodeValue } from '../../node-map/types.js'; +import { Location } from '../../ref-utils.js'; +import { Source } from '../../resolve.js'; +import { changesIn } from '../changes.js'; +import { pairDocuments } from '../pairs.js'; +import type { DiffSpec } from '../types.js'; + +const source = new Source('api.yaml', ''); + +/** A root with one level of children, each given as `key: { type, value }`. */ +function documentOf(children: Record) { + const root: NodeEntry = { + type: 'Root', + key: '', + location: new Location(source, '#/'), + value: {}, + parent: null, + children: [], + }; + const nodes = new Map([['#/', root]]); + for (const [key, { type = 'Schema', value = {} }] of Object.entries(children)) { + const node: NodeEntry = { + type, + key, + location: root.location.child([key]), + value, + parent: root, + children: [], + }; + root.children.push(node); + nodes.set(node.location.pointer, node); + } + return nodes; +} + +/** No identity of its own and no direction: the structure alone decides. */ +const structural: DiffSpec = { identityOf: () => undefined, directionOf: () => 'neutral' }; + +const changesBetween = ( + base: ReturnType, + revision: ReturnType +) => changesIn(pairDocuments(base.get('#/')!, revision.get('#/')!, structural).root, structural); + +/** `kind key · property base-pointer → revision-pointer` per change. */ +function summarize(changes: ReturnType): string[] { + return changes.map((change) => { + const property = change.kind === 'modified' ? ` · ${change.property}` : ''; + const base = change.kind === 'added' ? '-' : change.base.location.pointer; + const revision = change.kind === 'removed' ? '-' : change.revision.location.pointer; + return `${change.kind} ${change.key}${property} ${base} → ${revision}`; + }); +} + +describe('changesIn', () => { + it('emits one modified change per differing property, located at the escaped property pointer', () => { + const base = documentOf({ a: { value: { type: 'integer', 'x/y': 1 } } }); + const revision = documentOf({ a: { value: { type: 'number', 'x/y': 2 } } }); + + expect(summarize(changesBetween(base, revision))).toEqual([ + 'modified #/a · type #/a/type → #/a/type', + 'modified #/a · x/y #/a/x~1y → #/a/x~1y', + ]); + }); + + it('reports a removed node once, with the whole node as the value, and nothing below it', () => { + const base = documentOf({ a: { value: { b: { c: 1 } } } }); + const nested: NodeEntry = { + type: 'Schema', + key: 'b', + location: new Location(source, '#/a/b'), + value: { c: 1 }, + parent: base.get('#/a')!, + children: [], + }; + base.get('#/a')!.children.push(nested); + base.set('#/a/b', nested); + + const changes = changesBetween(base, documentOf({})); + + expect(summarize(changes)).toEqual(['removed #/a #/a → -']); + expect(changes[0].kind === 'removed' && changes[0].base.value).toEqual({ b: { c: 1 } }); + }); + + it('treats a node whose type changed as a removed+added pair at the same key', () => { + const base = documentOf({ a: { type: 'Schema' } }); + const revision = documentOf({ a: { type: 'Parameter' } }); + + expect(summarize(changesBetween(base, revision))).toEqual([ + 'removed #/a #/a → -', + 'added #/a - → #/a', + ]); + }); + + it('compares a $ref the way it compares a scalar', () => { + const base = documentOf({ a: { value: { schema: { $ref: '#/A' } } } }); + const revision = documentOf({ a: { value: { schema: { $ref: '#/B' } } } }); + + const [change] = changesBetween(base, revision); + + expect(change.kind === 'modified' && change.property).toBe('schema'); + expect(change.kind === 'modified' && change.revision.value).toEqual({ $ref: '#/B' }); + }); + + it('emits nothing when the two documents are identical', () => { + const shape = { a: { value: { enum: ['x', 'y'] } } }; + + expect(changesBetween(documentOf(shape), documentOf(shape))).toEqual([]); + }); +}); diff --git a/packages/core/src/diff/__tests__/compare.test.ts b/packages/core/src/diff/__tests__/compare.test.ts deleted file mode 100644 index 6c3301cab2..0000000000 --- a/packages/core/src/diff/__tests__/compare.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { NodeEntry } from '../../node-map/types.js'; -import { Location } from '../../ref-utils.js'; -import { Source } from '../../resolve.js'; -import { compareMaps } from '../compare.js'; - -const source = new Source('api.yaml', ''); - -function entry(partial: Partial & { key: string }): NodeEntry { - return { - parentKey: null, - location: new Location(source, partial.key), - typeName: 'Schema', - properties: partial.properties ?? {}, - raw: partial.raw ?? partial.properties ?? {}, - ...partial, - }; -} - -function toMap(entries: NodeEntry[]): Map { - return new Map(entries.map((item) => [item.key, item])); -} - -/** `kind key · property base-pointer → revision-pointer` per change. */ -function summarize(changes: ReturnType): string[] { - return changes.map((change) => { - const property = change.kind === 'modified' ? ` · ${change.property}` : ''; - const base = change.kind === 'added' ? '-' : change.base.location.pointer; - const revision = change.kind === 'removed' ? '-' : change.revision.location.pointer; - return `${change.kind} ${change.key}${property} ${base} → ${revision}`; - }); -} - -describe('compareMaps', () => { - it('emits one modified change per differing property, located at the escaped property pointer', () => { - const base = toMap([entry({ key: '#/a', properties: { type: 'integer', 'x/y': 1 } })]); - const revision = toMap([entry({ key: '#/a', properties: { type: 'number', 'x/y': 2 } })]); - - expect(summarize(compareMaps(base, revision))).toEqual([ - 'modified #/a · type #/a/type → #/a/type', - 'modified #/a · x/y #/a/x~1y → #/a/x~1y', - ]); - }); - - it('collapses a removed subtree into one change at its root, carrying the raw node', () => { - const base = toMap([ - entry({ key: '#/a', raw: { b: { c: 1 } } }), - entry({ key: '#/a/b', parentKey: '#/a', raw: { c: 1 } }), - ]); - - const [change] = compareMaps(base, new Map()); - - expect(summarize([change])).toEqual(['removed #/a #/a → -']); - expect(change.kind === 'removed' && change.base.value).toEqual({ b: { c: 1 } }); - expect(compareMaps(base, new Map())).toHaveLength(1); - }); - - it('treats a node whose type changed as a removed+added pair and skips its subtree', () => { - const base = toMap([ - entry({ key: '#/a', typeName: 'Schema' }), - entry({ key: '#/a/b', parentKey: '#/a' }), - ]); - const revision = toMap([ - entry({ key: '#/a', typeName: 'Parameter' }), - entry({ key: '#/a/b', parentKey: '#/a' }), - ]); - - expect(summarize(compareMaps(base, revision))).toEqual([ - 'removed #/a #/a → -', - 'added #/a - → #/a', - ]); - }); - - it('compares a $ref the way it compares a scalar', () => { - const base = toMap([entry({ key: '#/a', properties: { schema: { $ref: '#/A' } } })]); - const revision = toMap([entry({ key: '#/a', properties: { schema: { $ref: '#/B' } } })]); - - const [change] = compareMaps(base, revision); - - expect(change.kind === 'modified' && change.property).toBe('schema'); - expect(change.kind === 'modified' && change.revision.value).toEqual({ $ref: '#/B' }); - }); - - it('emits nothing when the two maps are identical', () => { - const entries = [entry({ key: '#/a', properties: { enum: ['x', 'y'] } })]; - - expect(compareMaps(toMap(entries), toMap(entries))).toEqual([]); - }); -}); diff --git a/packages/core/src/diff/__tests__/detect.test.ts b/packages/core/src/diff/__tests__/detect.test.ts index 6a07c4e40d..33bdff129e 100644 --- a/packages/core/src/diff/__tests__/detect.test.ts +++ b/packages/core/src/diff/__tests__/detect.test.ts @@ -1,20 +1,26 @@ +import { defaultDiffRules } from '../../config/diff-recommended.js'; import { Location } from '../../ref-utils.js'; import { Source } from '../../resolve.js'; -import { detectBreakingChanges, initDiffRules } from '../detect.js'; -import { recommendedDiffRules } from '../rules/index.js'; -import type { Change, DiffRule } from '../types.js'; -import { UsageIndex } from '../usage.js'; -import { treeOf } from './tree.js'; +import { handlersOf, initDiffRules, judgeChanges, matches } from '../detect.js'; +import { oas3Rules } from '../rules/index.js'; +import { oas3Spec } from '../specs/oas3.js'; +import type { Change, DiffRule, Impact, Pair } from '../types.js'; +import { pairsOfTree, treeOf, usageOfTree } from './tree.js'; const source = new Source('api.yaml', ''); +const defaults: Record = defaultDiffRules; +const defaultImpact = (ruleId: string) => defaults[ruleId] ?? 'off'; const at = (pointer: string) => new Location(source, pointer); -const emptyMaps = { - base: new Map(), - revision: new Map(), - usage: new UsageIndex([], () => undefined), +const lonePair = (type: string, location: Location): Pair => { + const key = location.pointer.slice(location.pointer.lastIndexOf('/') + 1); + const node = { type, key, location, value: {}, parent: null, children: [] }; + return { base: node, revision: node, parent: null, children: [], occurrence: 1 }; }; +// Nothing references anything: every direction comes from the node's own position. +const unlinked = { fromUsage: () => 'neutral' as const }; + // What every finding passes through: which visitors run, what a report becomes, and how the // same finding under two directions is kept once. The rules themselves are covered by tests/e2e/diff. describe('detectBreakingChanges', () => { @@ -24,17 +30,19 @@ describe('detectBreakingChanges', () => { key: '#/info', kind: 'modified', property: 'title', - typeName: 'Info', + pair: lonePair('Info', at('#/info')), base: { location: at('#/info/title'), value: 'a' }, revision: { location: at('#/info/title'), value: 'b' }, }, ]; - const [change] = detectBreakingChanges({ + const [change] = judgeChanges({ changes, specVersion: 'oas3_1', - ruleMap: recommendedDiffRules, - ...emptyMaps, + spec: oas3Spec, + ruleSets: [oas3Rules], + impactOf: defaultImpact, + ...unlinked, }); expect(change.impact).toBe('patch'); @@ -46,16 +54,17 @@ describe('detectBreakingChanges', () => { { key: '#/x', kind: 'removed', - typeName: 'Operation', + pair: lonePair('Operation', at('#/x')), base: { location: at('#/x'), value: {} }, }, ]; - const [change] = detectBreakingChanges({ + const [change] = judgeChanges({ changes, specVersion: 'async2', - ruleMap: recommendedDiffRules, - ...emptyMaps, + ruleSets: [oas3Rules], + impactOf: defaultImpact, + ...unlinked, }); expect(change.impact).toBe('patch'); @@ -66,16 +75,18 @@ describe('detectBreakingChanges', () => { { key: '#/paths/~1x/delete', kind: 'removed', - typeName: 'Operation', + pair: lonePair('Operation', at('#/paths/~1x/delete')), base: { location: at('#/paths/~1x/delete'), value: {} }, }, ]; - const [change] = detectBreakingChanges({ + const [change] = judgeChanges({ changes, specVersion: 'oas3_1', - ruleMap: recommendedDiffRules, - ...emptyMaps, + spec: oas3Spec, + ruleSets: [oas3Rules], + impactOf: defaultImpact, + ...unlinked, }); expect(change.impact).toBe('major'); @@ -107,38 +118,35 @@ describe('detectBreakingChanges', () => { #/components/schemas NamedSchemas #/components/schemas/Shared Schema `); - const lookup = (key: string) => entries.get(key); - const usage = new UsageIndex( + const fromUsage = usageOfTree( + entries, [ - { - site: '#/paths/~1x/get/parameters/{query:q}/schema', - target: '#/components/schemas/Shared', - }, - { - site: '#/paths/~1x/get/responses/200/content/application~1json/schema', - target: '#/components/schemas/Shared', - }, + ['#/paths/~1x/get/parameters/{query:q}/schema', '#/components/schemas/Shared'], + [ + '#/paths/~1x/get/responses/200/content/application~1json/schema', + '#/components/schemas/Shared', + ], ], - lookup + oas3Spec ); const changes: Change[] = [ { key: '#/components/schemas/Shared', kind: 'modified', property: 'enum', - typeName: 'Schema', + pair: pairsOfTree(entries).get(entries.get('#/components/schemas/Shared')!)!, base: { location: at('#/components/schemas/Shared/enum'), value: ['a', 'b'] }, revision: { location: at('#/components/schemas/Shared/enum'), value: ['a', 'c'] }, }, ]; - const [change] = detectBreakingChanges({ + const [change] = judgeChanges({ changes, specVersion: 'oas3_1', - base: entries, - revision: entries, - usage, - ruleMap: recommendedDiffRules, + spec: oas3Spec, + fromUsage, + ruleSets: [oas3Rules], + impactOf: defaultImpact, }); expect(change.verdicts.map((verdict) => verdict.ruleId)).toEqual([ @@ -152,24 +160,26 @@ describe('detectBreakingChanges', () => { { key: '#/tags/{pets}', kind: 'added', - typeName: 'Tag', + pair: lonePair('Tag', at('#/tags/0')), revision: { location: at('#/tags/0'), value: {} }, }, { key: '#/info', kind: 'modified', property: 'title', - typeName: 'Info', + pair: lonePair('Info', at('#/info')), base: { location: at('#/info/title'), value: 'a' }, revision: { location: at('#/info/title'), value: 'b' }, }, ]; - const [added, modified] = detectBreakingChanges({ + const [added, modified] = judgeChanges({ changes, specVersion: 'oas3_1', - ruleMap: recommendedDiffRules, - ...emptyMaps, + spec: oas3Spec, + ruleSets: [oas3Rules], + impactOf: defaultImpact, + ...unlinked, }); expect(added.impact).toBe('minor'); @@ -181,22 +191,26 @@ describe('detectBreakingChanges', () => { { key: '#/paths/~1x/delete', kind: 'removed', - typeName: 'Operation', + pair: lonePair('Operation', at('#/paths/~1x/delete')), base: { location: at('#/paths/~1x/delete'), value: {} }, }, ]; - const [minor] = detectBreakingChanges({ + const [minor] = judgeChanges({ changes, specVersion: 'oas3_1', - ruleMap: { 'operation-removed': 'minor' }, - ...emptyMaps, + spec: oas3Spec, + ruleSets: [oas3Rules], + impactOf: (ruleId: string) => (ruleId === 'operation-removed' ? 'minor' : 'off'), + ...unlinked, }); - const [off] = detectBreakingChanges({ + const [off] = judgeChanges({ changes, specVersion: 'oas3_1', - ruleMap: { 'operation-removed': 'off' }, - ...emptyMaps, + spec: oas3Spec, + ruleSets: [oas3Rules], + impactOf: () => 'off' as const, + ...unlinked, }); expect(minor.impact).toBe('minor'); @@ -206,16 +220,91 @@ describe('detectBreakingChanges', () => { }); }); +// The tree the walker was probed with: a nested key must pick the members of a `properties` +// map and nothing that merely sits somewhere below one. +const schemaTree = treeOf(` + #/ Root + #/components Components + #/components/schemas NamedSchemas + #/components/schemas/Pet Schema + #/components/schemas/Pet/properties SchemaProperties + #/components/schemas/Pet/properties/name Schema + #/components/schemas/Pet/properties/owner Schema + #/components/schemas/Pet/properties/owner/oneOf OneOf + #/components/schemas/Pet/properties/owner/oneOf/0 Schema + #/components/schemas/Pet/properties/owner/oneOf/0/properties SchemaProperties + #/components/schemas/Pet/properties/owner/oneOf/0/properties/login Schema + #/components/schemas/Pet/properties/tags Schema + #/components/schemas/Pet/properties/tags/items Schema +`); +const schemaPairs = pairsOfTree(schemaTree); +const pairAt = (key: string) => schemaPairs.get(schemaTree.get(key)!)!; + +describe('matches', () => { + it('matches the members of a properties map, at any depth, and nothing below a member', () => { + const matched = [...schemaTree.keys()].filter((key) => + matches(['SchemaProperties', 'Schema'], pairAt(key)) + ); + + expect(matched).toEqual([ + '#/components/schemas/Pet/properties/name', + '#/components/schemas/Pet/properties/owner', + '#/components/schemas/Pet/properties/owner/oneOf/0/properties/login', + '#/components/schemas/Pet/properties/tags', + ]); + }); + + it('matches the alternatives of a oneOf and a flat key matches every node of the type', () => { + expect( + matches(['OneOf', 'Schema'], pairAt('#/components/schemas/Pet/properties/owner/oneOf/0')) + ).toBe(true); + expect( + matches( + ['OneOf', 'Schema'], + pairAt('#/components/schemas/Pet/properties/owner/oneOf/0/properties/login') + ) + ).toBe(false); + expect(matches(['Schema'], pairAt('#/components/schemas/Pet/properties/tags/items'))).toBe( + true + ); + expect(matches([], pairAt('#/'))).toBe(true); + }); +}); + +describe('handlersOf', () => { + it('flattens nested keys to type paths, enter to the container itself and any to the empty path', () => { + const visit = () => {}; + const handlers = handlersOf( + { any: visit, Schema: visit, SchemaProperties: { enter: visit, Schema: visit } }, + [], + 'my-rule' + ); + + expect(handlers.map(({ path }) => path)).toEqual([ + [], + ['Schema'], + ['SchemaProperties'], + ['SchemaProperties', 'Schema'], + ]); + }); + + it('rejects leave and skip, which a diff rule has no use for', () => { + expect(() => handlersOf({ Schema: { leave: () => {} } }, [], 'my-rule')).toThrow( + "Diff rule 'my-rule' uses 'leave'" + ); + }); +}); + describe('initDiffRules', () => { it('instantiates one visitor per rule under the rule id', () => { const rule: DiffRule = () => ({ Schema() {} }); expect( - initDiffRules({ 'my-rule': rule }, { 'my-rule': 'major' }).map(({ id, impact, visitor }) => [ + initDiffRules([{ 'my-rule': rule }], () => 'major').map(({ id, impact, handlers }) => [ id, impact, - Object.keys(visitor), + handlers.map(({ path }) => path), ]) - ).toEqual([['my-rule', 'major', ['Schema']]]); + ).toEqual([['my-rule', 'major', [['Schema']]]]); }); }); diff --git a/packages/core/src/diff/__tests__/diff-documents.test.ts b/packages/core/src/diff/__tests__/diff-documents.test.ts index b3a7ff26a7..ea6015a923 100644 --- a/packages/core/src/diff/__tests__/diff-documents.test.ts +++ b/packages/core/src/diff/__tests__/diff-documents.test.ts @@ -62,6 +62,49 @@ function report(result: DiffResult): string { } describe('diffDocuments', () => { + it('takes the direction of a component from a site that exists only in the revision', async () => { + const shared = outdent` + components: + schemas: + Size: + type: string + `; + const base = outdent` + openapi: 3.1.0 + info: { title: T, version: '1.0.0' } + paths: {} + ${shared} + enum: [s, m, l] + `; + const revision = outdent` + openapi: 3.1.0 + info: { title: T, version: '1.0.0' } + paths: + /pets: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Size' } + responses: { '201': { description: Created } } + ${shared} + enum: [s, m] + `; + const config = await createConfig({ extends: ['diff-recommended'] }); + + const result = diffDocuments({ + base: makeDocumentFromString(base, 'base.yaml'), + revision: makeDocumentFromString(revision, 'rev.yaml'), + config, + }); + + const enumChange = result.changes.find( + (change) => change.kind === 'modified' && change.property === 'enum' + ); + expect(enumChange?.direction).toBe('request'); + expect(enumChange?.verdicts.map((verdict) => verdict.ruleId)).toEqual(['enum-values-removed']); + }); + it('matches reordered parameters by identity and judges what actually changed', async () => { const config = await createConfig({ extends: ['diff-recommended'] }); const result = diffDocuments({ diff --git a/packages/core/src/diff/__tests__/direction.test.ts b/packages/core/src/diff/__tests__/direction.test.ts index fe3631c59b..431de57bb5 100644 --- a/packages/core/src/diff/__tests__/direction.test.ts +++ b/packages/core/src/diff/__tests__/direction.test.ts @@ -1,7 +1,7 @@ -import { getComponentRoot, type NodeLookup } from '../../node-map/chain.js'; -import { getAsync3Direction, getOas3Direction } from '../direction.js'; -import { mergeDirections, UsageIndex } from '../usage.js'; -import { treeOf } from './tree.js'; +import { async3Spec } from '../specs/async3.js'; +import { mergeDirections } from '../specs/direction.js'; +import { oas3Spec } from '../specs/oas3.js'; +import { treeOf, usageOfTree } from './tree.js'; // One document covering every direction-bearing shape at once. const entries = treeOf(` @@ -49,22 +49,9 @@ const entries = treeOf(` #/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(); - }); -}); +const node = (key: string) => entries.get(key)!; +const usageOf = (edges: Array<[string, string]>) => usageOfTree(entries, edges, oas3Spec); +const emptyUsage = usageOf([]); describe('mergeDirections', () => { it('merges directions', () => { @@ -77,100 +64,93 @@ describe('mergeDirections', () => { describe('getOas3Direction', () => { it('reads the direction off the node types on the way down', () => { - expect(getOas3Direction('#/paths/~1p/get/responses/200', emptyUsage, tree)).toBe('response'); + expect(oas3Spec.directionOf(node('#/paths/~1p/get/responses/200'), emptyUsage)).toBe( + 'response' + ); expect( - getOas3Direction('#/paths/~1p/get/parameters/{query:limit}/schema', emptyUsage, tree) + oas3Spec.directionOf(node('#/paths/~1p/get/parameters/{query:limit}/schema'), emptyUsage) ).toBe('request'); expect( - getOas3Direction('#/paths/~1p/post/requestBody/content/application~1json', emptyUsage, tree) + oas3Spec.directionOf( + node('#/paths/~1p/post/requestBody/content/application~1json'), + emptyUsage + ) ).toBe('request'); - expect(getOas3Direction('#/info/title', emptyUsage, tree)).toBe('neutral'); - expect(getOas3Direction('#/tags/{pets}', emptyUsage, tree)).toBe('neutral'); + expect(oas3Spec.directionOf(node('#/info/title'), emptyUsage)).toBe('neutral'); + expect(oas3Spec.directionOf(node('#/tags/{pets}'), emptyUsage)).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( - getOas3Direction('#/paths/~1p/post/callbacks/onEvent/~1cb/post/requestBody', emptyUsage, tree) + oas3Spec.directionOf( + node('#/paths/~1p/post/callbacks/onEvent/~1cb/post/requestBody'), + emptyUsage + ) ).toBe('response'); - expect(getOas3Direction('#/webhooks/newPet/post/requestBody', emptyUsage, tree)).toBe( + expect(oas3Spec.directionOf(node('#/webhooks/newPet/post/requestBody'), emptyUsage)).toBe( 'response' ); // ...and what the consumer answers with is a request. - expect(getOas3Direction('#/webhooks/newPet/post/responses', emptyUsage, tree)).toBe('request'); + expect(oas3Spec.directionOf(node('#/webhooks/newPet/post/responses'), emptyUsage)).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( - getOas3Direction( - '#/paths/~1p/post/requestBody/content/application~1json/schema/properties/responses', - emptyUsage, - tree + oas3Spec.directionOf( + node('#/paths/~1p/post/requestBody/content/application~1json/schema/properties/responses'), + emptyUsage ) ).toBe('request'); expect( - getOas3Direction( - '#/paths/~1p/get/responses/200/content/application~1json/schema/properties/callbacks', - emptyUsage, - tree + oas3Spec.directionOf( + node('#/paths/~1p/get/responses/200/content/application~1json/schema/properties/callbacks'), + emptyUsage ) ).toBe('response'); }); it('derives component direction from usage sites', () => { - const usage = new UsageIndex( + const usage = usageOf([ [ - { - site: '#/paths/~1p/get/responses/200/content/application~1json/schema', - target: '#/components/schemas/Pet', - }, + '#/paths/~1p/get/responses/200/content/application~1json/schema', + '#/components/schemas/Pet', ], - tree - ); - expect(getOas3Direction('#/components/schemas/Pet/properties/name', usage, tree)).toBe( + ]); + expect(oas3Spec.directionOf(node('#/components/schemas/Pet/properties/name'), usage)).toBe( 'response' ); }); it('derives both when a component is used on both sides', () => { - const usage = new UsageIndex( + const usage = usageOf([ [ - { - 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', - }, + '#/paths/~1p/get/responses/200/content/application~1json/schema', + '#/components/schemas/Pet', ], - tree - ); - expect(getOas3Direction('#/components/schemas/Pet', usage, tree)).toBe('both'); + ['#/paths/~1p/post/requestBody/content/application~1json/schema', '#/components/schemas/Pet'], + ]); + expect(oas3Spec.directionOf(node('#/components/schemas/Pet'), usage)).toBe('both'); }); it('resolves transitive usage through other components, cycle-safe', () => { - const usage = new UsageIndex( + const usage = usageOf([ [ - { - 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' }, + '#/paths/~1p/get/responses/200/content/application~1json/schema', + '#/components/schemas/Pet', ], - tree - ); - expect(getOas3Direction('#/components/schemas/Address', usage, tree)).toBe('response'); + ['#/components/schemas/Pet/properties/name', '#/components/schemas/Address'], + // cycle back + ['#/components/schemas/Address', '#/components/schemas/Pet'], + ]); + expect(oas3Spec.directionOf(node('#/components/schemas/Address'), usage)).toBe('response'); }); it('returns neutral for unused components', () => { - expect(getOas3Direction('#/components/schemas/Orphan', emptyUsage, tree)).toBe('neutral'); + expect(oas3Spec.directionOf(node('#/components/schemas/Orphan'), emptyUsage)).toBe('neutral'); }); }); @@ -193,51 +173,47 @@ const asyncEntries = treeOf(` #/operations/onOrder Operation action=receive #/operations/onOrder/reply OperationReply `); -const asyncTree: NodeLookup = (pointer) => asyncEntries.get(pointer); +const asyncNode = (key: string) => asyncEntries.get(key)!; +const asyncUsageOf = (edges: Array<[string, string]>) => + usageOfTree(asyncEntries, edges, async3Spec); describe('getAsync3Direction', () => { - const usage = new UsageIndex( - [ - { site: '#/operations/onSignup', target: '#/channels/signups' }, - { site: '#/operations/sendReceipt', target: '#/channels/receipts' }, - { site: '#/operations/onOrder/reply', target: '#/channels/orders' }, - ], - asyncTree - ); + const usage = asyncUsageOf([ + ['#/operations/onSignup', '#/channels/signups'], + ['#/operations/sendReceipt', '#/channels/receipts'], + ['#/operations/onOrder/reply', '#/channels/orders'], + ]); 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(getAsync3Direction('#/channels/signups/messages/signup/payload', usage, asyncTree)).toBe( - 'request' - ); - expect(getAsync3Direction('#/channels/receipts/messages/receipt', usage, asyncTree)).toBe( + expect( + async3Spec.directionOf(asyncNode('#/channels/signups/messages/signup/payload'), usage) + ).toBe('request'); + expect(async3Spec.directionOf(asyncNode('#/channels/receipts/messages/receipt'), usage)).toBe( 'response' ); }); it('flips the direction for a reply channel', () => { - expect(getAsync3Direction('#/channels/orders', usage, asyncTree)).toBe('response'); + expect(async3Spec.directionOf(asyncNode('#/channels/orders'), usage)).toBe('response'); }); it('reads the direction off the operation the change sits in', () => { - expect(getAsync3Direction('#/operations/sendReceipt', usage, asyncTree)).toBe('response'); + expect(async3Spec.directionOf(asyncNode('#/operations/sendReceipt'), usage)).toBe('response'); }); it('returns neutral for a channel no operation references', () => { - expect(getAsync3Direction('#/channels/signups', new UsageIndex([], asyncTree), asyncTree)).toBe( + expect(async3Spec.directionOf(asyncNode('#/channels/signups'), asyncUsageOf([]))).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(getAsync3Direction('#/channels/signups/messages/signup', recursive, asyncTree)).toBe( + const recursive = asyncUsageOf([ + ['#/channels/signups/messages/signup/payload', '#/channels/signups'], + ['#/channels/signups', '#/channels/signups/messages/signup/payload'], + ]); + expect(async3Spec.directionOf(asyncNode('#/channels/signups/messages/signup'), recursive)).toBe( 'neutral' ); }); diff --git a/packages/core/src/diff/__tests__/identity.test.ts b/packages/core/src/diff/__tests__/identity.test.ts deleted file mode 100644 index b36f60d20f..0000000000 --- a/packages/core/src/diff/__tests__/identity.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { outdent } from 'outdent'; - -import { createConfig } from '../../config/index.js'; -import { detectSpec } from '../../detect-spec.js'; -import { collectNodeMap } from '../../node-map/collect.js'; -import { getTypes } from '../../oas-types.js'; -import { makeDocumentFromString } from '../../resolve.js'; -import { normalizeTypes } from '../../types/index.js'; -import { identityOf } from '../identity.js'; - -async function keysOf(yaml: string): Promise>> { - const document = makeDocumentFromString(yaml, 'api.yaml'); - const config = await createConfig({}); - const specVersion = detectSpec(document.parsed); - const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - const { entries } = collectNodeMap({ document, types, specVersion, identityOf }); - return new Map([...entries.values()].map((entry) => [entry.key, entry.properties])); -} - -describe('identityOf', () => { - it('keys a path by its shape and keeps the template as a property', async () => { - const keys = await keysOf(outdent` - openapi: 3.1.0 - info: { title: T, version: '1' } - paths: - /pets/{petId}/toys/{toyId}: - get: - parameters: - - { name: toyId, in: path, required: true, schema: { type: string } } - - { name: petId, in: path, required: true, schema: { type: string } } - - { name: q, in: query, schema: { type: string } } - responses: {} - `); - - expect(keys.get('#/paths/~1pets~1{0}~1toys~1{1}')).toEqual({ - path: '/pets/{petId}/toys/{toyId}', - }); - expect( - [...keys.keys()].filter((key) => key.includes('parameters/') && !key.endsWith('/schema')) - ).toEqual([ - '#/paths/~1pets~1{0}~1toys~1{1}/get/parameters/{path:1}', - '#/paths/~1pets~1{0}~1toys~1{1}/get/parameters/{path:0}', - '#/paths/~1pets~1{0}~1toys~1{1}/get/parameters/{query:q}', - ]); - }); - - it('keeps a callback key as it is and falls back to the parameter name there', async () => { - const keys = await keysOf(outdent` - openapi: 3.1.0 - info: { title: T, version: '1' } - paths: - /subscribe: - post: - responses: {} - callbacks: - onEvent: - '{$request.body#/url}': - post: - parameters: - - { name: id, in: path, required: true, schema: { type: string } } - responses: {} - `); - - expect(keys.has('#/paths/~1subscribe/post/callbacks/onEvent/{$request.body#~1url}')).toBe(true); - expect( - keys.has( - '#/paths/~1subscribe/post/callbacks/onEvent/{$request.body#~1url}/post/parameters/{path:id}' - ) - ).toBe(true); - }); - - it('keys servers by url, tags by name, and security requirements by their scheme names', async () => { - const keys = await keysOf(outdent` - openapi: 3.1.0 - info: { title: T, version: '1' } - servers: - - url: https://a.example/v1 - tags: - - name: pets - security: - - oauth: [read] - apiKey: [] - paths: {} - `); - - expect(keys.has('#/servers/{https:~1~1a.example~1v1}')).toBe(true); - expect(keys.has('#/tags/{pets}')).toBe(true); - expect(keys.has('#/security/{apiKey+oauth}')).toBe(true); - }); -}); diff --git a/packages/core/src/diff/__tests__/impact.test.ts b/packages/core/src/diff/__tests__/impact.test.ts index 6b8dbb753f..57222e3ab3 100644 --- a/packages/core/src/diff/__tests__/impact.test.ts +++ b/packages/core/src/diff/__tests__/impact.test.ts @@ -1,4 +1,4 @@ -import { highestImpact, impactRank } from '../types.js'; +import { highestImpact, impactRank } from '../impact.js'; describe('impact ladder', () => { it('orders patch below minor below major', () => { diff --git a/packages/core/src/diff/__tests__/pairs.test.ts b/packages/core/src/diff/__tests__/pairs.test.ts new file mode 100644 index 0000000000..aca8630c52 --- /dev/null +++ b/packages/core/src/diff/__tests__/pairs.test.ts @@ -0,0 +1,134 @@ +import { outdent } from 'outdent'; + +import { createConfig } from '../../config/index.js'; +import { detectSpec } from '../../detect-spec.js'; +import { collectNodes } from '../../node-map/collect.js'; +import { getTypes } from '../../oas-types.js'; +import { makeDocumentFromString } from '../../resolve.js'; +import { normalizeTypes } from '../../types/index.js'; +import { labelOf, pairDocuments } from '../pairs.js'; +import { oas3Spec } from '../specs/oas3.js'; +import type { Pair } from '../types.js'; + +async function nodesOf(yaml: string) { + const document = makeDocumentFromString(yaml, 'api.yaml'); + const config = await createConfig({}); + const specVersion = detectSpec(document.parsed); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + return collectNodes({ document, types, specVersion }).root; +} + +/** Every pair's label, the document paired with itself. */ +async function labelsOf(yaml: string): Promise> { + const root = await nodesOf(yaml); + const labels = new Map(); + const visit = (pair: Pair) => { + labels.set(labelOf(pair, oas3Spec), pair); + pair.children.forEach(visit); + }; + visit(pairDocuments(root, root, oas3Spec).root); + return labels; +} + +describe('pairDocuments', () => { + it('labels a path by its shape and its path parameters by position', async () => { + const labels = await labelsOf(outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + paths: + /pets/{petId}/toys/{toyId}: + get: + parameters: + - { name: toyId, in: path, required: true, schema: { type: string } } + - { name: petId, in: path, required: true, schema: { type: string } } + - { name: q, in: query, schema: { type: string } } + responses: {} + `); + + expect(labels.get('#/paths/~1pets~1{0}~1toys~1{1}')?.base?.key).toBe( + '/pets/{petId}/toys/{toyId}' + ); + expect( + [...labels.keys()].filter((key) => key.includes('parameters/') && !key.endsWith('/schema')) + ).toEqual([ + '#/paths/~1pets~1{0}~1toys~1{1}/get/parameters/{path:1}', + '#/paths/~1pets~1{0}~1toys~1{1}/get/parameters/{path:0}', + '#/paths/~1pets~1{0}~1toys~1{1}/get/parameters/{query:q}', + ]); + }); + + it('keeps a callback key as it is and falls back to the parameter name there', async () => { + const labels = await labelsOf(outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + paths: + /subscribe: + post: + responses: {} + callbacks: + onEvent: + '{$request.body#/url}': + post: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: {} + `); + + expect(labels.has('#/paths/~1subscribe/post/callbacks/onEvent/{$request.body#~1url}')).toBe( + true + ); + expect( + labels.has( + '#/paths/~1subscribe/post/callbacks/onEvent/{$request.body#~1url}/post/parameters/{path:id}' + ) + ).toBe(true); + }); + + it('labels servers by url, tags by name, and security requirements by their scheme names', async () => { + const labels = await labelsOf(outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + servers: + - url: https://a.example/v1 + tags: + - name: pets + security: + - oauth: [read] + apiKey: [] + paths: {} + `); + + expect(labels.has('#/servers/{https:~1~1a.example~1v1}')).toBe(true); + expect(labels.has('#/tags/{pets}')).toBe(true); + expect(labels.has('#/security/{apiKey+oauth}')).toBe(true); + }); + + it('pairs siblings with the same identity in order and numbers the label from the second one', async () => { + const yaml = (second: string) => outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + paths: + /p: + get: + parameters: + - { name: a, in: query } + ${second} + responses: {} + `; + const base = await nodesOf(yaml('- { name: a, in: query, required: true }')); + const revision = await nodesOf(yaml('')); + + const list = pairDocuments(base, revision, oas3Spec) + .root.children.flatMap((pair) => pair.children) + .flatMap((pair) => pair.children) + .flatMap((pair) => pair.children) + .find((pair) => pair.base?.type === 'ParameterList')!; + + expect( + list.children.map((pair) => [labelOf(pair, oas3Spec), pair.base?.key, pair.revision?.key]) + ).toEqual([ + ['#/paths/~1p/get/parameters/{query:a}', 0, 0], + ['#/paths/~1p/get/parameters/{query:a}#2', 1, undefined], + ]); + }); +}); diff --git a/packages/core/src/diff/__tests__/rules.test.ts b/packages/core/src/diff/__tests__/rules.test.ts index a0248bdda4..7f63176f27 100644 --- a/packages/core/src/diff/__tests__/rules.test.ts +++ b/packages/core/src/diff/__tests__/rules.test.ts @@ -1,8 +1,19 @@ import { getTypes } from '../../oas-types.js'; import { async3Rules, oas3Rules } from '../rules/index.js'; +import type { DiffVisitor } from '../types.js'; // A visitor key is a node type name; a typo (`HeaderMap` for `HeadersMap`) would make the // rule silently never run, which no e2e fixture can tell from "nothing to report". +function visitedTypes(visitor: DiffVisitor): string[] { + return Object.entries(visitor).flatMap(([key, value]) => + typeof value === 'function' + ? key === 'any' || key === 'enter' + ? [] + : [key] + : [key, ...visitedTypes(value)] + ); +} + describe('diff rule registries', () => { it.each([ ['oas3_1', oas3Rules], @@ -13,8 +24,8 @@ describe('diff rule registries', () => { const typeNames = new Set(Object.keys(getTypes(specVersion))); const unknown = Object.values(rules) - .flatMap((rule) => Object.keys(rule())) - .filter((typeName) => typeName !== 'any' && !typeNames.has(typeName)); + .flatMap((rule) => visitedTypes(rule())) + .filter((typeName) => !typeNames.has(typeName)); expect(unknown).toEqual([]); } diff --git a/packages/core/src/diff/__tests__/tree.ts b/packages/core/src/diff/__tests__/tree.ts index 0a877ba5ed..89d4dcf9bf 100644 --- a/packages/core/src/diff/__tests__/tree.ts +++ b/packages/core/src/diff/__tests__/tree.ts @@ -1,33 +1,66 @@ -import type { NodeEntry } from '../../node-map/types.js'; +import type { NodeEntry, Reference } from '../../node-map/types.js'; import { Location } from '../../ref-utils.js'; import { Source } from '../../resolve.js'; +import type { DiffSpec, Pair } from '../types.js'; +import { usageDirections } from '../usage.js'; const source = new Source('tree.yaml', ''); /** - * Builds a lookup over a spelled-out node tree for tests that need real node types rather - * than bare keys. Each line is `key typeName`, optionally followed by `name=value` - * properties; a node's parent is the closest preceding line whose key is a prefix of it — - * which is what `collectNodeMap` records when it walks a document. + * Builds nodes from a spelled-out tree for tests that need real node types rather than a + * document. Each line is `key Type`, optionally followed by `name=value` fields; a node's + * parent is the closest preceding line whose key is a prefix of it — which is what + * `collectNodes` records when it walks a document. */ export function treeOf(nodes: string): Map { const entries = new Map(); - const keys: string[] = []; for (const line of nodes.trim().split('\n')) { - const [key, typeName, ...assignments] = line.trim().split(/\s+/); - const parentKey = - [...keys].reverse().find((candidate) => key.startsWith(`${candidate}/`)) ?? null; - keys.push(key); - entries.set(key, { - key, - parentKey, + const [key, type, ...assignments] = line.trim().split(/\s+/); + const parentKey = [...entries.keys()] + .reverse() + .find((candidate) => key.startsWith(`${candidate}/`)); + + const parent = (parentKey ? entries.get(parentKey) : null) ?? null; + const entry: NodeEntry = { + type, + key: key.slice(key.lastIndexOf('/') + 1), location: new Location(source, key), - typeName, - properties: Object.fromEntries(assignments.map((pair) => pair.split('='))), - raw: {}, - }); + value: Object.fromEntries(assignments.map((pair) => pair.split('='))), + parent, + children: [], + }; + parent?.children.push(entry); + entries.set(key, entry); } return entries; } + +/** The tree paired with itself: every node is both sides of its pair. */ +export function pairsOfTree(entries: Map): Map { + const pairs = new Map(); + for (const entry of entries.values()) { + const parent = entry.parent ? pairs.get(entry.parent)! : null; + const pair: Pair = { base: entry, revision: entry, parent, children: [], occurrence: 1 }; + parent?.children.push(pair); + pairs.set(entry, pair); + } + return pairs; +} + +export function referencesOfTree( + entries: Map, + edges: Array<[from: string, to: string]> +): Reference[] { + return edges.map(([from, to]) => ({ from: entries.get(from)!, to: entries.get(to)! })); +} + +/** The usage directions of a tree paired with itself, for tests that only vary the edges. */ +export function usageOfTree( + entries: Map, + edges: Array<[from: string, to: string]>, + spec: DiffSpec +) { + return usageDirections(referencesOfTree(entries, edges), pairsOfTree(entries), spec); +} diff --git a/packages/core/src/diff/changes.ts b/packages/core/src/diff/changes.ts new file mode 100644 index 0000000000..57835131d4 --- /dev/null +++ b/packages/core/src/diff/changes.ts @@ -0,0 +1,70 @@ +import type { NodeEntry } from '../node-map/types.js'; +import { isRef } from '../ref-utils.js'; +import { dequal } from '../utils/dequal.js'; +import { isPlainObject } from '../utils/is-plain-object.js'; +import { isScalar, isScalarArray } from '../utils/is-scalar.js'; +import { labelOf, nodeOf, typeOf } from './pairs.js'; +import type { Change, DiffSpec, LocatedNode, Pair } from './types.js'; + +export function displaySide(change: Change): LocatedNode { + return change.kind === 'removed' ? change.base : change.revision; +} + +/** Every change under the root; nothing under a node that was added or removed as a whole. */ +export function changesIn(root: Pair, spec: DiffSpec): Change[] { + const changes: Change[] = []; + const visit = (pair: Pair) => { + changes.push(...changesOf(pair, spec)); + if (pair.base && pair.revision) pair.children.forEach(visit); + }; + visit(root); + return changes; +} + +export function changesOf(pair: Pair, spec: DiffSpec): Change[] { + const { base, revision } = pair; + const key = labelOf(pair, spec); + if (!base) return [{ key, pair, kind: 'added', revision: located(nodeOf(pair)) }]; + if (!revision) return [{ key, pair, kind: 'removed', base: located(base) }]; + + const before = comparableValues(pair, base, spec); + const after = comparableValues(pair, revision, spec); + return [...new Set([...Object.keys(before), ...Object.keys(after)])] + .sort() + .filter((property) => !dequal(before[property], after[property])) + .map((property) => ({ + key, + pair, + kind: 'modified', + property, + base: locatedProperty(base, property, before[property]), + revision: locatedProperty(revision, property, after[property]), + })); +} + +/** + * The node's own values — scalars, scalar arrays and `$ref`s; nested objects and arrays are + * pairs of their own. A value the segment hides, such as a path template, is compared too. + */ +function comparableValues(pair: Pair, node: NodeEntry, spec: DiffSpec): Record { + const values: Record = {}; + + if (isPlainObject(node.value)) { + for (const [name, item] of Object.entries(node.value)) { + if (isRef(item) || isScalar(item) || isScalarArray(item)) values[name] = item; + } + } + + const identity = pair.parent && spec.identityOf(node, typeOf(pair.parent)); + return { ...values, ...identity?.values }; +} + +function located(node: NodeEntry): LocatedNode { + return { location: node.location, value: node.value }; +} + +// A value the node does not carry as a property (the path template) is located at the node. +function locatedProperty(node: NodeEntry, property: string, value: unknown): LocatedNode { + const isOwnProperty = isPlainObject(node.value) && property in node.value; + return { location: isOwnProperty ? node.location.child([property]) : node.location, value }; +} diff --git a/packages/core/src/diff/compare.ts b/packages/core/src/diff/compare.ts deleted file mode 100644 index 46d75e29ab..0000000000 --- a/packages/core/src/diff/compare.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { NodeEntry } from '../node-map/types.js'; -import { dequal } from '../utils/dequal.js'; -import { isPlainObject } from '../utils/is-plain-object.js'; -import type { Change } from './types.js'; - -function removalOf(entry: NodeEntry): Change { - return { - key: entry.key, - kind: 'removed', - typeName: entry.typeName, - base: { location: entry.location, value: entry.raw }, - }; -} - -function additionOf(entry: NodeEntry): Change { - return { - key: entry.key, - kind: 'added', - typeName: entry.typeName, - revision: { location: entry.location, value: entry.raw }, - }; -} - -export function compareMaps( - base: Map, - revision: Map -): Change[] { - const changes: Change[] = []; - 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)?.parentKey ?? null; - while (parent !== null) { - if (boundaries.has(parent)) return true; - parent = getEntry(parent)?.parentKey ?? null; - } - return false; - }; - - // Pass 2: emission, in deterministic key 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(baseEntry)); - if (revisionEntry) changes.push(additionOf(revisionEntry)); - } else if (baseEntry.typeName !== revisionEntry.typeName) { - // replaced → a removed+added pair at the same key - changes.push(removalOf(baseEntry), additionOf(revisionEntry)); - } else { - const properties = new Set([ - ...Object.keys(baseEntry.properties), - ...Object.keys(revisionEntry.properties), - ]); - for (const property of [...properties].sort()) { - const before = baseEntry.properties[property]; - const after = revisionEntry.properties[property]; - if (dequal(before, after)) continue; - changes.push({ - key, - kind: 'modified', - typeName: baseEntry.typeName, - property, - base: { location: propertyLocation(baseEntry, property), value: before }, - revision: { location: propertyLocation(revisionEntry, property), value: after }, - }); - } - } - } - - return changes; -} - -// A property the node does not carry itself came from its identity (the path template), so it is located at the node. -function propertyLocation(entry: NodeEntry, property: string) { - const isOwnProperty = isPlainObject(entry.raw) && property in entry.raw; - return isOwnProperty ? entry.location.child([property]) : entry.location; -} diff --git a/packages/core/src/diff/detect.ts b/packages/core/src/diff/detect.ts index fdb92463c6..9ecd66e3ad 100644 --- a/packages/core/src/diff/detect.ts +++ b/packages/core/src/diff/detect.ts @@ -1,111 +1,105 @@ -import type { NodeLookup } from '../node-map/chain.js'; import type { NodeEntry } from '../node-map/types.js'; import type { SpecVersion } from '../oas-types.js'; -import { getAsync3Direction, getOas3Direction, type DirectionResolver } from './direction.js'; -import { async3Rules } from './rules/async3.js'; -import type { DiffRuleMap } from './rules/index.js'; -import { oas3Rules } from './rules/oas3.js'; -import { - displaySide, - highestImpact, - impactRank, - type Change, - type DiffReport, - type DiffRule, - type DiffVisitor, - type Direction, - type Impact, - type JudgedChange, - type RuleVerdict, +import { displaySide } from './changes.js'; +import { highestImpact, impactRank } from './impact.js'; +import { nodeOf, typeOf } from './pairs.js'; +import type { + Change, + DiffReport, + DiffRule, + DiffSpec, + DiffVisitor, + Direction, + Impact, + InitializedDiffRule, + JudgedChange, + Pair, + RuleHandler, + RuleVerdict, } from './types.js'; -import type { UsageIndex } from './usage.js'; - -export interface InitializedDiffRule { - id: string; - impact: Impact; - visitor: DiffVisitor; -} export function initDiffRules( - rules: Record, - ruleMap: Partial> + ruleSets: Record[], + impactOf: (ruleId: string) => Impact | 'off' ): InitializedDiffRule[] { - return Object.entries(rules).flatMap(([id, rule]) => { - const impact = ruleMap[id] ?? 'off'; - return impact === 'off' ? [] : [{ id, impact, visitor: rule() }]; + return ruleSets.flatMap((rules) => + Object.entries(rules).flatMap(([id, rule]) => { + const impact = impactOf(id); + return impact === 'off' ? [] : [{ id, impact, handlers: handlersOf(rule(), [], id) }]; + }) + ); +} + +export function handlersOf(visitor: DiffVisitor, path: string[], ruleId: string): RuleHandler[] { + return Object.entries(visitor).flatMap(([key, value]) => { + if (key === 'leave' || key === 'skip' || (key === 'enter' && path.length === 0)) { + throw new Error(`Diff rule '${ruleId}' uses '${key}', which diff visitors do not have.`); + } + if (typeof value !== 'function') return handlersOf(value, [...path, key], ruleId); + if (key === 'any') return [{ path: [], visit: value }]; + return [{ path: key === 'enter' ? path : [...path, key], visit: value }]; }); } +/** + * Lint's nesting: `[A, B]` matches a node of type `B` when the first ancestor that is either + * an `A` or a `B` is an `A`. The empty path matches every change. + */ +export function matches(path: string[], pair: Pair): boolean { + if (path.length === 0) return true; + const type = path[path.length - 1]; + if (typeOf(pair) !== type) return false; + if (path.length === 1) return true; + + const container = nearest(pair.parent, [path[path.length - 2], type]); + return container !== undefined && matches(path.slice(0, -1), container); +} + +function nearest(pair: Pair | null, types: string[]): Pair | undefined { + for (let current = pair; current; current = current.parent) { + if (types.includes(typeOf(current))) return current; + } + return undefined; +} + // What a change means when no rule spoke: something new is a minor, everything else a patch. function unjudgedImpact(kind: Change['kind']): Impact { return kind === 'added' ? 'minor' : 'patch'; } -/** - * What a specification family brings to detection: the rules to run, and the way that - * family states which direction the data in a node travels. - */ -const SPECS: Partial< - Record; directionOf: DirectionResolver }> -> = { - oas3_0: { rules: oas3Rules, directionOf: getOas3Direction }, - oas3_1: { rules: oas3Rules, directionOf: getOas3Direction }, - // A version gets its own entry once it needs a rule the others must not run. - oas3_2: { rules: oas3Rules, directionOf: getOas3Direction }, - async3: { rules: async3Rules, directionOf: getAsync3Direction }, -}; - function expandDirection(direction: Direction): Direction[] { return direction === 'both' ? ['request', 'response'] : [direction]; } -export function detectBreakingChanges(opts: { +export function judgeChanges(opts: { changes: Change[]; specVersion: SpecVersion; - base: Map; - revision: Map; - usage: UsageIndex; - ruleMap: DiffRuleMap; + spec?: DiffSpec; + ruleSets: Record[]; + impactOf: (ruleId: string) => Impact | 'off'; + fromUsage: (node: NodeEntry) => Direction; }): JudgedChange[] { - const { changes, specVersion, base, revision, usage, ruleMap } = 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, - impact: unjudgedImpact(change.kind), - direction: 'neutral' as const, - verdicts: [], - })); - } - - const rules = initDiffRules(spec.rules, ruleMap); - // A removed node only exists in the base, an added one only in the revision. - const nodeAt: NodeLookup = (key) => revision.get(key) ?? base.get(key); + const { changes, specVersion, spec, ruleSets, impactOf, fromUsage } = opts; + // Structural comparison works for every specification; only the families with a spec are + // judged, so elsewhere no rule runs and nothing is called breaking. + const rules = spec ? initDiffRules(ruleSets, impactOf) : []; return changes.map((change) => { const verdicts: RuleVerdict[] = []; - const direction = spec.directionOf(change.key, usage, nodeAt); + const direction = spec?.directionOf(nodeOf(change.pair), fromUsage) ?? 'neutral'; for (const expandedDirection of expandDirection(direction)) { - for (const { id, impact, visitor } of rules) { + for (const { id, impact, handlers } of rules) { const report = ({ message, location = displaySide(change).location }: DiffReport) => { // a node used both ways is visited twice; the same finding is kept once if (!verdicts.some((verdict) => verdict.ruleId === id && verdict.message === message)) { verdicts.push({ ruleId: id, impact, message, location }); } }; - const context = { - report, - direction: expandedDirection, - specVersion, - base: (key: string) => base.get(key), - revision: (key: string) => revision.get(key), - nodeAt, - }; - visitor[change.typeName]?.(change, context); - visitor.any?.(change, context); + const context = { report, direction: expandedDirection, specVersion }; + for (const { path, visit } of handlers) { + if (matches(path, change.pair)) visit(change, context); + } } } diff --git a/packages/core/src/diff/direction.ts b/packages/core/src/diff/direction.ts deleted file mode 100644 index 04cbd3e6df..0000000000 --- a/packages/core/src/diff/direction.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { ancestorChain, getComponentRoot, type NodeLookup } from '../node-map/chain.js'; -import type { NodeEntry } from '../node-map/types.js'; -import type { Direction } from './types.js'; -import type { UsageIndex } from './usage.js'; - -/** How one specification family decides which way the data in a node travels. */ -export type DirectionResolver = (key: string, usage: UsageIndex, lookup: NodeLookup) => Direction; - -function opposite(direction: Direction): Direction { - if (direction === 'request') return 'response'; - if (direction === 'response') return 'request'; - return direction; -} - -// 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 getOas3SiteDirection(key: string, lookup: NodeLookup): Direction { - let inverted = false; - - for (const { typeName } of ancestorChain(key, 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 getOas3Direction: DirectionResolver = (key, 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(key, lookup); - if (componentRoot) { - return usage.directionOf(componentRoot, (site) => getOas3SiteDirection(site, lookup)); - } - - return getOas3SiteDirection(key, 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 actionDirection(action: unknown): Direction { - if (action === 'receive') return 'request'; - if (action === 'send') return 'response'; - return 'neutral'; -} - -function getOperationDirection(chain: NodeEntry[]): Direction { - const operation = [...chain].reverse().find((entry) => entry.typeName === 'Operation'); - if (!operation) return 'neutral'; - - const direction = actionDirection(operation.properties.action); - // A reply answers the operation, so it travels back the other way. - const underReply = chain.some((entry) => entry.typeName === 'OperationReply'); - return underReply ? opposite(direction) : direction; -} - -/** - * 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 getAsync3Direction: DirectionResolver = (key, usage, lookup) => - resolveAsync3Direction(key, usage, lookup, new Set()); - -function resolveAsync3Direction( - key: string, - usage: UsageIndex, - lookup: NodeLookup, - resolving: Set -): Direction { - // A payload that refers back into its own channel would otherwise resolve forever. - if (resolving.has(key)) return 'neutral'; - resolving.add(key); - - const chain = ancestorChain(key, lookup); - const own = getOperationDirection(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 direction = usage.directionOf(entry.key, (site) => - resolveAsync3Direction(site, usage, lookup, resolving) - ); - if (direction !== 'neutral') return direction; - } - - return 'neutral'; -} diff --git a/packages/core/src/diff/identity.ts b/packages/core/src/diff/identity.ts deleted file mode 100644 index 1e4ecdcd4f..0000000000 --- a/packages/core/src/diff/identity.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { IdentityFn, NodeEntry } from '../node-map/types.js'; -import { escapePointerFragment } from '../ref-utils.js'; - -const TEMPLATE_EXPRESSION = /\{[^}]+\}/g; - -// "Templated paths with the same hierarchy but different templated names MUST NOT exist -// as they are identical" (OpenAPI, Paths Object) — the shape is the identity, the names are not. -function templateShape(template: string): string { - let position = 0; - return template.replace(TEMPLATE_EXPRESSION, () => `{${position++}}`); -} - -function templateParameterNames(template: string): string[] { - return [...template.matchAll(TEMPLATE_EXPRESSION)].map(([expression]) => expression.slice(1, -1)); -} - -// "If `in` is 'path', the `name` field MUST correspond to a template expression occurring -// within the path" (OpenAPI, Parameter Object) — so the position of that expression is the -// identity. A name the template does not contain (an invalid document, or a callback whose -// key is a runtime expression) falls back to the name. -function pathParameterIdentity(name: string, ancestors: NodeEntry[]): string { - const template = ancestors.findLast((entry) => entry.typeName === 'PathItem')?.properties.path; - const position = - typeof template === 'string' ? templateParameterNames(template).indexOf(name) : -1; - return position === -1 ? `{path:${escapePointerFragment(name)}}` : `{path:${position}}`; -} - -type ListItemIdentity = ( - node: Record, - ancestors: NodeEntry[] -) => string | undefined; - -// Identity segments for list items that have a natural identity; everything else keeps -// its position. Each part is pointer-escaped so a `/` inside a name cannot forge a level. -const LIST_ITEM_IDENTITIES: Record = { - Parameter: (parameter, ancestors) => { - if (typeof parameter.in !== 'string' || typeof parameter.name !== 'string') return undefined; - return parameter.in === 'path' - ? pathParameterIdentity(parameter.name, ancestors) - : `{${escapePointerFragment(parameter.in)}:${escapePointerFragment(parameter.name)}}`; - }, - Server: (server) => - typeof server.url === 'string' ? `{${escapePointerFragment(server.url)}}` : undefined, - Tag: (tag) => (typeof tag.name === 'string' ? `{${escapePointerFragment(tag.name)}}` : undefined), - SecurityRequirement: (requirement) => - `{${Object.keys(requirement).sort().map(escapePointerFragment).join('+')}}`, -}; - -export const identityOf: IdentityFn = (node, { typeName, key, parent, ancestors }) => { - if (Array.isArray(parent)) { - const segment = LIST_ITEM_IDENTITIES[typeName]?.(node, ancestors); - return segment === undefined ? undefined : { segment }; - } - if ( - typeName === 'PathItem' && - ancestors.at(-1)?.typeName === 'Paths' && - typeof key === 'string' - ) { - return { segment: escapePointerFragment(templateShape(key)), properties: { path: key } }; - } - return undefined; -}; diff --git a/packages/core/src/diff/impact.ts b/packages/core/src/diff/impact.ts new file mode 100644 index 0000000000..2a6635ac87 --- /dev/null +++ b/packages/core/src/diff/impact.ts @@ -0,0 +1,16 @@ +import type { Impact } from './types.js'; + +/** Lowest first. An impact is the semver part a change requires to be bumped. */ +export const impacts = ['patch', 'minor', 'major'] as const; + +export function impactRank(impact: Impact): number { + return impacts.indexOf(impact); +} + +export function highestImpact(candidates: Impact[]): Impact | undefined { + return candidates.reduce( + (highest, impact) => + highest === undefined || impactRank(impact) > impactRank(highest) ? impact : highest, + undefined + ); +} diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index d16a278384..9f166ceb2b 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -1,30 +1,31 @@ import type { Config } from '../config/index.js'; import { detectSpec, getMajorSpecVersion } from '../detect-spec.js'; -import { collectNodeMap } from '../node-map/collect.js'; +import { collectNodes } from '../node-map/collect.js'; import { getTypes, type SpecVersion } from '../oas-types.js'; import type { Document } from '../resolve.js'; import { normalizeTypes } from '../types/index.js'; -import { isEmptyObject } from '../utils/is-empty-object.js'; -import { compareMaps } from './compare.js'; -import { detectBreakingChanges } from './detect.js'; -import { identityOf } from './identity.js'; -import { recommendedDiffRules } from './rules/index.js'; -import { - highestImpact, - type DiffResult, - type DiffSummary, - type Impact, - type JudgedChange, -} from './types.js'; -import { UsageIndex } from './usage.js'; +import { changesIn } from './changes.js'; +import { judgeChanges } from './detect.js'; +import { highestImpact } from './impact.js'; +import { pairDocuments, typeOf } from './pairs.js'; +import { diffSpecs, structuralSpec } from './specs/index.js'; +import type { DiffResult, DiffSummary, Impact, JudgedChange } from './types.js'; +import { usageDirections } from './usage.js'; export class DiffError extends Error {} +const byKey = (left: { key: string }, right: { key: string }) => + left.key < right.key ? -1 : left.key > right.key ? 1 : 0; + // The change to `info.version` is the bump being checked, so it does not count toward it. function requiredBump(changes: JudgedChange[]): Impact | undefined { const counted = changes.filter( (change) => - !(change.key === '#/info' && change.kind === 'modified' && change.property === 'version') + !( + typeOf(change.pair) === 'Info' && + change.kind === 'modified' && + change.property === 'version' + ) ); return highestImpact(counted.map((change) => change.impact)); } @@ -38,7 +39,8 @@ export function diffDocuments(opts: { const baseVersion = detectSpec(base.parsed); const revisionVersion = detectSpec(revision.parsed); - if (getMajorSpecVersion(baseVersion) !== getMajorSpecVersion(revisionVersion)) { + const family = getMajorSpecVersion(revisionVersion); + if (getMajorSpecVersion(baseVersion) !== family) { throw new DiffError( `The base and the revision use different specification families: '${baseVersion}' and '${revisionVersion}'. The diff command compares documents of one family only.` ); @@ -46,30 +48,33 @@ export function diffDocuments(opts: { // Each side is collected with its own type tree. const collect = (document: Document, specVersion: SpecVersion) => - collectNodeMap({ + collectNodes({ document, types: normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config), specVersion, - identityOf, }); - const baseMap = collect(base, baseVersion); - const revisionMap = collect(revision, revisionVersion); + const spec = diffSpecs[family]; + + const collectedBase = collect(base, baseVersion); + const collectedRevision = collect(revision, revisionVersion); - const nodeAt = (key: string) => revisionMap.entries.get(key) ?? baseMap.entries.get(key); - const usage = new UsageIndex([...baseMap.usageEdges, ...revisionMap.usageEdges], nodeAt); + const references = [...collectedBase.references, ...collectedRevision.references]; - // A config that says nothing about diff gets the built-in preset, the way lint falls - // back to `recommended` when there is no config at all. - const ruleMap = isEmptyObject(config.diff) ? recommendedDiffRules : config.diff; + const { root, pairOf } = pairDocuments( + collectedBase.root, + collectedRevision.root, + spec ?? structuralSpec + ); + const fromUsage = usageDirections(references, pairOf, spec ?? structuralSpec); - const changes = detectBreakingChanges({ - changes: compareMaps(baseMap.entries, revisionMap.entries), + const changes = judgeChanges({ + changes: changesIn(root, spec ?? structuralSpec).sort(byKey), specVersion: revisionVersion, - base: baseMap.entries, - revision: revisionMap.entries, - usage, - ruleMap, + spec, + ruleSets: config.getDiffRulesForSpecVersion(family), + impactOf: (ruleId) => config.getDiffImpact(ruleId, revisionVersion), + fromUsage, }); const summary: DiffSummary = { major: 0, minor: 0, patch: 0 }; diff --git a/packages/core/src/diff/pairs.ts b/packages/core/src/diff/pairs.ts new file mode 100644 index 0000000000..ad0201362b --- /dev/null +++ b/packages/core/src/diff/pairs.ts @@ -0,0 +1,84 @@ +import type { NodeEntry } from '../node-map/types.js'; +import { escapePointerFragment, joinPointer } from '../ref-utils.js'; +import type { DiffSpec, Pair } from './types.js'; + +export function nodeOf(pair: Pair): NodeEntry { + return (pair.revision ?? pair.base)!; +} + +export function typeOf(pair: Pair): string { + return nodeOf(pair).type; +} + +/** + * Matches the two documents up top-down: the roots are a pair, and the children of a pair are + * matched by the segment their container gives them. Every node gets a pair, including those + * under a node that exists on one side only, so a reference site is always addressable. + */ +export function pairDocuments( + base: NodeEntry, + revision: NodeEntry, + spec: DiffSpec +): { root: Pair; pairOf: Map } { + const pairOf = new Map(); + + function pairUp( + baseNode: NodeEntry | undefined, + revisionNode: NodeEntry | undefined, + parent: Pair | null, + occurrence: number + ): Pair { + const pair: Pair = { base: baseNode, revision: revisionNode, parent, children: [], occurrence }; + if (baseNode) pairOf.set(baseNode, pair); + if (revisionNode) pairOf.set(revisionNode, pair); + + const container = typeOf(pair); + const baseChildren = groupBySegment(baseNode?.children ?? [], container, spec); + const revisionChildren = groupBySegment(revisionNode?.children ?? [], container, spec); + + for (const segment of new Set([...baseChildren.keys(), ...revisionChildren.keys()])) { + const left = baseChildren.get(segment) ?? []; + const right = revisionChildren.get(segment) ?? []; + for (let index = 0; index < Math.max(left.length, right.length); index++) { + if (left[index] && right[index] && left[index].type !== right[index].type) { + // The same place holds a different kind of node: a removal and an addition. + pair.children.push(pairUp(left[index], undefined, pair, index + 1)); + pair.children.push(pairUp(undefined, right[index], pair, index + 1)); + } else { + pair.children.push(pairUp(left[index], right[index], pair, index + 1)); + } + } + } + + return pair; + } + + return { root: pairUp(base, revision, null, 1), pairOf }; +} + +function groupBySegment( + nodes: NodeEntry[], + container: string, + spec: DiffSpec +): Map { + const groups = new Map(); + for (const node of nodes) { + const segment = segmentOf(node, container, spec); + groups.set(segment, [...(groups.get(segment) ?? []), node]); + } + return groups; +} + +function segmentOf(node: NodeEntry, container: string, spec: DiffSpec): string { + return spec.identityOf(node, container)?.segment ?? escapePointerFragment(String(node.key)); +} + +/** The report key: the pair's segments joined like a pointer, `#n` marking the n-th sibling with the same segment. */ +export function labelOf(pair: Pair, spec: DiffSpec): string { + if (!pair.parent) return '#/'; + const own = joinPointer( + labelOf(pair.parent, spec), + segmentOf(nodeOf(pair), typeOf(pair.parent), spec) + ); + return pair.occurrence === 1 ? own : `${own}#${pair.occurrence}`; +} diff --git a/packages/core/src/diff/__tests__/constraints.test.ts b/packages/core/src/diff/rules/__tests__/constraints.test.ts similarity index 98% rename from packages/core/src/diff/__tests__/constraints.test.ts rename to packages/core/src/diff/rules/__tests__/constraints.test.ts index af413a3f46..fadc1d8481 100644 --- a/packages/core/src/diff/__tests__/constraints.test.ts +++ b/packages/core/src/diff/rules/__tests__/constraints.test.ts @@ -1,4 +1,4 @@ -import { isScalarArray } from '../../utils/is-scalar.js'; +import { isScalarArray } from '../../../utils/is-scalar.js'; import { constraintDirection, effectiveTypes, diff --git a/packages/core/src/diff/rules/async3.ts b/packages/core/src/diff/rules/async3.ts deleted file mode 100644 index da41270740..0000000000 --- a/packages/core/src/diff/rules/async3.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { DiffRule } from '../types.js'; -import { ChannelAddressChanged, ChannelRemoved } from './channel.js'; -import { MessageContentTypeChanged, MessageRemoved } from './message.js'; -import { OperationActionChanged, OperationRemoved } from './operation.js'; -import { RefTargetChanged } from './ref.js'; -import { schemaRules } from './schema.js'; -import { ServerRemoved } from './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 `direction.ts`), so the whole schema rule set is reused as it is. -export const async3Rules = { - 'channel-removed': ChannelRemoved, - 'channel-address-changed': ChannelAddressChanged, - 'message-removed': MessageRemoved, - 'message-content-type-changed': MessageContentTypeChanged, - 'operation-removed': OperationRemoved, - 'operation-action-changed': OperationActionChanged, - 'server-removed': ServerRemoved, - 'ref-target-changed': RefTargetChanged, - ...schemaRules, -} satisfies Record; diff --git a/packages/core/src/diff/constraints.ts b/packages/core/src/diff/rules/constraints.ts similarity index 100% rename from packages/core/src/diff/constraints.ts rename to packages/core/src/diff/rules/constraints.ts diff --git a/packages/core/src/diff/rules/index.ts b/packages/core/src/diff/rules/index.ts index 2f70a1b240..50cb1a21c4 100644 --- a/packages/core/src/diff/rules/index.ts +++ b/packages/core/src/diff/rules/index.ts @@ -1,7 +1,91 @@ -import { async3Rules } from './async3.js'; -import { oas3Rules } from './oas3.js'; +import type { DiffRule } from '../types.js'; +import { ChannelAddressChanged, ChannelRemoved } from './channel.js'; +import { MessageContentTypeChanged, MessageRemoved } from './message.js'; +import { OperationActionChanged, OperationRemoved, PathRemoved } from './operation.js'; +import { + ParameterAddedRequired, + ParameterBecameRequired, + ParameterRemoved, + ParameterSerializationChanged, +} from './parameter.js'; +import { RefTargetChanged } from './ref.js'; +import { RequestBodyBecameRequired, RequestBodyRemoved } from './request-body.js'; +import { MediaTypeRemoved, ResponseHeaderRemoved, ResponseRemoved } from './response.js'; +import { + AdditionalPropertiesChanged, + EnumValuesAdded, + EnumValuesRemoved, + NumericRangeChanged, + PropertyRemovedFromResponse, + RequiredPropertiesAdded, + RequiredPropertiesRemoved, + SchemaCombinatorChanged, + SchemaFormatChanged, + SchemaTypeChanged, + StringLengthChanged, +} from './schema.js'; +import { + SecurityRequirementAdded, + SecuritySchemeChanged, + SecuritySchemeRemoved, + SecurityScopesAdded, +} from './security.js'; +import { ServerRemoved } from './server.js'; -export { async3Rules, oas3Rules }; +/** + * Every rule over a `Schema` node, shared by both registries: an AsyncAPI payload is the same + * node type, judged by the same questions. + */ +const schemaRules = { + 'schema-type-changed': SchemaTypeChanged, + 'enum-values-removed': EnumValuesRemoved, + 'enum-values-added': EnumValuesAdded, + 'required-properties-added': RequiredPropertiesAdded, + 'required-properties-removed': RequiredPropertiesRemoved, + 'property-removed-from-response': PropertyRemovedFromResponse, + 'numeric-range-changed': NumericRangeChanged, + 'string-length-changed': StringLengthChanged, + 'schema-format-changed': SchemaFormatChanged, + 'additional-properties-changed': AdditionalPropertiesChanged, +} satisfies Record; + +export const oas3Rules = { + 'operation-removed': OperationRemoved, + 'path-removed': PathRemoved, + 'parameter-removed': ParameterRemoved, + 'parameter-added-required': ParameterAddedRequired, + 'parameter-became-required': ParameterBecameRequired, + 'parameter-serialization-changed': ParameterSerializationChanged, + 'request-body-removed': RequestBodyRemoved, + 'request-body-became-required': RequestBodyBecameRequired, + 'response-removed': ResponseRemoved, + 'response-header-removed': ResponseHeaderRemoved, + 'media-type-removed': MediaTypeRemoved, + 'security-requirement-added': SecurityRequirementAdded, + 'security-scopes-added': SecurityScopesAdded, + 'security-scheme-changed': SecuritySchemeChanged, + 'security-scheme-removed': SecuritySchemeRemoved, + 'ref-target-changed': RefTargetChanged, + ...schemaRules, + 'schema-combinator-changed': SchemaCombinatorChanged, +} satisfies Record; + +/** + * An AsyncAPI 3 payload is a `Schema` node of the same shape the OpenAPI rules already judge, + * so the whole schema rule set is reused. The combinator rule is not: the shared JSON Schema + * type tree calls every combinator list `SchemaList`, so the keywords cannot be told apart. + */ +export const async3Rules = { + 'channel-removed': ChannelRemoved, + 'channel-address-changed': ChannelAddressChanged, + 'message-removed': MessageRemoved, + 'message-content-type-changed': MessageContentTypeChanged, + 'operation-removed': OperationRemoved, + 'operation-action-changed': OperationActionChanged, + 'server-removed': ServerRemoved, + 'ref-target-changed': RefTargetChanged, + ...schemaRules, +} satisfies Record; export type Oas3DiffRuleId = keyof typeof oas3Rules; export type Async3DiffRuleId = keyof typeof async3Rules; diff --git a/packages/core/src/diff/rules/oas3.ts b/packages/core/src/diff/rules/oas3.ts deleted file mode 100644 index 6801b35e03..0000000000 --- a/packages/core/src/diff/rules/oas3.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { DiffRule } from '../types.js'; -import { OperationRemoved, PathRemoved } from './operation.js'; -import { - ParameterAddedRequired, - ParameterBecameRequired, - ParameterRemoved, - ParameterSerializationChanged, -} from './parameter.js'; -import { RefTargetChanged } from './ref.js'; -import { RequestBodyBecameRequired, RequestBodyRemoved } from './request-body.js'; -import { MediaTypeRemoved, ResponseHeaderRemoved, ResponseRemoved } from './response.js'; -import { schemaRules } from './schema.js'; -import { - SecurityRequirementAdded, - SecuritySchemeChanged, - SecuritySchemeRemoved, - SecurityScopesAdded, -} from './security.js'; - -export const oas3Rules = { - 'operation-removed': OperationRemoved, - 'path-removed': PathRemoved, - 'parameter-removed': ParameterRemoved, - 'parameter-added-required': ParameterAddedRequired, - 'parameter-became-required': ParameterBecameRequired, - 'parameter-serialization-changed': ParameterSerializationChanged, - 'request-body-removed': RequestBodyRemoved, - 'request-body-became-required': RequestBodyBecameRequired, - 'response-removed': ResponseRemoved, - 'response-header-removed': ResponseHeaderRemoved, - 'media-type-removed': MediaTypeRemoved, - 'security-requirement-added': SecurityRequirementAdded, - 'security-scopes-added': SecurityScopesAdded, - 'security-scheme-changed': SecuritySchemeChanged, - 'security-scheme-removed': SecuritySchemeRemoved, - 'ref-target-changed': RefTargetChanged, - ...schemaRules, -} satisfies Record; diff --git a/packages/core/src/diff/rules/parameter.ts b/packages/core/src/diff/rules/parameter.ts index f99eb81e30..2e903d1d5e 100644 --- a/packages/core/src/diff/rules/parameter.ts +++ b/packages/core/src/diff/rules/parameter.ts @@ -1,6 +1,6 @@ import { isPlainObject } from '../../utils/is-plain-object.js'; -import { becameTrue } from '../constraints.js'; import type { DiffRule } from '../types.js'; +import { becameTrue } from './constraints.js'; // 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. diff --git a/packages/core/src/diff/rules/request-body.ts b/packages/core/src/diff/rules/request-body.ts index da91b8d856..674007680e 100644 --- a/packages/core/src/diff/rules/request-body.ts +++ b/packages/core/src/diff/rules/request-body.ts @@ -1,5 +1,5 @@ -import { becameTrue } from '../constraints.js'; import type { DiffRule } from '../types.js'; +import { becameTrue } from './constraints.js'; export const RequestBodyBecameRequired: DiffRule = () => ({ RequestBody(change, { report, direction }) { diff --git a/packages/core/src/diff/rules/schema.ts b/packages/core/src/diff/rules/schema.ts index fe852fae22..1e8e95d366 100644 --- a/packages/core/src/diff/rules/schema.ts +++ b/packages/core/src/diff/rules/schema.ts @@ -1,3 +1,5 @@ +import { fieldOf } from '../../node-map/access.js'; +import type { DiffRule, DiffVisit, Direction } from '../types.js'; import { addedItems, constraintDirection, @@ -6,8 +8,7 @@ import { isTypeSetWidened, missingItems, type ConstraintDirection, -} from '../constraints.js'; -import type { DiffRule, Direction } from '../types.js'; +} from './constraints.js'; /** * A tightening rejects input the API used to accept, so it breaks a request; a @@ -22,12 +23,12 @@ function breaks(moved: ConstraintDirection, direction: Direction): boolean { } export const SchemaTypeChanged: DiffRule = () => ({ - Schema(change, { report, direction, base, revision }) { + Schema(change, { report, direction }) { if (change.kind !== 'modified' || 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, base(change.key)?.properties.nullable); - const after = effectiveTypes(change.revision.value, revision(change.key)?.properties.nullable); + const before = effectiveTypes(change.base.value, fieldOf(change.pair.base, 'nullable')); + const after = effectiveTypes(change.revision.value, fieldOf(change.pair.revision, 'nullable')); const described = `from '${[...before].join(' | ')}' to '${[...after].join(' | ')}'`; if (direction === 'request' && isTypeSetNarrowed(before, after)) { @@ -75,13 +76,14 @@ export const RequiredPropertiesRemoved: DiffRule = () => ({ }, }); +// A member of a `properties` map; an alternative of a `oneOf` is a schema too, but not a property. export const PropertyRemovedFromResponse: DiffRule = () => ({ - Schema(change, { report, direction, nodeAt }) { - if (change.kind !== 'removed' || direction !== 'response') return; - // Only a member of a `properties` map counts; a subschema of `oneOf` does not. - const parentKey = nodeAt(change.key)?.parentKey; - if (!parentKey || nodeAt(parentKey)?.typeName !== 'SchemaProperties') return; - report({ message: 'Schema property was removed.' }); + SchemaProperties: { + Schema(change, { report, direction }) { + if (change.kind === 'removed' && direction === 'response') { + report({ message: 'Schema property was removed.' }); + } + }, }, }); @@ -122,45 +124,22 @@ export const SchemaFormatChanged = constraintRule(['format']); export const AdditionalPropertiesChanged = constraintRule(['additionalProperties']); // `oneOf`/`anyOf` list alternatives, so dropping one accepts less; `allOf` combines -// constraints, so adding one accepts less. The type tree names each list after its keyword, -// which is what tells a combinator apart from any other list of schemas. -const COMBINATOR_KEYWORDS: Record = { - AllOf: 'allOf', - AnyOf: 'anyOf', - OneOf: 'oneOf', -}; - -export const SchemaCombinatorChanged: DiffRule = () => ({ - Schema(change, { report, direction, nodeAt }) { +// constraints, so adding one accepts less. Only the OpenAPI type tree names each list after +// its keyword; the shared JSON Schema tree calls them all `SchemaList`, so there the rule +// could not tell the keywords apart and is not registered. +function combinatorChanged(combinator: 'allOf' | 'anyOf' | 'oneOf'): DiffVisit { + return (change, { report, direction }) => { if (change.kind === 'modified') return; - - const parentKey = nodeAt(change.key)?.parentKey; - const parent = parentKey ? nodeAt(parentKey) : undefined; - const combinator = parent && COMBINATOR_KEYWORDS[parent.typeName]; - if (!combinator) return; - const removed = change.kind === 'removed'; const acceptsLess = combinator === 'allOf' ? !removed : removed; if (breaks(acceptsLess ? 'tighter' : 'looser', direction)) { report({ message: `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 = { - 'schema-type-changed': SchemaTypeChanged, - 'enum-values-removed': EnumValuesRemoved, - 'enum-values-added': EnumValuesAdded, - 'required-properties-added': RequiredPropertiesAdded, - 'required-properties-removed': RequiredPropertiesRemoved, - 'property-removed-from-response': PropertyRemovedFromResponse, - 'numeric-range-changed': NumericRangeChanged, - 'string-length-changed': StringLengthChanged, - 'schema-format-changed': SchemaFormatChanged, - 'additional-properties-changed': AdditionalPropertiesChanged, - 'schema-combinator-changed': SchemaCombinatorChanged, -} satisfies Record; +export const SchemaCombinatorChanged: DiffRule = () => ({ + AllOf: { Schema: combinatorChanged('allOf') }, + AnyOf: { Schema: combinatorChanged('anyOf') }, + OneOf: { Schema: combinatorChanged('oneOf') }, +}); diff --git a/packages/core/src/diff/rules/security.ts b/packages/core/src/diff/rules/security.ts index 8396527136..b4b750c33f 100644 --- a/packages/core/src/diff/rules/security.ts +++ b/packages/core/src/diff/rules/security.ts @@ -1,30 +1,27 @@ -import { addedItems } from '../constraints.js'; -import type { Change, DiffRule, DiffRuleContext } from '../types.js'; +import { fieldOf } from '../../node-map/access.js'; +import type { DiffRule } from '../types.js'; +import { addedItems } from './constraints.js'; // Security sits outside the request/response split, so these rules do not read // the direction: 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. + * A `security` list that appears where there was none lands on the list. `security: []` + * states that no authentication is needed, so a first entry filling that list lands on the + * entry; one more entry in a list that already had one only offers another way to + * authenticate, which no existing client has to follow. */ -function fillsAnEmptyList(change: Change, { nodeAt, base }: DiffRuleContext): boolean { - const parentKey = nodeAt(change.key)?.parentKey; - const baseList = parentKey ? base(parentKey)?.raw : undefined; - return Array.isArray(baseList) && baseList.length === 0; -} - -// 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 = () => ({ - SecurityRequirementList(change, { report }) { - if (change.kind === 'added') report({ message: 'The API now requires authentication.' }); - }, - SecurityRequirement(change, context) { - if (change.kind === 'added' && fillsAnEmptyList(change, context)) { - context.report({ message: 'The API now requires authentication.' }); - } + SecurityRequirementList: { + enter(change, { report }) { + if (change.kind === 'added') report({ message: 'The API now requires authentication.' }); + }, + SecurityRequirement(change, { report }) { + const list = change.pair.parent?.base?.value; + if (change.kind === 'added' && Array.isArray(list) && list.length === 0) { + report({ message: 'The API now requires authentication.' }); + } + }, }, }); @@ -38,12 +35,12 @@ const SCHEME_IDENTITY = new Set([ ]); export const SecuritySchemeChanged: DiffRule = () => ({ - SecurityScheme(change, { report, base, revision }) { + SecurityScheme(change, { report }) { if (change.kind !== 'modified' || !SCHEME_IDENTITY.has(change.property)) return; // Switching the scheme's `type` drags its other fields along (an apiKey has // `in`/`name`, a bearer has `scheme`), so the type change speaks for them all. - const typeChanged = base(change.key)?.properties.type !== revision(change.key)?.properties.type; + const typeChanged = fieldOf(change.pair.base, 'type') !== fieldOf(change.pair.revision, 'type'); if (typeChanged && change.property !== 'type') return; report({ diff --git a/packages/core/src/diff/specs/async3.ts b/packages/core/src/diff/specs/async3.ts new file mode 100644 index 0000000000..469f2905cc --- /dev/null +++ b/packages/core/src/diff/specs/async3.ts @@ -0,0 +1,40 @@ +import { enclosing, fieldOf } from '../../node-map/access.js'; +import type { NodeEntry } from '../../node-map/types.js'; +import type { DiffSpec, Direction } from '../types.js'; +import { opposite } from './direction.js'; + +/** + * `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 it is judged as a response. + */ +function actionDirection(action: unknown): Direction { + if (action === 'receive') return 'request'; + if (action === 'send') return 'response'; + return 'neutral'; +} + +function operationDirection(node: NodeEntry): Direction { + const operation = enclosing(node, 'Operation'); + if (!operation) return 'neutral'; + + const direction = actionDirection(fieldOf(operation, 'action')); + // A reply answers the operation, so it travels back the other way. + return enclosing(node, 'OperationReply') ? opposite(direction) : direction; +} + +export const async3Spec: DiffSpec = { + // Channels, messages and operations are named maps, so the node's own key is its identity. + identityOf: () => undefined, + // Channels and their messages sit outside the operations, so a change deep inside a payload + // is answered by the nearest ancestor an operation references. + directionOf: (node, fromUsage) => { + const own = operationDirection(node); + if (own !== 'neutral') return own; + + for (let current: NodeEntry | null = node; current; current = current.parent) { + const direction = fromUsage(current); + if (direction !== 'neutral') return direction; + } + return 'neutral'; + }, +}; diff --git a/packages/core/src/diff/specs/direction.ts b/packages/core/src/diff/specs/direction.ts new file mode 100644 index 0000000000..d330f7ab4a --- /dev/null +++ b/packages/core/src/diff/specs/direction.ts @@ -0,0 +1,14 @@ +import type { Direction } from '../types.js'; + +export function mergeDirections(a: Direction, b: Direction): Direction { + if (a === b) return a; + if (a === 'neutral') return b; + if (b === 'neutral') return a; + return 'both'; +} + +export function opposite(direction: Direction): Direction { + if (direction === 'request') return 'response'; + if (direction === 'response') return 'request'; + return direction; +} diff --git a/packages/core/src/diff/specs/index.ts b/packages/core/src/diff/specs/index.ts new file mode 100644 index 0000000000..39b98ecf7f --- /dev/null +++ b/packages/core/src/diff/specs/index.ts @@ -0,0 +1,16 @@ +import type { SpecMajorVersion } from '../../oas-types.js'; +import type { DiffSpec } from '../types.js'; +import { async3Spec } from './async3.js'; +import { oas3Spec } from './oas3.js'; + +/** A document of a family without its own spec is still compared, just never judged. */ +export const structuralSpec: DiffSpec = { + identityOf: () => undefined, + directionOf: () => 'neutral', +}; + +/** Only these families have diff rules. */ +export const diffSpecs: Partial> = { + oas3: oas3Spec, + async3: async3Spec, +}; diff --git a/packages/core/src/diff/specs/oas3.ts b/packages/core/src/diff/specs/oas3.ts new file mode 100644 index 0000000000..dc4e292bed --- /dev/null +++ b/packages/core/src/diff/specs/oas3.ts @@ -0,0 +1,109 @@ +import type { NodeEntry } from '../../node-map/types.js'; +import { escapePointerFragment } from '../../ref-utils.js'; +import { isPlainObject } from '../../utils/is-plain-object.js'; +import { templateParameterNames, templateShape } from '../../utils/path-template.js'; +import type { DiffSpec, Direction, NodeIdentity } from '../types.js'; +import { opposite } from './direction.js'; + +/** + * Where the specification gives a node an identity of its own, the segment says it instead of + * the position or the name the document happens to use. + */ +const identityByContainer: Record NodeIdentity | undefined> = { + // "Templated paths with the same hierarchy but different templated names MUST NOT exist + // as they are identical" (OpenAPI, Paths Object) — the shape is the identity, the names are not. + Paths: (node) => ({ + segment: escapePointerFragment(templateShape(String(node.key))), + values: { path: node.key }, + }), + ParameterList: parameterIdentity, + ServerList: (node) => named(stringField(node, 'url')), + TagList: (node) => named(stringField(node, 'name')), + SecurityRequirementList: (node) => + isPlainObject(node.value) + ? { segment: `{${Object.keys(node.value).sort().map(escapePointerFragment).join('+')}}` } + : undefined, +}; + +function parameterIdentity(node: NodeEntry): NodeIdentity | undefined { + const location = stringField(node, 'in'); + const name = stringField(node, 'name'); + if (location === undefined || name === undefined) return undefined; + return location === 'path' + ? { segment: pathParameterSegment(name, node) } + : { segment: `{${escapePointerFragment(location)}:${escapePointerFragment(name)}}` }; +} + +/** + * "If `in` is 'path', the `name` field MUST correspond to a template expression occurring + * within the path" (OpenAPI, Parameter Object) — so the position of that expression is the + * identity. A name the template does not contain (an invalid document, or a callback whose + * key is a runtime expression) falls back to the name. + */ +function pathParameterSegment(name: string, node: NodeEntry): string { + const position = templateParameterNames(pathTemplateOf(node)).indexOf(name); + return position === -1 ? `{path:${escapePointerFragment(name)}}` : `{path:${position}}`; +} + +function pathTemplateOf(node: NodeEntry): string { + for (let current = node.parent; current; current = current.parent) { + if (current.type === 'PathItem') return String(current.key); + } + return ''; +} + +/** + * The reusable component a node belongs to. 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. + */ +function componentRoot(node: NodeEntry): NodeEntry | undefined { + for (let current: NodeEntry | null = node; current; current = current.parent) { + if (current.parent?.parent?.type === 'Components') return current; + } + return undefined; +} + +// 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. Only +// the containing map is listed, never the entry inside it — counting both would flip twice. +const INVERTING_TYPES = new Set(['CallbacksMap', 'WebhooksMap']); + +// Walking out to the root, the outermost answer wins and every inversion above it applies. +function positionDirection(node: NodeEntry): Direction { + let direction: Direction = 'neutral'; + + for (let current: NodeEntry | null = node; current; current = current.parent) { + if (RESPONSE_TYPES.has(current.type)) direction = 'response'; + else if (REQUEST_TYPES.has(current.type)) direction = 'request'; + else if (INVERTING_TYPES.has(current.type)) direction = opposite(direction); + } + + return direction; +} + +export const oas3Spec: DiffSpec = { + identityOf: (node, container) => identityByContainer[container]?.(node), + referenceTarget: componentRoot, + // A component is compared at its own place, so its direction comes from the sites that + // reference it rather than from its own position. + directionOf: (node, fromUsage) => { + const component = componentRoot(node); + return component ? fromUsage(component) : positionDirection(node); + }, +}; + +function stringField(node: NodeEntry, name: string): string | undefined { + const value = isPlainObject(node.value) ? node.value[name] : undefined; + return typeof value === 'string' ? value : undefined; +} + +function named(value: string | undefined): NodeIdentity | undefined { + return value === undefined ? undefined : { segment: `{${escapePointerFragment(value)}}` }; +} diff --git a/packages/core/src/diff/types.ts b/packages/core/src/diff/types.ts index 4d451b211d..539b84e350 100644 --- a/packages/core/src/diff/types.ts +++ b/packages/core/src/diff/types.ts @@ -2,20 +2,35 @@ import type { NodeEntry } from '../node-map/types.js'; import type { SpecVersion } from '../oas-types.js'; import type { Location } from '../ref-utils.js'; -/** Lowest first. An impact is the semver part a change requires to be bumped. */ -export const impacts = ['patch', 'minor', 'major'] as const; -export type Impact = (typeof impacts)[number]; +export type Impact = 'patch' | 'minor' | 'major'; -export function impactRank(impact: Impact): number { - return impacts.indexOf(impact); +/** Which way the data in a node travels; decides whether "accepts less" or "returns more" is the breaking side. */ +export type Direction = 'request' | 'response' | 'both' | 'neutral'; + +/** The same logical node on both sides; one side is missing when the node was added or removed. */ +export interface Pair { + base?: NodeEntry; + revision?: NodeEntry; + parent: Pair | null; + children: Pair[]; + /** Position among the siblings that share this pair's segment; the label carries it as `#n`. */ + occurrence: number; +} + +/** What the specification says about a node that the structure alone does not. */ +export interface NodeIdentity { + /** Replaces the node's own key when the two documents are matched up. */ + segment: string; + /** Values the segment hides, compared as if the node carried them. */ + values?: Record; } -export function highestImpact(candidates: Impact[]): Impact | undefined { - return candidates.reduce( - (highest, impact) => - highest === undefined || impactRank(impact) > impactRank(highest) ? impact : highest, - undefined - ); +/** Everything the comparison needs to know about one specification family. */ +export interface DiffSpec { + identityOf(node: NodeEntry, container: string): NodeIdentity | undefined; + /** The node a reference to this one lands on, when it is not the node itself. */ + referenceTarget?: (node: NodeEntry) => NodeEntry | undefined; + directionOf(node: NodeEntry, fromUsage: (node: NodeEntry) => Direction): Direction; } export interface LocatedNode { @@ -24,8 +39,9 @@ export interface LocatedNode { } interface ChangeBase { + /** The report key, derived from the pair; the pair itself is what a rule reads. */ key: string; - typeName: string; + pair: Pair; } export type Change = @@ -38,13 +54,6 @@ export type Change = revision: LocatedNode; }); -export function displaySide(change: Change): LocatedNode { - return change.kind === 'removed' ? change.base : change.revision; -} - -/** Which way the data in a node travels; decides whether "accepts less" or "returns more" is the breaking side. */ -export type Direction = 'request' | 'response' | 'both' | 'neutral'; - export interface DiffReport { message: string; /** Defaults to the change's display side; a rule may point at a finer node. */ @@ -55,15 +64,30 @@ export interface DiffRuleContext { report: (report: DiffReport) => void; direction: Direction; // never 'both': a node used both ways is visited once per direction specVersion: SpecVersion; - base: (key: string) => NodeEntry | undefined; - revision: (key: string) => NodeEntry | undefined; - nodeAt: (key: string) => NodeEntry | undefined; // either side, revision first } export type DiffVisit = (change: Change, context: DiffRuleContext) => void; -export type DiffVisitor = Record; // keyed by node type name; `any` runs for every change + +/** + * Keyed by node type name, nested the way lint visitors are: `SchemaProperties: { Schema }` + * runs for a schema that is a member of a properties map. `enter` inside a nested object is + * the handler of the container itself; `any` runs for every change. + */ +export type DiffVisitor = { [type: string]: DiffVisit | DiffVisitor }; export type DiffRule = () => DiffVisitor; +/** One handler of a rule with the type path it is nested under; `any` has the empty path. */ +export interface RuleHandler { + path: string[]; + visit: DiffVisit; +} + +export interface InitializedDiffRule { + id: string; + impact: Impact; + handlers: RuleHandler[]; +} + export interface RuleVerdict { ruleId: string; impact: Impact; diff --git a/packages/core/src/diff/usage.ts b/packages/core/src/diff/usage.ts index 9373887eba..36f7787b26 100644 --- a/packages/core/src/diff/usage.ts +++ b/packages/core/src/diff/usage.ts @@ -1,45 +1,43 @@ -import { getComponentRoot, type NodeLookup } from '../node-map/chain.js'; -import type { Direction } from './types.js'; +import type { NodeEntry, Reference } from '../node-map/types.js'; +import { mergeDirections } from './specs/direction.js'; +import type { DiffSpec, Direction, Pair } from './types.js'; -export function mergeDirections(a: Direction, b: Direction): Direction { - if (a === b) return a; - if (a === 'neutral') return b; - if (b === 'neutral') return a; - return 'both'; -} +/** + * The direction every referenced node gets from the sites that reference it, over both + * documents at once so a shared component keeps one direction. A site inside another + * referenced node borrows that node's direction, which the repeated passes settle: merging + * only ever widens a direction, so the answers stop changing after a few rounds, and a + * reference cycle simply contributes nothing. + */ +export function usageDirections( + references: Reference[], + pairOf: Map, + spec: DiffSpec +): (node: NodeEntry) => Direction { + const directions = new Map(); + const fromUsage = (node: NodeEntry): Direction => { + const pair = pairOf.get(node); + return (pair && directions.get(pair)) ?? 'neutral'; + }; -export class UsageIndex { - private sitesByTarget = new Map>(); + const edges = references.flatMap(({ from, to }) => { + const target = pairOf.get(spec.referenceTarget?.(to) ?? to); + return target ? [{ from, target }] : []; + }); - 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); + for (let settled = false; !settled; ) { + settled = true; + for (const { from, target } of edges) { + const merged = mergeDirections( + directions.get(target) ?? 'neutral', + spec.directionOf(from, fromUsage) + ); + if (merged !== directions.get(target)) { + directions.set(target, merged); + settled = false; + } } } - /** `resolveSiteDirection` receives the key of the node that holds the reference. */ - directionOf(componentKey: string, resolveSiteDirection: (site: string) => Direction): Direction { - const seen = new Set(); - const visit = (key: string): Direction => { - if (seen.has(key)) return 'neutral'; // cycle guard - seen.add(key); - let result: Direction = 'neutral'; - for (const site of this.sitesByTarget.get(key) ?? []) { - // a ref site inside another component chains to that component's own usage - const siteComponentRoot = getComponentRoot(site, this.lookup); - const siteDirection = siteComponentRoot - ? visit(siteComponentRoot) - : resolveSiteDirection(site); - result = mergeDirections(result, siteDirection); - if (result === 'both') return 'both'; - } - return result; - }; - return visit(getComponentRoot(componentKey, this.lookup) ?? componentKey); - } + return fromUsage; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b9348cf8ef..5653fd023e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,13 +61,14 @@ export { YamlParseError } from './errors/yaml-parse-error.js'; export { parseYaml, stringifyYaml } from './js-yaml/index.js'; export { unescapePointerFragment, + parsePointer, isRef, isMappingRef, isAbsoluteUrl, getDir, resolvePath, escapePointerFragment, - type Location, + Location, } from './ref-utils.js'; export { detectSpec, getMajorSpecVersion } from './detect-spec.js'; export { getTypes, type SpecVersion, type SpecMajorVersion } from './oas-types.js'; @@ -131,24 +132,18 @@ export { type Assertions, type Assertion } from './rules/common/assertions/index export { logger, type LoggerInterface } from './logger.js'; export { diffDocuments, DiffError } from './diff/index.js'; export { - impactRank, - impacts, type Change, type DiffResult, type DiffSummary, + type Direction, type Impact, type JudgedChange, + type LocatedNode, + type Pair, } from './diff/types.js'; -export { diffReportFormats, type DiffReportFormat } from './diff/format/index.js'; -export { diffToProblems } from './diff/format/problems.js'; -export { - bumpSemver, - formatSemver, - parseSemver, - semverBumpBetween, - type Semver, - type SemverPart, -} from './utils/semver.js'; +export { impactRank, impacts } from './diff/impact.js'; +export { displaySide } from './diff/changes.js'; +export { typeOf } from './diff/pairs.js'; export { HandledError, AbortFlowError } from './utils/error.js'; export { isSupportedExtension } from './utils/is-supported-extension.js'; export { isBrowser } from './env.js'; diff --git a/packages/core/src/node-map/__tests__/collect.test.ts b/packages/core/src/node-map/__tests__/collect.test.ts index 6a8256bc77..8ef04d7042 100644 --- a/packages/core/src/node-map/__tests__/collect.test.ts +++ b/packages/core/src/node-map/__tests__/collect.test.ts @@ -5,24 +5,20 @@ import { detectSpec } from '../../detect-spec.js'; import { getTypes } from '../../oas-types.js'; import { makeDocumentFromString } from '../../resolve.js'; import { normalizeTypes } from '../../types/index.js'; -import { collectNodeMap } from '../collect.js'; -import type { IdentityFn, NodeEntry } from '../types.js'; +import { collectNodes } from '../collect.js'; +import type { NodeEntry } from '../types.js'; -const noIdentity: IdentityFn = () => undefined; - -async function collect(yaml: string, identityOf: IdentityFn = noIdentity) { +async function collect(yaml: string) { const document = makeDocumentFromString(yaml, 'api.yaml'); const config = await createConfig({}); const specVersion = detectSpec(document.parsed); const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - return collectNodeMap({ document, types, specVersion, identityOf }); + return collectNodes({ document, types, specVersion }); } -/** `key (real pointer) TypeName` per node — the map the comparison runs on. */ -function tree(entries: Map): string { - return [...entries.values()] - .map((entry) => `${entry.key} (${entry.location.pointer}) ${entry.typeName}`) - .join('\n'); +/** `pointer key Type` per node — the map every reader starts from. */ +function tree(nodes: Map): string { + return [...nodes].map(([pointer, node]) => `${pointer} ${node.key} ${node.type}`).join('\n'); } const PETSTORE = outdent` @@ -47,86 +43,74 @@ const PETSTORE = outdent` id: { type: string } `; -describe('collectNodeMap', () => { - it('keys every object and array node hierarchically and keeps the walker location on it', async () => { - const { entries } = await collect(PETSTORE); +describe('collectNodes', () => { + it('records every object and array node under its pointer, with the walker location and key', async () => { + const { nodes } = await collect(PETSTORE); - 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/0 (#/paths/~1pets/get/parameters/0) Parameter - #/paths/~1pets/get/parameters/0/schema (#/paths/~1pets/get/parameters/0/schema) Schema - #/paths/~1pets/get/responses (#/paths/~1pets/get/responses) Responses - #/paths/~1pets/get/responses/200 (#/paths/~1pets/get/responses/200) Response - #/paths/~1pets/get/responses/200/content (#/paths/~1pets/get/responses/200/content) MediaTypesMap - #/paths/~1pets/get/responses/200/content/application~1json (#/paths/~1pets/get/responses/200/content/application~1json) MediaType - #/components (#/components) Components - #/components/schemas (#/components/schemas) NamedSchemas - #/components/schemas/Pet (#/components/schemas/Pet) Schema - #/components/schemas/Pet/properties (#/components/schemas/Pet/properties) SchemaProperties - #/components/schemas/Pet/properties/id (#/components/schemas/Pet/properties/id) Schema" + expect(tree(nodes)).toMatchInlineSnapshot(` + "#/ Root + #/info info Info + #/paths paths Paths + #/paths/~1pets /pets PathItem + #/paths/~1pets/get get Operation + #/paths/~1pets/get/parameters parameters ParameterList + #/paths/~1pets/get/parameters/0 0 Parameter + #/paths/~1pets/get/parameters/0/schema schema Schema + #/paths/~1pets/get/responses responses Responses + #/paths/~1pets/get/responses/200 200 Response + #/paths/~1pets/get/responses/200/content content MediaTypesMap + #/paths/~1pets/get/responses/200/content/application~1json application/json MediaType + #/components components Components + #/components/schemas schemas NamedSchemas + #/components/schemas/Pet Pet Schema + #/components/schemas/Pet/properties properties SchemaProperties + #/components/schemas/Pet/properties/id id Schema" `); - expect(entries.get('#/info')?.location.source.absoluteRef).toBe('api.yaml'); - expect(entries.get('#/info')?.parentKey).toBe('#/'); + expect(nodes.get('#/info')?.location.source.absoluteRef).toBe('api.yaml'); + expect(nodes.get('#/info')?.parent).toBe(nodes.get('#/')); + expect(nodes.get('#/paths/~1pets/get/parameters/0')?.parent).toBe( + nodes.get('#/paths/~1pets/get/parameters') + ); + expect(nodes.get('#/paths')?.children).toEqual([nodes.get('#/paths/~1pets')]); }); - it('keeps scalars, scalar arrays and $refs as properties and leaves nested nodes out', async () => { - const { entries, usageEdges } = await collect(PETSTORE); + it('keeps the node itself as the value, children included', async () => { + const { nodes } = await collect(PETSTORE); - const parameter = entries.get('#/paths/~1pets/get/parameters/0'); - expect(parameter?.properties).toEqual({ name: 'limit', in: 'query' }); - expect(parameter?.raw).toEqual({ + expect(nodes.get('#/paths/~1pets/get/parameters/0')?.value).toEqual({ name: 'limit', in: 'query', schema: { type: 'integer', maximum: 100 }, }); - expect(entries.get('#/components/schemas/Pet')?.properties).toEqual({ - type: 'object', - required: ['id'], - }); - const mediaType = entries.get('#/paths/~1pets/get/responses/200/content/application~1json'); - expect(mediaType?.properties).toEqual({ schema: { $ref: '#/components/schemas/Pet' } }); - expect(usageEdges).toEqual([ - { - site: '#/paths/~1pets/get/responses/200/content/application~1json', - target: '#/components/schemas/Pet', - }, - ]); + expect(nodes.get('#/paths/~1pets/get/parameters')?.value).toHaveLength(1); }); +}); - it('lets the identity replace the walker key, add properties, and suffixes a duplicate identity', async () => { - const byName: IdentityFn = (node, { typeName }) => - typeName === 'Parameter' && typeof node.name === 'string' - ? { segment: `{${node.name}}`, properties: { identified: true } } - : undefined; - const { entries } = await collect( - outdent` - openapi: 3.1.0 - info: { title: T, version: '1' } - paths: - /p: - get: - parameters: - - { name: a, in: query } - - { name: a, in: header } - responses: {} - `, - byName - ); +describe('collectNodes references', () => { + it('resolves a $ref to the node it points at, wherever the $ref is written', async () => { + const { references } = await collect(outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + paths: + /p: + get: + parameters: + - $ref: '#/components/parameters/Limit' + responses: + '200': + content: + application/json: + schema: { $ref: '#/components/schemas/Pet' } + components: + parameters: + Limit: { name: limit, in: query } + schemas: + Pet: { type: object } + `); - const keys = [...entries.keys()].filter((key) => key.includes('parameters/')); - expect(keys).toEqual(['#/paths/~1p/get/parameters/{a}', '#/paths/~1p/get/parameters/{a}#2']); - expect(entries.get('#/paths/~1p/get/parameters/{a}')?.properties).toEqual({ - name: 'a', - in: 'query', - identified: true, - }); - expect(entries.get('#/paths/~1p/get/parameters/{a}#2')?.location.pointer).toBe( - '#/paths/~1p/get/parameters/1' - ); + expect(references.map(({ from, to }) => [from.location.pointer, to.location.pointer])).toEqual([ + ['#/paths/~1p/get/parameters', '#/components/parameters/Limit'], + ['#/paths/~1p/get/responses/200/content/application~1json', '#/components/schemas/Pet'], + ]); }); }); diff --git a/packages/core/src/node-map/access.ts b/packages/core/src/node-map/access.ts new file mode 100644 index 0000000000..bb57f91b9d --- /dev/null +++ b/packages/core/src/node-map/access.ts @@ -0,0 +1,14 @@ +import { isPlainObject } from '../utils/is-plain-object.js'; +import type { NodeEntry } from './types.js'; + +/** The nearest node of that type, the node itself included. */ +export function enclosing(node: NodeEntry | null, type: string): NodeEntry | undefined { + for (let current = node; current; current = current.parent) { + if (current.type === type) return current; + } + return undefined; +} + +export function fieldOf(node: NodeEntry | undefined, name: string): unknown { + return node && isPlainObject(node.value) ? node.value[name] : undefined; +} diff --git a/packages/core/src/node-map/chain.ts b/packages/core/src/node-map/chain.ts deleted file mode 100644 index 99c97bc986..0000000000 --- a/packages/core/src/node-map/chain.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 = (key: string) => NodeEntry | undefined; - -/** The node's ancestors and itself, root first, as far as the map can resolve them. */ -export function ancestorChain(key: string, lookup: NodeLookup): NodeEntry[] { - const chain: NodeEntry[] = []; - const seen = new Set(); - - for (let current: string | null = key; current && !seen.has(current); ) { - seen.add(current); - const entry = lookup(current); - if (!entry) break; - chain.unshift(entry); - current = entry.parentKey; - } - - return chain; -} - -/** - * The key 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(key: string, lookup: NodeLookup): string | undefined { - const chain = ancestorChain(key, lookup); - const componentsIndex = chain.findIndex((entry) => entry.typeName === 'Components'); - return componentsIndex === -1 ? undefined : chain[componentsIndex + 2]?.key; -} diff --git a/packages/core/src/node-map/collect.ts b/packages/core/src/node-map/collect.ts index 5ebf472b78..fa7aa71e3d 100644 --- a/packages/core/src/node-map/collect.ts +++ b/packages/core/src/node-map/collect.ts @@ -1,106 +1,71 @@ import type { SpecVersion } from '../oas-types.js'; -import { isRef } from '../ref-utils.js'; import type { Document } from '../resolve.js'; import type { NormalizedNodeType } from '../types/index.js'; import { isPlainObject } from '../utils/is-plain-object.js'; -import { isScalar, isScalarArray } from '../utils/is-scalar.js'; import { normalizeVisitors } from '../visitors.js'; import { walkDocument, type UserContext, type WalkContext } from '../walk.js'; -import { ancestorChain } from './chain.js'; -import type { IdentityFn, NodeEntry, NodeMap, UsageEdge } from './types.js'; +import type { NodeEntry, Reference } from './types.js'; -export function collectNodeMap(opts: { +export interface DocumentNodes { + root: NodeEntry; + nodes: Map; + references: Reference[]; +} + +export function collectNodes(opts: { document: Document; types: Record; specVersion: SpecVersion; - identityOf: IdentityFn; -}): NodeMap { - const { document, types, specVersion, identityOf } = opts; - const entries = new Map(); - const usageEdges: UsageEdge[] = []; - // real pointer → key, filled top-down (the walk is pre-order) - const keyByPointer = new Map(); - const collisionCounts = new Map(); +}): DocumentNodes { + const { document, types, specVersion } = opts; + const nodes = new Map(); + const refSites: Array<{ from: NodeEntry; to: string }> = []; const visitor = { - any: { - enter(node: unknown, ctx: UserContext) { - if (!isPlainObject(node) && !Array.isArray(node)) return; - - const { parentPointer, segment } = splitPointer(ctx.location.pointer); - const parentKey = - parentPointer === null ? null : (keyByPointer.get(parentPointer) ?? parentPointer); - const ancestors = - parentKey === null ? [] : ancestorChain(parentKey, (key) => entries.get(key)); - const identity = isPlainObject(node) - ? identityOf(node, { - typeName: ctx.type.name, - key: ctx.key, - parent: ctx.parent, - ancestors, - }) - : undefined; - - // The root's own key already ends in a slash, so a child of the root - // must not add a second one. - const prefix = parentKey === '#/' ? '#' : parentKey; - let key = prefix === null ? segment : `${prefix}/${identity?.segment ?? segment}`; - if (entries.has(key)) { - const occurrence = (collisionCounts.get(key) ?? 1) + 1; - collisionCounts.set(key, occurrence); - key = `${key}#${occurrence}`; - } - keyByPointer.set(ctx.location.pointer, key); - - const properties: Record = {}; - if (isPlainObject(node)) { - for (const [name, value] of Object.entries(node)) { - if (isRef(value)) { - // The site is the node holding the reference: a `$ref` is not a node of its own. - usageEdges.push({ site: key, target: value.$ref }); - } - if (isRef(value) || isScalar(value) || isScalarArray(value)) { - properties[name] = value; - } - } - } - - entries.set(key, { - key, - parentKey, - location: ctx.location, - typeName: ctx.type.name, - properties: { ...properties, ...identity?.properties }, - raw: node, - }); - }, + any(node: unknown, { location, type, key }: UserContext) { + if (!isPlainObject(node) && !Array.isArray(node)) return; + const parent = nodes.get(parentPointer(location.pointer)) ?? null; + const entry: NodeEntry = { + type: type.name, + key, + location, + value: node, + parent, + children: [], + }; + parent?.children.push(entry); + nodes.set(location.pointer, entry); + }, + ref(node: { $ref: string }, { location }: UserContext) { + // The node holding the reference has been entered already; its target may come later. + const from = nodes.get(parentPointer(location.pointer)); + if (from) refSites.push({ from, to: node.$ref }); }, }; - const normalizedVisitors = normalizeVisitors( - [{ severity: 'warn', ruleId: 'node-map', visitor }], - types - ); - const ctx: WalkContext = { problems: [], specVersion, visitorsData: {} }; + const walkContext: WalkContext = { problems: [], specVersion, visitorsData: {} }; walkDocument({ document, rootType: types.Root, - normalizedVisitors, - // Empty map: `$ref` nodes fail to resolve and are NOT traversed — a reference - // is recorded as a property of the node holding it. + normalizedVisitors: normalizeVisitors( + [{ severity: 'warn', ruleId: 'node-map', visitor }], + types + ), + // An empty map leaves every `$ref` unresolved, so each node is visited once, where it is written. resolvedRefMap: new Map(), - ctx, + ctx: walkContext, + }); + + const references = refSites.flatMap(({ from, to }) => { + const target = nodes.get(to); + return target ? [{ from, to: target }] : []; }); - return { entries, usageEdges }; + return { root: nodes.get('#/')!, nodes, references }; } -function splitPointer(pointer: string): { parentPointer: string | null; segment: string } { - if (pointer === '#/' || pointer === '#') { - return { parentPointer: null, segment: pointer }; - } +function parentPointer(pointer: string): string { const lastSlash = pointer.lastIndexOf('/'); - const parentPointer = lastSlash <= 1 ? '#/' : pointer.slice(0, lastSlash); - return { parentPointer, segment: pointer.slice(lastSlash + 1) }; + return lastSlash <= 1 ? '#/' : pointer.slice(0, lastSlash); } diff --git a/packages/core/src/node-map/types.ts b/packages/core/src/node-map/types.ts index 13d09259a7..06032c48b2 100644 --- a/packages/core/src/node-map/types.ts +++ b/packages/core/src/node-map/types.ts @@ -1,47 +1,17 @@ import type { Location } from '../ref-utils.js'; -export interface NodeEntry { - /** Stable matching key, the same for the same logical node on both sides; also the map key. */ - key: string; - parentKey: string | null; - /** Where the node really is: source and JSON Pointer, as the walker reports it. */ - location: Location; - typeName: string; - /** The node's own comparable values — scalars, scalar arrays, `$ref`s — verbatim. Nested objects are nodes of their own. */ - properties: Record; - /** The whole node, children included — the payload of an added or removed change. */ - raw: unknown; -} - -export interface UsageEdge { - /** The key of the node holding the `$ref`. */ - site: string; - /** The `$ref` string, which for a component is also its key. */ - target: string; -} +export type NodeValue = Record | unknown[]; -export interface NodeMap { - entries: Map; - usageEdges: UsageEdge[]; -} - -export interface IdentityContext { - typeName: string; - /** The walker's own key: an index in a list, a property name in a map. */ +export interface NodeEntry { + type: string; key: string | number; - parent: unknown; - /** The ancestors already collected, root first — the walk is pre-order. */ - ancestors: NodeEntry[]; + location: Location; + value: NodeValue; + parent: NodeEntry | null; + children: NodeEntry[]; } -export interface NodeIdentity { - /** Replaces the walker's key as the last segment of the node's key. */ - segment: string; - /** What the key used to say and compare should still see — the path template, for one. */ - properties?: Record; +export interface Reference { + from: NodeEntry; + to: NodeEntry; } - -export type IdentityFn = ( - node: Record, - context: IdentityContext -) => NodeIdentity | undefined; diff --git a/packages/core/src/utils/__tests__/semver.test.ts b/packages/core/src/utils/__tests__/semver.test.ts deleted file mode 100644 index 6dfe297fc3..0000000000 --- a/packages/core/src/utils/__tests__/semver.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { bumpSemver, formatSemver, parseSemver, semverBumpBetween } from '../semver.js'; - -describe('semver', () => { - it.each([ - ['1.2.3', { major: 1, minor: 2, patch: 3 }], - ['1.2.3-beta.1+build', { major: 1, minor: 2, patch: 3 }], - ['1.2', undefined], - ['v1.2.3', undefined], - [undefined, undefined], - ])('parses %s', (version, expected) => { - expect(parseSemver(version)).toEqual(expected); - }); - - it.each([ - ['1.2.3', '2.0.0', 'major'], - ['1.2.3', '1.3.0', 'minor'], - ['1.2.3', '1.2.4', 'patch'], - ['1.2.3', '1.2.3', undefined], - ['2.0.0', '1.9.9', undefined], - ])('reads the bump from %s to %s as %s', (from, to, expected) => { - expect(semverBumpBetween(parseSemver(from)!, parseSemver(to)!)).toBe(expected); - }); - - it.each([ - ['major', '2.0.0'], - ['minor', '1.3.0'], - ['patch', '1.2.4'], - ] as const)('bumps 1.2.3 by a %s to %s', (part, expected) => { - expect(formatSemver(bumpSemver(parseSemver('1.2.3')!, part))).toBe(expected); - }); -}); diff --git a/packages/core/src/utils/path-template.ts b/packages/core/src/utils/path-template.ts new file mode 100644 index 0000000000..6fc2d08292 --- /dev/null +++ b/packages/core/src/utils/path-template.ts @@ -0,0 +1,12 @@ +const TEMPLATE_EXPRESSION = /\{[^}]+\}/g; + +/** The names of the template expressions in a path, in the order they appear. */ +export function templateParameterNames(template: string): string[] { + return [...template.matchAll(TEMPLATE_EXPRESSION)].map(([expression]) => expression.slice(1, -1)); +} + +/** The path with its template names replaced by their positions: `/pets/{id}` → `/pets/{0}`. */ +export function templateShape(template: string): string { + let position = 0; + return template.replace(TEMPLATE_EXPRESSION, () => `{${position++}}`); +} diff --git a/packages/core/src/utils/semver.ts b/packages/core/src/utils/semver.ts deleted file mode 100644 index 6141e6a021..0000000000 --- a/packages/core/src/utils/semver.ts +++ /dev/null @@ -1,35 +0,0 @@ -export type SemverPart = 'major' | 'minor' | 'patch'; - -export interface Semver { - major: number; - minor: number; - patch: number; -} - -// Pre-release and build metadata are accepted and ignored: only the three numbers are compared. -const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/; - -export function parseSemver(version: unknown): Semver | undefined { - const match = typeof version === 'string' ? version.match(SEMVER) : null; - if (!match) return undefined; - const [, major, minor, patch] = match; - return { major: Number(major), minor: Number(minor), patch: Number(patch) }; -} - -export function formatSemver({ major, minor, patch }: Semver): string { - return `${major}.${minor}.${patch}`; -} - -/** The first part that grew from `from` to `to`; `undefined` when nothing grew. */ -export function semverBumpBetween(from: Semver, to: Semver): SemverPart | undefined { - if (to.major > from.major) return 'major'; - if (to.major === from.major && to.minor > from.minor) return 'minor'; - if (to.major === from.major && to.minor === from.minor && to.patch > from.patch) return 'patch'; - return undefined; -} - -export function bumpSemver(version: Semver, part: SemverPart): Semver { - if (part === 'major') return { major: version.major + 1, minor: 0, patch: 0 }; - if (part === 'minor') return { ...version, minor: version.minor + 1, patch: 0 }; - return { ...version, patch: version.patch + 1 }; -} diff --git a/tests/e2e/diff/async3-channel-address-changed/snapshot.txt b/tests/e2e/diff/async3-channel-address-changed/snapshot.txt index a4e5afb702..cdc7e2f902 100644 --- a/tests/e2e/diff/async3-channel-address-changed/snapshot.txt +++ b/tests/e2e/diff/async3-channel-address-changed/snapshot.txt @@ -1,5 +1,5 @@ channels - ✖ major modified channels/userSignedUp · address + ✖ major modified userSignedUp · address The channel address changed from 'user/signedup' to 'user/signed-up'. (channel-address-changed) at revision.yaml:7:14 diff --git a/tests/e2e/diff/async3-channel-removed/snapshot.txt b/tests/e2e/diff/async3-channel-removed/snapshot.txt index a0d02e8d75..b7b7d08966 100644 --- a/tests/e2e/diff/async3-channel-removed/snapshot.txt +++ b/tests/e2e/diff/async3-channel-removed/snapshot.txt @@ -1,10 +1,10 @@ channels - ✖ major removed channels/userDeleted + ✖ major removed userDeleted The channel was removed. (channel-removed) at base.yaml:21:5 operations - ✖ major removed operations/onUserDeleted + ✖ major removed onUserDeleted Operation was removed. (operation-removed) at base.yaml:34:5 diff --git a/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt b/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt index f8f7961ff8..5af48c4949 100644 --- a/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt +++ b/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt @@ -1,5 +1,5 @@ channels - ✖ major modified channels/userSignedUp/messages/signup · contentType + ✖ major modified userSignedUp/messages/signup · contentType The message content type changed from 'application/json' to 'application/avro'. (message-content-type-changed) at revision.yaml:10:22 diff --git a/tests/e2e/diff/async3-message-removed/snapshot.txt b/tests/e2e/diff/async3-message-removed/snapshot.txt index d3d748f257..806de7aee9 100644 --- a/tests/e2e/diff/async3-message-removed/snapshot.txt +++ b/tests/e2e/diff/async3-message-removed/snapshot.txt @@ -1,5 +1,5 @@ channels - ✖ major removed channels/userSignedUp/messages/signout + ✖ major removed userSignedUp/messages/signout The message was removed. (message-removed) at base.yaml:21:9 diff --git a/tests/e2e/diff/async3-operation-action-changed/snapshot.txt b/tests/e2e/diff/async3-operation-action-changed/snapshot.txt index 2c8cfe8153..43f61ffcbb 100644 --- a/tests/e2e/diff/async3-operation-action-changed/snapshot.txt +++ b/tests/e2e/diff/async3-operation-action-changed/snapshot.txt @@ -1,5 +1,5 @@ operations - ✖ major modified operations/onUserSignedUp · action + ✖ major modified onUserSignedUp · action The operation action changed from 'receive' to 'send'. (operation-action-changed) at revision.yaml:22:13 diff --git a/tests/e2e/diff/async3-payload-property-removed/snapshot.txt b/tests/e2e/diff/async3-payload-property-removed/snapshot.txt index 149593c291..c1f4191fee 100644 --- a/tests/e2e/diff/async3-payload-property-removed/snapshot.txt +++ b/tests/e2e/diff/async3-payload-property-removed/snapshot.txt @@ -1,5 +1,5 @@ channels - ✖ major removed channels/userSignedUp/messages/signup/payload/properties/plan + ✖ major removed userSignedUp/messages/signup/payload/properties/plan Schema property was removed. (property-removed-from-response) at base.yaml:19:15 diff --git a/tests/e2e/diff/async3-payload-required-added/snapshot.txt b/tests/e2e/diff/async3-payload-required-added/snapshot.txt index 4f5ae5075c..dee53c2fe8 100644 --- a/tests/e2e/diff/async3-payload-required-added/snapshot.txt +++ b/tests/e2e/diff/async3-payload-required-added/snapshot.txt @@ -1,5 +1,5 @@ channels - ✖ major modified channels/userSignedUp/messages/signup/payload · required + ✖ major modified userSignedUp/messages/signup/payload · required Properties became required: plan. (required-properties-added) at revision.yaml:14:13 diff --git a/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt b/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt index 5506beb71f..b671b8536a 100644 --- a/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt +++ b/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt @@ -1,5 +1,5 @@ channels - · patch modified channels/userSignedUp/messages/signup/payload · required + · patch modified userSignedUp/messages/signup/payload · required at revision.yaml:14:13 0 major, 0 minor, 1 patch. diff --git a/tests/e2e/diff/async3-server-removed/snapshot.txt b/tests/e2e/diff/async3-server-removed/snapshot.txt index d096dcacba..9fd29b3854 100644 --- a/tests/e2e/diff/async3-server-removed/snapshot.txt +++ b/tests/e2e/diff/async3-server-removed/snapshot.txt @@ -1,5 +1,5 @@ servers - ✖ major removed servers/staging + ✖ major removed staging The server was removed. (server-removed) at base.yaml:10:5 diff --git a/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt b/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt index 1402e91c68..09761e0d82 100644 --- a/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✖ major modified requestBody/content/application~1json/schema · additionalProperties + ✖ major modified requestBody/content/application/json/schema · additionalProperties `additionalProperties` changed from 'true' to 'false'. (additional-properties-changed) at revision.yaml:13:37 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 index d3592f78be..a9d7ffd234 100644 --- a/tests/e2e/diff/oas3-enum-values-added-to-request/snapshot.txt +++ b/tests/e2e/diff/oas3-enum-values-added-to-request/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - · patch modified requestBody/content/application~1json/schema · enum + · patch modified requestBody/content/application/json/schema · enum at revision.yaml:13:21 0 major, 0 minor, 1 patch. diff --git a/tests/e2e/diff/oas3-enum-values-added/snapshot.txt b/tests/e2e/diff/oas3-enum-values-added/snapshot.txt index bd5bbec8fa..7cc4b19dec 100644 --- a/tests/e2e/diff/oas3-enum-values-added/snapshot.txt +++ b/tests/e2e/diff/oas3-enum-values-added/snapshot.txt @@ -1,5 +1,5 @@ GET /pets - ✖ major modified responses/200/content/application~1json/schema · enum + ✖ major modified responses/200/content/application/json/schema · enum Enum values added: sold. (enum-values-added) at revision.yaml:15:23 diff --git a/tests/e2e/diff/oas3-media-type-removed/snapshot.txt b/tests/e2e/diff/oas3-media-type-removed/snapshot.txt index 8ed5bfc09a..e41bca5081 100644 --- a/tests/e2e/diff/oas3-media-type-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-media-type-removed/snapshot.txt @@ -1,5 +1,5 @@ GET /pets - ✖ major removed responses/200/content/application~1xml + ✖ major removed responses/200/content/application/xml Media type was removed. (media-type-removed) at base.yaml:15:15 diff --git a/tests/e2e/diff/oas3-nullability-changed/snapshot.txt b/tests/e2e/diff/oas3-nullability-changed/snapshot.txt index 39d4dffc9b..2a1c2a54f0 100644 --- a/tests/e2e/diff/oas3-nullability-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-nullability-changed/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✖ major modified requestBody/content/application~1json/schema/properties/name · type + ✖ major modified requestBody/content/application/json/schema/properties/name · type Schema type narrowed from 'string | null' to 'string'. (schema-type-changed) at revision.yaml:15:25 diff --git a/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt index ec158bac99..5a4cd26906 100644 --- a/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt +++ b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt @@ -3,9 +3,9 @@ openapi at revision.yaml:1:10 POST /pets - · patch modified requestBody/content/application~1json/schema/properties/name · nullable + · patch modified requestBody/content/application/json/schema/properties/name · nullable at revision.yaml:15:19 - · patch modified requestBody/content/application~1json/schema/properties/name · type + · patch modified requestBody/content/application/json/schema/properties/name · type at revision.yaml:16:21 0 major, 0 minor, 3 patch. diff --git a/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt b/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt index 47c8da4b87..128eccfeb4 100644 --- a/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✖ major modified requestBody/content/application~1json/schema/properties/age · minimum + ✖ major modified requestBody/content/application/json/schema/properties/age · minimum `minimum` changed from '0' to '10'. (numeric-range-changed) at revision.yaml:16:28 diff --git a/tests/e2e/diff/oas3-property-removed-from-response/snapshot.txt b/tests/e2e/diff/oas3-property-removed-from-response/snapshot.txt index a47ae2ac5a..2a5142e08a 100644 --- a/tests/e2e/diff/oas3-property-removed-from-response/snapshot.txt +++ b/tests/e2e/diff/oas3-property-removed-from-response/snapshot.txt @@ -1,5 +1,5 @@ GET /pets - ✖ major removed responses/200/content/application~1json/schema/properties/tag + ✖ major removed responses/200/content/application/json/schema/properties/tag Schema property was removed. (property-removed-from-response) at base.yaml:19:21 diff --git a/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt b/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt index b431680327..28b88a1fa1 100644 --- a/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt @@ -1,5 +1,5 @@ GET /pets - ✖ major modified responses/200/content/application~1json · schema + ✖ major modified responses/200/content/application/json · 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 diff --git a/tests/e2e/diff/oas3-required-properties-added/snapshot.txt b/tests/e2e/diff/oas3-required-properties-added/snapshot.txt index 6a1693a8f5..b771ceab62 100644 --- a/tests/e2e/diff/oas3-required-properties-added/snapshot.txt +++ b/tests/e2e/diff/oas3-required-properties-added/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✖ major modified requestBody/content/application~1json/schema · required + ✖ major modified requestBody/content/application/json/schema · required Properties became required: name. (required-properties-added) at revision.yaml:13:25 diff --git a/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt b/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt index 9ab8db8acf..28fb6d288f 100644 --- a/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt @@ -1,5 +1,5 @@ GET /pets - ✖ major modified responses/200/content/application~1json/schema · required + ✖ major modified responses/200/content/application/json/schema · required Properties are no longer required: name. (required-properties-removed) at revision.yaml:15:27 diff --git a/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt b/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt index c80d645b5e..e7a7839629 100644 --- a/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✖ major removed requestBody/content/application~1json/schema/oneOf/1 + ✖ major removed requestBody/content/application/json/schema/oneOf/1 A `oneOf` subschema was removed. (schema-combinator-changed) at base.yaml:14:19 diff --git a/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt b/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt index e2c341fbd0..3782b94ea4 100644 --- a/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✖ major modified requestBody/content/application~1json/schema/properties/id · format + ✖ major modified requestBody/content/application/json/schema/properties/id · format `format` was added with value 'uuid'. (schema-format-changed) at revision.yaml:16:27 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 index 89b31555f3..ee2ba0e70f 100644 --- a/tests/e2e/diff/oas3-schema-type-widened-in-request/snapshot.txt +++ b/tests/e2e/diff/oas3-schema-type-widened-in-request/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - · patch modified requestBody/content/application~1json/schema/properties/id · type + · patch modified requestBody/content/application/json/schema/properties/id · type at revision.yaml:16:21 0 major, 0 minor, 1 patch. diff --git a/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt b/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt index 4c8c5d19e5..e229290eaf 100644 --- a/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt @@ -1,12 +1,12 @@ components - ✖ major modified components/securitySchemes/apiKey · type + ✖ major modified securitySchemes/apiKey · type Security scheme `type` changed from 'apiKey' to 'http'. (security-scheme-changed) at revision.yaml:16:13 - · patch modified components/securitySchemes/apiKey · in + · patch modified securitySchemes/apiKey · in at revision.yaml:16:7 - · patch modified components/securitySchemes/apiKey · name + · patch modified securitySchemes/apiKey · name at revision.yaml:16:7 - · patch modified components/securitySchemes/apiKey · scheme + · patch modified securitySchemes/apiKey · scheme at revision.yaml:17:15 1 major, 0 minor, 3 patch. diff --git a/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt b/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt index fb25470c8f..16dda27320 100644 --- a/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt @@ -1,5 +1,5 @@ components - ✖ major removed components/securitySchemes/basicAuth + ✖ major removed securitySchemes/basicAuth A security scheme was removed. (security-scheme-removed) at base.yaml:17:7 diff --git a/tests/e2e/diff/oas3-string-length-changed/snapshot.txt b/tests/e2e/diff/oas3-string-length-changed/snapshot.txt index 2125c97029..fb95f97030 100644 --- a/tests/e2e/diff/oas3-string-length-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-string-length-changed/snapshot.txt @@ -1,5 +1,5 @@ POST /pets - ✖ major modified requestBody/content/application~1json/schema/properties/name · maxLength + ✖ major modified requestBody/content/application/json/schema/properties/name · maxLength `maxLength` changed from '100' to '10'. (string-length-changed) at revision.yaml:16:30 diff --git a/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt b/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt index 95fd112e42..d5e50cef99 100644 --- a/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt @@ -1,5 +1,5 @@ webhooks - ✖ major removed webhooks/newPet/post/requestBody/content/application~1json/schema/properties/tag + ✖ major removed newPet/post/requestBody/content/application/json/schema/properties/tag Schema property was removed. (property-removed-from-response) at base.yaml:18:19 From 5b80ec7b37bd39026572573b0d3e7fc23bee043d Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:20:08 +0300 Subject: [PATCH 43/57] refactor(diff): report an unsupported comparison as a handled error and sort changes in the report formats --- .../__tests__/__snapshots__/html-report.html | 90 +++++++++---------- .../diff/format/__tests__/format.test.ts | 6 +- .../diff/format/__tests__/problems.test.ts | 14 +-- packages/cli/src/commands/diff/format/html.ts | 3 +- packages/cli/src/commands/diff/format/json.ts | 7 +- .../cli/src/commands/diff/format/markdown.ts | 4 +- .../cli/src/commands/diff/format/order.ts | 7 ++ .../cli/src/commands/diff/format/problems.ts | 4 +- packages/cli/src/commands/diff/index.ts | 20 +---- .../src/diff/__tests__/diff-documents.test.ts | 7 +- packages/core/src/diff/index.ts | 63 ++++++------- packages/core/src/index.ts | 2 +- tests/e2e/diff/cli.test.ts | 2 +- 13 files changed, 117 insertions(+), 112 deletions(-) create mode 100644 packages/cli/src/commands/diff/format/order.ts diff --git a/packages/cli/src/commands/diff/format/__tests__/__snapshots__/html-report.html b/packages/cli/src/commands/diff/format/__tests__/__snapshots__/html-report.html index 939c3413c9..430069f665 100644 --- a/packages/cli/src/commands/diff/format/__tests__/__snapshots__/html-report.html +++ b/packages/cli/src/commands/diff/format/__tests__/__snapshots__/html-report.html @@ -29,6 +29,26 @@

API diff

oas3_1 → oas3_1

+
+ + minor + added + #/components/schemas/Pet + + +
{
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/components/schemas/Pet",
+    "line": 20,
+    "col": 7,
+    "value": {
+      "type": "object"
+    }
+  }
+}
+
+
major @@ -49,6 +69,31 @@

API diff

}
+
+ + patch + modified + #/paths/~1pets/get · summary + + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1pets/get/summary",
+    "line": 5,
+    "col": 16,
+    "value": "List pets"
+  },
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/paths/~1pets/get/summary",
+    "line": 5,
+    "col": 16,
+    "value": "List all pets"
+  }
+}
+
+
major @@ -95,51 +140,6 @@

API diff

"col": 24, "value": "a|b" } -} -
- -
- - patch - modified - #/paths/~1pets/get · summary - - -
{
-  "base": {
-    "file": "base.yaml",
-    "pointer": "#/paths/~1pets/get/summary",
-    "line": 5,
-    "col": 16,
-    "value": "List pets"
-  },
-  "revision": {
-    "file": "revision.yaml",
-    "pointer": "#/paths/~1pets/get/summary",
-    "line": 5,
-    "col": 16,
-    "value": "List all pets"
-  }
-}
-
- -
- - minor - added - #/components/schemas/Pet - - -
{
-  "revision": {
-    "file": "revision.yaml",
-    "pointer": "#/components/schemas/Pet",
-    "line": 20,
-    "col": 7,
-    "value": {
-      "type": "object"
-    }
-  }
 }
diff --git a/packages/cli/src/commands/diff/format/__tests__/format.test.ts b/packages/cli/src/commands/diff/format/__tests__/format.test.ts index 3857a370f1..e20b946e7b 100644 --- a/packages/cli/src/commands/diff/format/__tests__/format.test.ts +++ b/packages/cli/src/commands/diff/format/__tests__/format.test.ts @@ -228,11 +228,11 @@ describe('markdownDiff', () => { | Impact | Change | Location | Details | | --- | --- | --- | --- | + | 🟢 minor | added | \`#/components/schemas/Pet\` | | | 🔴 major | removed | \`#/paths/~1pets/delete\` | Operation was removed. \`operation-removed\` | - | 🔴 major | modified | \`#/paths/~1pets/get/parameters/{query:limit} · required\` | Parameter became required. \`parameter-became-required\` | - | 🔴 major | modified | \`#/paths/~1pets/post/requestBody/content/application~1json/schema · pattern\` | \\\`pattern\\\` changed from 'a' to 'a\\|b'. \`string-length-changed\` | | ⚪ patch | modified | \`#/paths/~1pets/get · summary\` | | - | 🟢 minor | added | \`#/components/schemas/Pet\` | |" + | 🔴 major | modified | \`#/paths/~1pets/get/parameters/{query:limit} · required\` | Parameter became required. \`parameter-became-required\` | + | 🔴 major | modified | \`#/paths/~1pets/post/requestBody/content/application~1json/schema · pattern\` | \\\`pattern\\\` changed from 'a' to 'a\\|b'. \`string-length-changed\` |" `); }); }); diff --git a/packages/cli/src/commands/diff/format/__tests__/problems.test.ts b/packages/cli/src/commands/diff/format/__tests__/problems.test.ts index 2705c2c4d8..9bce0b02ff 100644 --- a/packages/cli/src/commands/diff/format/__tests__/problems.test.ts +++ b/packages/cli/src/commands/diff/format/__tests__/problems.test.ts @@ -87,6 +87,13 @@ describe('diffToProblems', () => { })) ).toMatchInlineSnapshot(` [ + { + "at": "revision.yaml#/components/schemas/Pet", + "from": undefined, + "message": "added #/components/schemas/Pet", + "ruleId": "diff", + "severity": "warn", + }, { "at": "revision.yaml#/paths/~1pets/get/parameters/0/required", "from": "base.yaml#/paths/~1pets/get/parameters/0/required", @@ -101,13 +108,6 @@ describe('diffToProblems', () => { "ruleId": "parameter-serialization-changed", "severity": "error", }, - { - "at": "revision.yaml#/components/schemas/Pet", - "from": undefined, - "message": "added #/components/schemas/Pet", - "ruleId": "diff", - "severity": "warn", - }, ] `); }); diff --git a/packages/cli/src/commands/diff/format/html.ts b/packages/cli/src/commands/diff/format/html.ts index e774221075..7d584129c1 100644 --- a/packages/cli/src/commands/diff/format/html.ts +++ b/packages/cli/src/commands/diff/format/html.ts @@ -1,6 +1,7 @@ import type { DiffResult, JudgedChange } from '@redocly/openapi-core'; import { toJsonChange } from './json.js'; +import { byKey } from './order.js'; function escapeHtml(value: unknown): string { return String(value) @@ -70,7 +71,7 @@ export function htmlDiff(result: DiffResult): string { result.specVersions.revision )}

-${result.changes.map(renderChange).join('\n')} +${result.changes.toSorted(byKey).map(renderChange).join('\n')} `; } diff --git a/packages/cli/src/commands/diff/format/json.ts b/packages/cli/src/commands/diff/format/json.ts index 24882107ef..825581a36b 100644 --- a/packages/cli/src/commands/diff/format/json.ts +++ b/packages/cli/src/commands/diff/format/json.ts @@ -10,6 +10,8 @@ import { type LocatedNode, } from '@redocly/openapi-core'; +import { byKey } from './order.js'; + export interface JsonLocatedNode { file: string; pointer: string; @@ -89,6 +91,9 @@ export function toJsonChange(change: JudgedChange): JsonChange { } export function jsonDiff(result: DiffResult): string { - const report: JsonDiffResult = { ...result, changes: result.changes.map(toJsonChange) }; + const report: JsonDiffResult = { + ...result, + changes: result.changes.toSorted(byKey).map(toJsonChange), + }; return JSON.stringify(report, null, 2); } diff --git a/packages/cli/src/commands/diff/format/markdown.ts b/packages/cli/src/commands/diff/format/markdown.ts index 752d71caba..fbf9dfa61e 100644 --- a/packages/cli/src/commands/diff/format/markdown.ts +++ b/packages/cli/src/commands/diff/format/markdown.ts @@ -1,5 +1,7 @@ import type { DiffResult, Impact } from '@redocly/openapi-core'; +import { byKey } from './order.js'; + const IMPACT_LABELS: Record = { major: '🔴 major', minor: '🟢 minor', @@ -30,7 +32,7 @@ export function markdownDiff(result: DiffResult): string { '| --- | --- | --- | --- |', ]; - for (const change of result.changes) { + for (const change of result.changes.toSorted(byKey)) { const location = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key; // 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. diff --git a/packages/cli/src/commands/diff/format/order.ts b/packages/cli/src/commands/diff/format/order.ts new file mode 100644 index 0000000000..d0b317ff1a --- /dev/null +++ b/packages/cli/src/commands/diff/format/order.ts @@ -0,0 +1,7 @@ +import type { JudgedChange } from '@redocly/openapi-core'; + +// Compared by code unit, the way `Array.prototype.sort` compares strings, so a report lists its +// changes in the same order on every machine; `localeCompare` would follow the locale. +export function byKey(left: JudgedChange, right: JudgedChange): number { + return left.key < right.key ? -1 : left.key > right.key ? 1 : 0; +} diff --git a/packages/cli/src/commands/diff/format/problems.ts b/packages/cli/src/commands/diff/format/problems.ts index e604e462dd..200e88059b 100644 --- a/packages/cli/src/commands/diff/format/problems.ts +++ b/packages/cli/src/commands/diff/format/problems.ts @@ -6,12 +6,14 @@ import { type ProblemSeverity, } from '@redocly/openapi-core'; +import { byKey } from './order.js'; + // A lint problem carries one of two severities, so a patch has nowhere to go and is left // out; this is what lets the github-actions format come from core's formatProblems. const SEVERITIES: Partial> = { major: 'error', minor: 'warn' }; export function diffToProblems(result: DiffResult): NormalizedProblem[] { - return result.changes.flatMap((change) => { + return result.changes.toSorted(byKey).flatMap((change) => { const severity = SEVERITIES[change.impact]; if (!severity) return []; // Point at the counterpart in the base document, so formats that render diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts index 9f3ded211e..7e6a37ec72 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -1,12 +1,4 @@ -import { - bundle, - DiffError, - diffDocuments, - formatProblems, - getTotals, - logger, - type DiffResult, -} from '@redocly/openapi-core'; +import { bundle, diffDocuments, formatProblems, getTotals, logger } from '@redocly/openapi-core'; import { writeFileSync } from 'node:fs'; import { AbortFlowError, exitWithError } from '../../utils/error.js'; @@ -37,15 +29,7 @@ export async function handleDiff({ argv, config, collectSpecData }: CommandArgs< 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; - } + const result = diffDocuments({ base: baseDocument, revision: revisionDocument, config }); if (argv.format === 'github-actions') { const problems = diffToProblems(result); diff --git a/packages/core/src/diff/__tests__/diff-documents.test.ts b/packages/core/src/diff/__tests__/diff-documents.test.ts index ea6015a923..712c655c53 100644 --- a/packages/core/src/diff/__tests__/diff-documents.test.ts +++ b/packages/core/src/diff/__tests__/diff-documents.test.ts @@ -2,7 +2,8 @@ import { outdent } from 'outdent'; import { createConfig } from '../../config/index.js'; import { makeDocumentFromString } from '../../resolve.js'; -import { DiffError, diffDocuments } from '../index.js'; +import { HandledError } from '../../utils/error.js'; +import { diffDocuments } from '../index.js'; import type { DiffResult } from '../types.js'; const BASE = outdent` @@ -130,7 +131,7 @@ describe('diffDocuments', () => { expect(result.bump).toBe('major'); }); - it('throws DiffError for different spec families', async () => { + it('throws a handled error for different spec families', async () => { const config = await createConfig({ extends: ['diff-recommended'] }); const oas2 = makeDocumentFromString( outdent` @@ -142,7 +143,7 @@ describe('diffDocuments', () => { ); expect(() => diffDocuments({ base: oas2, revision: makeDocumentFromString(REVISION, ''), config }) - ).toThrow(DiffError); + ).toThrow(HandledError); }); it('matches renamed path parameters instead of remove+add', async () => { diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index 9f166ceb2b..b90a253990 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -4,6 +4,7 @@ import { collectNodes } from '../node-map/collect.js'; import { getTypes, type SpecVersion } from '../oas-types.js'; import type { Document } from '../resolve.js'; import { normalizeTypes } from '../types/index.js'; +import { HandledError } from '../utils/error.js'; import { changesIn } from './changes.js'; import { judgeChanges } from './detect.js'; import { highestImpact } from './impact.js'; @@ -12,24 +13,6 @@ import { diffSpecs, structuralSpec } from './specs/index.js'; import type { DiffResult, DiffSummary, Impact, JudgedChange } from './types.js'; import { usageDirections } from './usage.js'; -export class DiffError extends Error {} - -const byKey = (left: { key: string }, right: { key: string }) => - left.key < right.key ? -1 : left.key > right.key ? 1 : 0; - -// The change to `info.version` is the bump being checked, so it does not count toward it. -function requiredBump(changes: JudgedChange[]): Impact | undefined { - const counted = changes.filter( - (change) => - !( - typeOf(change.pair) === 'Info' && - change.kind === 'modified' && - change.property === 'version' - ) - ); - return highestImpact(counted.map((change) => change.impact)); -} - export function diffDocuments(opts: { base: Document; revision: Document; @@ -39,20 +22,20 @@ export function diffDocuments(opts: { const baseVersion = detectSpec(base.parsed); const revisionVersion = detectSpec(revision.parsed); + const family = getMajorSpecVersion(revisionVersion); + if (getMajorSpecVersion(baseVersion) !== family) { - throw new DiffError( - `The base and the revision use different specification families: '${baseVersion}' and '${revisionVersion}'. The diff command compares documents of one family only.` - ); + throw new HandledError(`Cannot compare ${baseVersion} with ${revisionVersion}.`); } - // Each side is collected with its own type tree. - const collect = (document: Document, specVersion: SpecVersion) => - collectNodes({ + const collect = (document: Document, specVersion: SpecVersion) => { + return collectNodes({ document, types: normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config), specVersion, }); + }; const spec = diffSpecs[family]; @@ -66,10 +49,11 @@ export function diffDocuments(opts: { collectedRevision.root, spec ?? structuralSpec ); + const fromUsage = usageDirections(references, pairOf, spec ?? structuralSpec); const changes = judgeChanges({ - changes: changesIn(root, spec ?? structuralSpec).sort(byKey), + changes: changesIn(root, spec ?? structuralSpec), specVersion: revisionVersion, spec, ruleSets: config.getDiffRulesForSpecVersion(family), @@ -77,14 +61,33 @@ export function diffDocuments(opts: { fromUsage, }); - const summary: DiffSummary = { major: 0, minor: 0, patch: 0 }; - for (const change of changes) summary[change.impact]++; - return { version: '1', - specVersions: { base: baseVersion, revision: revisionVersion }, - summary, + specVersions: { + base: baseVersion, + revision: revisionVersion, + }, + summary: countByImpact(changes), bump: requiredBump(changes), changes, }; } + +function countByImpact(changes: JudgedChange[]): DiffSummary { + const summary: DiffSummary = { major: 0, minor: 0, patch: 0 }; + for (const change of changes) summary[change.impact]++; + return summary; +} + +function requiredBump(changes: JudgedChange[]): Impact | undefined { + return highestImpact( + changes.flatMap((change) => { + const isVersionChange = + change.kind === 'modified' && + change.property === 'version' && + typeOf(change.pair) === 'Info'; + + return !isVersionChange ? [change.impact] : []; + }) + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5653fd023e..b204e3bc1a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -130,7 +130,7 @@ export { bundleDocument, type ComponentNamesStrategy } from './bundle/bundle-doc export { mapTypeToComponent } from './bundle/bundle-visitor.js'; export { type Assertions, type Assertion } from './rules/common/assertions/index.js'; export { logger, type LoggerInterface } from './logger.js'; -export { diffDocuments, DiffError } from './diff/index.js'; +export { diffDocuments } from './diff/index.js'; export { type Change, type DiffResult, diff --git a/tests/e2e/diff/cli.test.ts b/tests/e2e/diff/cli.test.ts index d099392372..935d6e186c 100644 --- a/tests/e2e/diff/cli.test.ts +++ b/tests/e2e/diff/cli.test.ts @@ -47,6 +47,6 @@ describe('diff command', () => { test('refuses to compare across specification families', () => { const output = runDiff('cross-family'); - expect(output).toContain('different specification families'); + expect(output).toContain('Cannot compare oas2 with oas3_1.'); }); }); From 17e3025f966fe1d20983ba3c05cf2dbdc105cc52 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:32:08 +0300 Subject: [PATCH 44/57] refactor(diff): build a diff tree, describe specs as tables, and give each diff rule its own file --- .../diff/format/__tests__/diff-node.ts | 8 + .../diff/format/__tests__/format.test.ts | 17 +- .../commands/diff/format/__tests__/pair.ts | 8 - .../diff/format/__tests__/problems.test.ts | 11 +- packages/cli/src/commands/diff/format/html.ts | 4 +- packages/cli/src/commands/diff/format/json.ts | 11 +- .../cli/src/commands/diff/format/markdown.ts | 4 +- .../cli/src/commands/diff/format/order.ts | 12 +- .../cli/src/commands/diff/format/problems.ts | 4 +- .../cli/src/commands/diff/format/stylish.ts | 4 +- .../core/src/diff/__tests__/changes.test.ts | 17 +- .../src/diff/__tests__/diff-documents.test.ts | 24 ++- .../{pairs.test.ts => diff-tree.test.ts} | 42 +++-- .../core/src/diff/__tests__/direction.test.ts | 131 ++++++++-------- .../{detect.test.ts => judge.test.ts} | 119 ++++++-------- packages/core/src/diff/__tests__/tree.ts | 38 +++-- packages/core/src/diff/changes.ts | 89 ++++++----- packages/core/src/diff/detect.ts | 120 --------------- packages/core/src/diff/diff-tree.ts | 99 ++++++++++++ packages/core/src/diff/direction.ts | 67 ++++++++ packages/core/src/diff/impact.ts | 7 +- packages/core/src/diff/index.ts | 29 ++-- packages/core/src/diff/judge.ts | 115 ++++++++++++++ packages/core/src/diff/pairs.ts | 84 ---------- .../diff/rules/__tests__/constraints.test.ts | 73 --------- .../__tests__/schema-type-changed.test.ts | 29 ++++ .../src/diff/rules/__tests__/utils.test.ts | 20 +++ .../rules/additional-properties-changed.ts | 3 + .../src/diff/rules/channel-address-changed.ts | 10 ++ .../rules/{channel.ts => channel-removed.ts} | 13 +- packages/core/src/diff/rules/constraints.ts | 100 ------------ .../core/src/diff/rules/enum-values-added.ts | 11 ++ .../src/diff/rules/enum-values-removed.ts | 11 ++ packages/core/src/diff/rules/index.ts | 65 ++++---- .../core/src/diff/rules/media-type-removed.ts | 7 + .../rules/message-content-type-changed.ts | 10 ++ .../rules/{message.ts => message-removed.ts} | 9 -- .../src/diff/rules/numeric-range-changed.ts | 9 ++ ...eration.ts => operation-action-changed.ts} | 12 -- .../core/src/diff/rules/operation-removed.ts | 7 + .../diff/rules/parameter-added-required.ts | 23 +++ .../diff/rules/parameter-became-required.ts | 10 ++ .../core/src/diff/rules/parameter-removed.ts | 16 ++ .../rules/parameter-serialization-changed.ts | 15 ++ packages/core/src/diff/rules/parameter.ts | 65 -------- packages/core/src/diff/rules/path-removed.ts | 7 + .../rules/property-removed-from-response.ts | 12 ++ .../rules/{ref.ts => ref-target-changed.ts} | 0 .../rules/request-body-became-required.ts | 10 ++ .../src/diff/rules/request-body-removed.ts | 9 ++ packages/core/src/diff/rules/request-body.ts | 20 --- .../diff/rules/required-properties-added.ts | 11 ++ .../diff/rules/required-properties-removed.ts | 13 ++ .../src/diff/rules/response-header-removed.ts | 16 ++ .../core/src/diff/rules/response-removed.ts | 7 + packages/core/src/diff/rules/response.ts | 28 ---- .../diff/rules/schema-combinator-changed.ts | 23 +++ .../src/diff/rules/schema-format-changed.ts | 3 + .../src/diff/rules/schema-type-changed.ts | 44 ++++++ packages/core/src/diff/rules/schema.ts | 145 ------------------ .../diff/rules/security-requirement-added.ts | 22 +++ .../src/diff/rules/security-scheme-changed.ts | 26 ++++ .../src/diff/rules/security-scheme-removed.ts | 7 + .../src/diff/rules/security-scopes-added.ts | 12 ++ packages/core/src/diff/rules/security.ts | 66 -------- .../rules/{server.ts => server-removed.ts} | 0 .../src/diff/rules/string-length-changed.ts | 3 + packages/core/src/diff/rules/utils.ts | 73 +++++++++ packages/core/src/diff/specs/async3.ts | 40 ++--- packages/core/src/diff/specs/direction.ts | 14 -- packages/core/src/diff/specs/index.ts | 23 ++- packages/core/src/diff/specs/oas3.ts | 139 +++++++---------- packages/core/src/diff/types.ts | 120 +++++---------- packages/core/src/diff/usage.ts | 43 ------ packages/core/src/index.ts | 5 +- .../__tests__/build.test.ts} | 35 +++-- .../src/{node-map => node-tree}/access.ts | 0 .../collect.ts => node-tree/build.ts} | 14 +- .../core/src/{node-map => node-tree}/types.ts | 9 +- .../src/utils/__tests__/is-scalar.test.ts | 10 ++ 80 files changed, 1241 insertions(+), 1350 deletions(-) create mode 100644 packages/cli/src/commands/diff/format/__tests__/diff-node.ts delete mode 100644 packages/cli/src/commands/diff/format/__tests__/pair.ts rename packages/core/src/diff/__tests__/{pairs.test.ts => diff-tree.test.ts} (76%) rename packages/core/src/diff/__tests__/{detect.test.ts => judge.test.ts} (71%) delete mode 100644 packages/core/src/diff/detect.ts create mode 100644 packages/core/src/diff/diff-tree.ts create mode 100644 packages/core/src/diff/direction.ts create mode 100644 packages/core/src/diff/judge.ts delete mode 100644 packages/core/src/diff/pairs.ts delete mode 100644 packages/core/src/diff/rules/__tests__/constraints.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/utils.test.ts create mode 100644 packages/core/src/diff/rules/additional-properties-changed.ts create mode 100644 packages/core/src/diff/rules/channel-address-changed.ts rename packages/core/src/diff/rules/{channel.ts => channel-removed.ts} (53%) delete mode 100644 packages/core/src/diff/rules/constraints.ts create mode 100644 packages/core/src/diff/rules/enum-values-added.ts create mode 100644 packages/core/src/diff/rules/enum-values-removed.ts create mode 100644 packages/core/src/diff/rules/media-type-removed.ts create mode 100644 packages/core/src/diff/rules/message-content-type-changed.ts rename packages/core/src/diff/rules/{message.ts => message-removed.ts} (53%) create mode 100644 packages/core/src/diff/rules/numeric-range-changed.ts rename packages/core/src/diff/rules/{operation.ts => operation-action-changed.ts} (57%) create mode 100644 packages/core/src/diff/rules/operation-removed.ts create mode 100644 packages/core/src/diff/rules/parameter-added-required.ts create mode 100644 packages/core/src/diff/rules/parameter-became-required.ts create mode 100644 packages/core/src/diff/rules/parameter-removed.ts create mode 100644 packages/core/src/diff/rules/parameter-serialization-changed.ts delete mode 100644 packages/core/src/diff/rules/parameter.ts create mode 100644 packages/core/src/diff/rules/path-removed.ts create mode 100644 packages/core/src/diff/rules/property-removed-from-response.ts rename packages/core/src/diff/rules/{ref.ts => ref-target-changed.ts} (100%) create mode 100644 packages/core/src/diff/rules/request-body-became-required.ts create mode 100644 packages/core/src/diff/rules/request-body-removed.ts delete mode 100644 packages/core/src/diff/rules/request-body.ts create mode 100644 packages/core/src/diff/rules/required-properties-added.ts create mode 100644 packages/core/src/diff/rules/required-properties-removed.ts create mode 100644 packages/core/src/diff/rules/response-header-removed.ts create mode 100644 packages/core/src/diff/rules/response-removed.ts delete mode 100644 packages/core/src/diff/rules/response.ts create mode 100644 packages/core/src/diff/rules/schema-combinator-changed.ts create mode 100644 packages/core/src/diff/rules/schema-format-changed.ts create mode 100644 packages/core/src/diff/rules/schema-type-changed.ts delete mode 100644 packages/core/src/diff/rules/schema.ts create mode 100644 packages/core/src/diff/rules/security-requirement-added.ts create mode 100644 packages/core/src/diff/rules/security-scheme-changed.ts create mode 100644 packages/core/src/diff/rules/security-scheme-removed.ts create mode 100644 packages/core/src/diff/rules/security-scopes-added.ts delete mode 100644 packages/core/src/diff/rules/security.ts rename packages/core/src/diff/rules/{server.ts => server-removed.ts} (100%) create mode 100644 packages/core/src/diff/rules/string-length-changed.ts create mode 100644 packages/core/src/diff/rules/utils.ts delete mode 100644 packages/core/src/diff/specs/direction.ts delete mode 100644 packages/core/src/diff/usage.ts rename packages/core/src/{node-map/__tests__/collect.test.ts => node-tree/__tests__/build.test.ts} (78%) rename packages/core/src/{node-map => node-tree}/access.ts (100%) rename packages/core/src/{node-map/collect.ts => node-tree/build.ts} (87%) rename packages/core/src/{node-map => node-tree}/types.ts (71%) create mode 100644 packages/core/src/utils/__tests__/is-scalar.test.ts diff --git a/packages/cli/src/commands/diff/format/__tests__/diff-node.ts b/packages/cli/src/commands/diff/format/__tests__/diff-node.ts new file mode 100644 index 0000000000..69f828b8ce --- /dev/null +++ b/packages/cli/src/commands/diff/format/__tests__/diff-node.ts @@ -0,0 +1,8 @@ +import type { DiffNode, Location } from '@redocly/openapi-core'; + +/** A diff node that stands alone, for tests that only need its type and location. */ +export function loneNode(type: string, location: Location): DiffNode { + const key = location.pointer.slice(location.pointer.lastIndexOf('/') + 1); + const node = { type, key, location, value: {}, parent: null, children: [] }; + return { base: node, revision: node, parent: null, children: [], key: '#/' }; +} diff --git a/packages/cli/src/commands/diff/format/__tests__/format.test.ts b/packages/cli/src/commands/diff/format/__tests__/format.test.ts index e20b946e7b..d2e0be0042 100644 --- a/packages/cli/src/commands/diff/format/__tests__/format.test.ts +++ b/packages/cli/src/commands/diff/format/__tests__/format.test.ts @@ -6,7 +6,7 @@ import { outdent } from 'outdent'; import { htmlDiff } from '../html.js'; import { markdownDiff } from '../markdown.js'; import { stylishDiff } from '../stylish.js'; -import { lonePair } from './pair.js'; +import { loneNode } from './diff-node.js'; // format.test.ts — vitest runs with FORCE_COLOR=1, so the codes are stripped before snapshotting function stripColors(output: string): string { @@ -80,13 +80,12 @@ const RESULT: DiffResult = { { key: '#/paths/~1pets/delete', kind: 'removed', - pair: lonePair('Operation', at(base, '#/paths/~1pets/delete')), + node: loneNode('Operation', at(base, '#/paths/~1pets/delete')), base: { location: at(base, '#/paths/~1pets/delete'), value: { summary: '' }, }, impact: 'major', - direction: 'neutral', verdicts: [ { ruleId: 'operation-removed', @@ -100,7 +99,7 @@ const RESULT: DiffResult = { key: '#/paths/~1pets/get/parameters/{query:limit}', property: 'required', kind: 'modified', - pair: lonePair('Parameter', at(base, '#/paths/~1pets/get/parameters/{query:limit}')), + node: loneNode('Parameter', at(base, '#/paths/~1pets/get/parameters/{query:limit}')), base: { location: at(base, '#/paths/~1pets/get/parameters/0/required'), value: undefined, @@ -110,7 +109,6 @@ const RESULT: DiffResult = { value: true, }, impact: 'major', - direction: 'request', verdicts: [ { ruleId: 'parameter-became-required', @@ -124,7 +122,7 @@ const RESULT: DiffResult = { key: '#/paths/~1pets/post/requestBody/content/application~1json/schema', property: 'pattern', kind: 'modified', - pair: lonePair( + node: loneNode( 'Schema', at(base, '#/paths/~1pets/post/requestBody/content/application~1json/schema') ), @@ -143,7 +141,6 @@ const RESULT: DiffResult = { value: 'a|b', }, impact: 'major', - direction: 'request', verdicts: [ { ruleId: 'string-length-changed', @@ -162,7 +159,7 @@ const RESULT: DiffResult = { key: '#/paths/~1pets/get', property: 'summary', kind: 'modified', - pair: lonePair('Operation', at(base, '#/paths/~1pets/get')), + node: loneNode('Operation', at(base, '#/paths/~1pets/get')), base: { location: at(base, '#/paths/~1pets/get/summary'), value: 'List pets', @@ -172,19 +169,17 @@ const RESULT: DiffResult = { value: 'List all pets', }, impact: 'patch', - direction: 'neutral', verdicts: [], }, { key: '#/components/schemas/Pet', kind: 'added', - pair: lonePair('Schema', at(base, '#/components/schemas/Pet')), + node: loneNode('Schema', at(base, '#/components/schemas/Pet')), revision: { location: at(revision, '#/components/schemas/Pet'), value: { type: 'object' }, }, impact: 'minor', - direction: 'neutral', verdicts: [], }, ], diff --git a/packages/cli/src/commands/diff/format/__tests__/pair.ts b/packages/cli/src/commands/diff/format/__tests__/pair.ts deleted file mode 100644 index 1c97b57259..0000000000 --- a/packages/cli/src/commands/diff/format/__tests__/pair.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { Location, Pair } from '@redocly/openapi-core'; - -/** A pair for a node that stands alone, for tests that only need its type and location. */ -export function lonePair(type: string, location: Location): Pair { - const key = location.pointer.slice(location.pointer.lastIndexOf('/') + 1); - const node = { type, key, location, value: {}, parent: null, children: [] }; - return { base: node, revision: node, parent: null, children: [], occurrence: 1 }; -} diff --git a/packages/cli/src/commands/diff/format/__tests__/problems.test.ts b/packages/cli/src/commands/diff/format/__tests__/problems.test.ts index 9bce0b02ff..e6eee850de 100644 --- a/packages/cli/src/commands/diff/format/__tests__/problems.test.ts +++ b/packages/cli/src/commands/diff/format/__tests__/problems.test.ts @@ -1,7 +1,7 @@ import { Location, Source, type DiffResult } from '@redocly/openapi-core'; import { diffToProblems } from '../problems.js'; -import { lonePair } from './pair.js'; +import { loneNode } from './diff-node.js'; const baseSource = new Source('base.yaml', 'openapi: 3.1.0\n'); const revisionSource = new Source('revision.yaml', 'openapi: 3.1.0\n'); @@ -17,7 +17,7 @@ const result: DiffResult = { key: '#/paths/~1pets/get/parameters/{query:limit}', property: 'required', kind: 'modified', - pair: lonePair('Parameter', at(baseSource, '#/paths/~1pets/get/parameters/{query:limit}')), + node: loneNode('Parameter', at(baseSource, '#/paths/~1pets/get/parameters/{query:limit}')), base: { location: at(baseSource, '#/paths/~1pets/get/parameters/0/required'), value: undefined, @@ -27,7 +27,6 @@ const result: DiffResult = { value: true, }, impact: 'major', - direction: 'request', verdicts: [ { ruleId: 'parameter-became-required', @@ -46,24 +45,22 @@ const result: DiffResult = { { key: '#/components/schemas/Pet', kind: 'added', - pair: lonePair('Schema', at(baseSource, '#/components/schemas/Pet')), + node: loneNode('Schema', at(baseSource, '#/components/schemas/Pet')), revision: { location: at(revisionSource, '#/components/schemas/Pet'), value: { type: 'object' }, }, impact: 'minor', - direction: 'neutral', verdicts: [], }, { key: '#/info', property: 'version', kind: 'modified', - pair: lonePair('Info', at(baseSource, '#/info')), + node: loneNode('Info', at(baseSource, '#/info')), base: { location: at(baseSource, '#/info/version'), value: '1.0.0' }, revision: { location: at(revisionSource, '#/info/version'), value: '1.0.1' }, impact: 'patch', - direction: 'neutral', verdicts: [], }, ], diff --git a/packages/cli/src/commands/diff/format/html.ts b/packages/cli/src/commands/diff/format/html.ts index 7d584129c1..dd2781525d 100644 --- a/packages/cli/src/commands/diff/format/html.ts +++ b/packages/cli/src/commands/diff/format/html.ts @@ -1,7 +1,7 @@ import type { DiffResult, JudgedChange } from '@redocly/openapi-core'; import { toJsonChange } from './json.js'; -import { byKey } from './order.js'; +import { byKeyAndProperty } from './order.js'; function escapeHtml(value: unknown): string { return String(value) @@ -71,7 +71,7 @@ export function htmlDiff(result: DiffResult): string { result.specVersions.revision )}

-${result.changes.toSorted(byKey).map(renderChange).join('\n')} +${result.changes.toSorted(byKeyAndProperty).map(renderChange).join('\n')} `; } diff --git a/packages/cli/src/commands/diff/format/json.ts b/packages/cli/src/commands/diff/format/json.ts index 825581a36b..3bc6effc70 100644 --- a/packages/cli/src/commands/diff/format/json.ts +++ b/packages/cli/src/commands/diff/format/json.ts @@ -3,14 +3,13 @@ import { typeOf, type Change, type DiffResult, - type Direction, type Impact, type JudgedChange, type Location, type LocatedNode, } from '@redocly/openapi-core'; -import { byKey } from './order.js'; +import { byKeyAndProperty } from './order.js'; export interface JsonLocatedNode { file: string; @@ -34,7 +33,6 @@ export interface JsonChange { kind: Change['kind']; property?: string; impact: Impact; - direction: Direction; verdicts: JsonVerdict[]; base?: JsonLocatedNode; revision?: JsonLocatedNode; @@ -61,13 +59,12 @@ function toJsonNode({ location, value }: LocatedNode): JsonLocatedNode { } export function toJsonChange(change: JudgedChange): JsonChange { - const { key, pair, kind, impact, direction, verdicts } = change; + const { key, node, kind, impact, verdicts } = change; const common = { key, - typeName: typeOf(pair), + typeName: typeOf(node), kind, impact, - direction, verdicts: verdicts.map((verdict) => ({ ruleId: verdict.ruleId, impact: verdict.impact, @@ -93,7 +90,7 @@ export function toJsonChange(change: JudgedChange): JsonChange { export function jsonDiff(result: DiffResult): string { const report: JsonDiffResult = { ...result, - changes: result.changes.toSorted(byKey).map(toJsonChange), + changes: result.changes.toSorted(byKeyAndProperty).map(toJsonChange), }; return JSON.stringify(report, null, 2); } diff --git a/packages/cli/src/commands/diff/format/markdown.ts b/packages/cli/src/commands/diff/format/markdown.ts index fbf9dfa61e..f5af6a5b77 100644 --- a/packages/cli/src/commands/diff/format/markdown.ts +++ b/packages/cli/src/commands/diff/format/markdown.ts @@ -1,6 +1,6 @@ import type { DiffResult, Impact } from '@redocly/openapi-core'; -import { byKey } from './order.js'; +import { byKeyAndProperty } from './order.js'; const IMPACT_LABELS: Record = { major: '🔴 major', @@ -32,7 +32,7 @@ export function markdownDiff(result: DiffResult): string { '| --- | --- | --- | --- |', ]; - for (const change of result.changes.toSorted(byKey)) { + for (const change of result.changes.toSorted(byKeyAndProperty)) { const location = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key; // 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. diff --git a/packages/cli/src/commands/diff/format/order.ts b/packages/cli/src/commands/diff/format/order.ts index d0b317ff1a..38e74c6792 100644 --- a/packages/cli/src/commands/diff/format/order.ts +++ b/packages/cli/src/commands/diff/format/order.ts @@ -2,6 +2,14 @@ import type { JudgedChange } from '@redocly/openapi-core'; // Compared by code unit, the way `Array.prototype.sort` compares strings, so a report lists its // changes in the same order on every machine; `localeCompare` would follow the locale. -export function byKey(left: JudgedChange, right: JudgedChange): number { - return left.key < right.key ? -1 : left.key > right.key ? 1 : 0; +function compare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function propertyOf(change: JudgedChange): string { + return change.kind === 'modified' ? change.property : ''; +} + +export function byKeyAndProperty(left: JudgedChange, right: JudgedChange): number { + return compare(left.key, right.key) || compare(propertyOf(left), propertyOf(right)); } diff --git a/packages/cli/src/commands/diff/format/problems.ts b/packages/cli/src/commands/diff/format/problems.ts index 200e88059b..f740b7e8e5 100644 --- a/packages/cli/src/commands/diff/format/problems.ts +++ b/packages/cli/src/commands/diff/format/problems.ts @@ -6,14 +6,14 @@ import { type ProblemSeverity, } from '@redocly/openapi-core'; -import { byKey } from './order.js'; +import { byKeyAndProperty } from './order.js'; // A lint problem carries one of two severities, so a patch has nowhere to go and is left // out; this is what lets the github-actions format come from core's formatProblems. const SEVERITIES: Partial> = { major: 'error', minor: 'warn' }; export function diffToProblems(result: DiffResult): NormalizedProblem[] { - return result.changes.toSorted(byKey).flatMap((change) => { + return result.changes.toSorted(byKeyAndProperty).flatMap((change) => { const severity = SEVERITIES[change.impact]; if (!severity) return []; // Point at the counterpart in the base document, so formats that render diff --git a/packages/cli/src/commands/diff/format/stylish.ts b/packages/cli/src/commands/diff/format/stylish.ts index 7f945a715d..a61e7fd62e 100644 --- a/packages/cli/src/commands/diff/format/stylish.ts +++ b/packages/cli/src/commands/diff/format/stylish.ts @@ -13,6 +13,8 @@ import { import { blue, bold, gray, green, red } from 'colorette'; import * as path from 'node:path'; +import { byKeyAndProperty } from './order.js'; + const IMPACT_GLYPHS: Record = { major: red('✖ major'), minor: green('✔ minor'), @@ -90,7 +92,7 @@ export function stylishDiff(result: DiffResult): string { lines.push(bold(blue(key))); const sorted = [...changes].sort( (left, right) => - impactRank(right.impact) - impactRank(left.impact) || left.key.localeCompare(right.key) + impactRank(right.impact) - impactRank(left.impact) || byKeyAndProperty(left, right) ); for (const change of sorted) { lines.push( diff --git a/packages/core/src/diff/__tests__/changes.test.ts b/packages/core/src/diff/__tests__/changes.test.ts index 99745d5f34..3431012e51 100644 --- a/packages/core/src/diff/__tests__/changes.test.ts +++ b/packages/core/src/diff/__tests__/changes.test.ts @@ -1,9 +1,8 @@ -import type { NodeEntry, NodeValue } from '../../node-map/types.js'; +import type { NodeEntry, NodeValue } from '../../node-tree/types.js'; import { Location } from '../../ref-utils.js'; import { Source } from '../../resolve.js'; -import { changesIn } from '../changes.js'; -import { pairDocuments } from '../pairs.js'; -import type { DiffSpec } from '../types.js'; +import { collectChanges } from '../changes.js'; +import { buildDiffTree } from '../diff-tree.js'; const source = new Source('api.yaml', ''); @@ -33,16 +32,14 @@ function documentOf(children: Record undefined, directionOf: () => 'neutral' }; - +// No identity of its own: the structure alone decides. const changesBetween = ( base: ReturnType, revision: ReturnType -) => changesIn(pairDocuments(base.get('#/')!, revision.get('#/')!, structural).root, structural); +) => collectChanges(buildDiffTree(base.get('#/')!, revision.get('#/')!, {}).root, {}); /** `kind key · property base-pointer → revision-pointer` per change. */ -function summarize(changes: ReturnType): string[] { +function summarize(changes: ReturnType): string[] { return changes.map((change) => { const property = change.kind === 'modified' ? ` · ${change.property}` : ''; const base = change.kind === 'added' ? '-' : change.base.location.pointer; @@ -51,7 +48,7 @@ function summarize(changes: ReturnType): string[] { }); } -describe('changesIn', () => { +describe('collectChanges', () => { it('emits one modified change per differing property, located at the escaped property pointer', () => { const base = documentOf({ a: { value: { type: 'integer', 'x/y': 1 } } }); const revision = documentOf({ a: { value: { type: 'number', 'x/y': 2 } } }); diff --git a/packages/core/src/diff/__tests__/diff-documents.test.ts b/packages/core/src/diff/__tests__/diff-documents.test.ts index 712c655c53..60dca62b7d 100644 --- a/packages/core/src/diff/__tests__/diff-documents.test.ts +++ b/packages/core/src/diff/__tests__/diff-documents.test.ts @@ -102,7 +102,6 @@ describe('diffDocuments', () => { const enumChange = result.changes.find( (change) => change.kind === 'modified' && change.property === 'enum' ); - expect(enumChange?.direction).toBe('request'); expect(enumChange?.verdicts.map((verdict) => verdict.ruleId)).toEqual(['enum-values-removed']); }); @@ -146,6 +145,29 @@ describe('diffDocuments', () => { ).toThrow(HandledError); }); + it('compares a specification without diff rules by its structure and judges nothing', async () => { + const config = await createConfig({ extends: ['diff-recommended'] }); + const oas2 = (paths: string) => + makeDocumentFromString( + outdent` + swagger: '2.0' + info: { title: Test, version: '1.0' } + paths: ${paths} + `, + '' + ); + + const result = diffDocuments({ + base: oas2(`{ /pets: { get: { responses: { '200': { description: OK } } } } }`), + revision: oas2('{}'), + config, + }); + + expect(result.changes.map((change) => [change.kind, change.key, change.impact])).toEqual([ + ['removed', '#/paths/~1pets', 'patch'], + ]); + }); + it('matches renamed path parameters instead of remove+add', async () => { const config = await createConfig({ extends: ['diff-recommended'] }); const makeSpec = (param: string) => outdent` diff --git a/packages/core/src/diff/__tests__/pairs.test.ts b/packages/core/src/diff/__tests__/diff-tree.test.ts similarity index 76% rename from packages/core/src/diff/__tests__/pairs.test.ts rename to packages/core/src/diff/__tests__/diff-tree.test.ts index aca8630c52..8928855f92 100644 --- a/packages/core/src/diff/__tests__/pairs.test.ts +++ b/packages/core/src/diff/__tests__/diff-tree.test.ts @@ -2,35 +2,35 @@ import { outdent } from 'outdent'; import { createConfig } from '../../config/index.js'; import { detectSpec } from '../../detect-spec.js'; -import { collectNodes } from '../../node-map/collect.js'; +import { buildNodeTree } from '../../node-tree/build.js'; import { getTypes } from '../../oas-types.js'; import { makeDocumentFromString } from '../../resolve.js'; import { normalizeTypes } from '../../types/index.js'; -import { labelOf, pairDocuments } from '../pairs.js'; -import { oas3Spec } from '../specs/oas3.js'; -import type { Pair } from '../types.js'; +import { buildDiffTree } from '../diff-tree.js'; +import { oas3Identities } from '../specs/oas3.js'; +import type { DiffNode } from '../types.js'; async function nodesOf(yaml: string) { const document = makeDocumentFromString(yaml, 'api.yaml'); const config = await createConfig({}); const specVersion = detectSpec(document.parsed); const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - return collectNodes({ document, types, specVersion }).root; + return buildNodeTree({ document, types, specVersion }).root; } -/** Every pair's label, the document paired with itself. */ -async function labelsOf(yaml: string): Promise> { +/** Every node's key, the document compared with itself. */ +async function labelsOf(yaml: string): Promise> { const root = await nodesOf(yaml); - const labels = new Map(); - const visit = (pair: Pair) => { - labels.set(labelOf(pair, oas3Spec), pair); - pair.children.forEach(visit); + const labels = new Map(); + const visit = (node: DiffNode) => { + labels.set(node.key, node); + node.children.forEach(visit); }; - visit(pairDocuments(root, root, oas3Spec).root); + visit(buildDiffTree(root, root, oas3Identities).root); return labels; } -describe('pairDocuments', () => { +describe('buildDiffTree', () => { it('labels a path by its shape and its path parameters by position', async () => { const labels = await labelsOf(outdent` openapi: 3.1.0 @@ -103,7 +103,7 @@ describe('pairDocuments', () => { expect(labels.has('#/security/{apiKey+oauth}')).toBe(true); }); - it('pairs siblings with the same identity in order and numbers the label from the second one', async () => { + it('matches siblings with the same identity in order and numbers the label from the second one', async () => { const yaml = (second: string) => outdent` openapi: 3.1.0 info: { title: T, version: '1' } @@ -118,15 +118,13 @@ describe('pairDocuments', () => { const base = await nodesOf(yaml('- { name: a, in: query, required: true }')); const revision = await nodesOf(yaml('')); - const list = pairDocuments(base, revision, oas3Spec) - .root.children.flatMap((pair) => pair.children) - .flatMap((pair) => pair.children) - .flatMap((pair) => pair.children) - .find((pair) => pair.base?.type === 'ParameterList')!; + const list = buildDiffTree(base, revision, oas3Identities) + .root.children.flatMap((node) => node.children) + .flatMap((node) => node.children) + .flatMap((node) => node.children) + .find((node) => node.base?.type === 'ParameterList')!; - expect( - list.children.map((pair) => [labelOf(pair, oas3Spec), pair.base?.key, pair.revision?.key]) - ).toEqual([ + expect(list.children.map((node) => [node.key, node.base?.key, node.revision?.key])).toEqual([ ['#/paths/~1p/get/parameters/{query:a}', 0, 0], ['#/paths/~1p/get/parameters/{query:a}#2', 1, undefined], ]); diff --git a/packages/core/src/diff/__tests__/direction.test.ts b/packages/core/src/diff/__tests__/direction.test.ts index 431de57bb5..acb02bcdaa 100644 --- a/packages/core/src/diff/__tests__/direction.test.ts +++ b/packages/core/src/diff/__tests__/direction.test.ts @@ -1,7 +1,7 @@ -import { async3Spec } from '../specs/async3.js'; -import { mergeDirections } from '../specs/direction.js'; -import { oas3Spec } from '../specs/oas3.js'; -import { treeOf, usageOfTree } from './tree.js'; +import { mergeDirections } from '../direction.js'; +import { async3Directions } from '../specs/async3.js'; +import { oas3Directions } from '../specs/oas3.js'; +import { directionsOfTree, treeOf } from './tree.js'; // One document covering every direction-bearing shape at once. const entries = treeOf(` @@ -50,94 +50,78 @@ const entries = treeOf(` #/components/schemas/Orphan Schema `); const node = (key: string) => entries.get(key)!; -const usageOf = (edges: Array<[string, string]>) => usageOfTree(entries, edges, oas3Spec); -const emptyUsage = usageOf([]); +const directionsOf = (edges: Array<[string, string]>) => + directionsOfTree(entries, edges, oas3Directions); +const unreferenced = directionsOf([]); describe('mergeDirections', () => { it('merges directions', () => { - expect(mergeDirections('neutral', 'request')).toBe('request'); - expect(mergeDirections('request', 'request')).toBe('request'); - expect(mergeDirections('request', 'response')).toBe('both'); - expect(mergeDirections('both', 'response')).toBe('both'); + expect(mergeDirections([], ['request'])).toEqual(['request']); + expect(mergeDirections(['request'], ['request'])).toEqual(['request']); + expect(mergeDirections(['response'], ['request'])).toEqual(['request', 'response']); }); }); describe('getOas3Direction', () => { it('reads the direction off the node types on the way down', () => { - expect(oas3Spec.directionOf(node('#/paths/~1p/get/responses/200'), emptyUsage)).toBe( - 'response' - ); - expect( - oas3Spec.directionOf(node('#/paths/~1p/get/parameters/{query:limit}/schema'), emptyUsage) - ).toBe('request'); - expect( - oas3Spec.directionOf( - node('#/paths/~1p/post/requestBody/content/application~1json'), - emptyUsage - ) - ).toBe('request'); - expect(oas3Spec.directionOf(node('#/info/title'), emptyUsage)).toBe('neutral'); - expect(oas3Spec.directionOf(node('#/tags/{pets}'), emptyUsage)).toBe('neutral'); + expect(unreferenced(node('#/paths/~1p/get/responses/200'))).toEqual(['response']); + expect(unreferenced(node('#/paths/~1p/get/parameters/{query:limit}/schema'))).toEqual([ + 'request', + ]); + expect(unreferenced(node('#/paths/~1p/post/requestBody/content/application~1json'))).toEqual([ + 'request', + ]); + expect(unreferenced(node('#/info/title'))).toEqual([]); + expect(unreferenced(node('#/tags/{pets}'))).toEqual([]); }); it('flips the direction under callbacks and webhooks', () => { // The API sends these, so their request body reaches the consumer like a response. - expect( - oas3Spec.directionOf( - node('#/paths/~1p/post/callbacks/onEvent/~1cb/post/requestBody'), - emptyUsage - ) - ).toBe('response'); - expect(oas3Spec.directionOf(node('#/webhooks/newPet/post/requestBody'), emptyUsage)).toBe( - 'response' - ); + expect(unreferenced(node('#/paths/~1p/post/callbacks/onEvent/~1cb/post/requestBody'))).toEqual([ + 'response', + ]); + expect(unreferenced(node('#/webhooks/newPet/post/requestBody'))).toEqual(['response']); // ...and what the consumer answers with is a request. - expect(oas3Spec.directionOf(node('#/webhooks/newPet/post/responses'), emptyUsage)).toBe( - 'request' - ); + expect(unreferenced(node('#/webhooks/newPet/post/responses'))).toEqual(['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( - oas3Spec.directionOf( - node('#/paths/~1p/post/requestBody/content/application~1json/schema/properties/responses'), - emptyUsage + unreferenced( + node('#/paths/~1p/post/requestBody/content/application~1json/schema/properties/responses') ) - ).toBe('request'); + ).toEqual(['request']); expect( - oas3Spec.directionOf( - node('#/paths/~1p/get/responses/200/content/application~1json/schema/properties/callbacks'), - emptyUsage + unreferenced( + node('#/paths/~1p/get/responses/200/content/application~1json/schema/properties/callbacks') ) - ).toBe('response'); + ).toEqual(['response']); }); it('derives component direction from usage sites', () => { - const usage = usageOf([ + const directionOf = directionsOf([ [ '#/paths/~1p/get/responses/200/content/application~1json/schema', '#/components/schemas/Pet', ], ]); - expect(oas3Spec.directionOf(node('#/components/schemas/Pet/properties/name'), usage)).toBe( - 'response' - ); + expect(directionOf(node('#/components/schemas/Pet/properties/name'))).toEqual(['response']); }); it('derives both when a component is used on both sides', () => { - const usage = usageOf([ + const directionOf = directionsOf([ [ '#/paths/~1p/get/responses/200/content/application~1json/schema', '#/components/schemas/Pet', ], ['#/paths/~1p/post/requestBody/content/application~1json/schema', '#/components/schemas/Pet'], ]); - expect(oas3Spec.directionOf(node('#/components/schemas/Pet'), usage)).toBe('both'); + expect(directionOf(node('#/components/schemas/Pet'))).toEqual(['request', 'response']); }); it('resolves transitive usage through other components, cycle-safe', () => { - const usage = usageOf([ + const directionOf = directionsOf([ [ '#/paths/~1p/get/responses/200/content/application~1json/schema', '#/components/schemas/Pet', @@ -146,11 +130,11 @@ describe('getOas3Direction', () => { // cycle back ['#/components/schemas/Address', '#/components/schemas/Pet'], ]); - expect(oas3Spec.directionOf(node('#/components/schemas/Address'), usage)).toBe('response'); + expect(directionOf(node('#/components/schemas/Address'))).toEqual(['response']); }); it('returns neutral for unused components', () => { - expect(oas3Spec.directionOf(node('#/components/schemas/Orphan'), emptyUsage)).toBe('neutral'); + expect(unreferenced(node('#/components/schemas/Orphan'))).toEqual([]); }); }); @@ -174,11 +158,11 @@ const asyncEntries = treeOf(` #/operations/onOrder/reply OperationReply `); const asyncNode = (key: string) => asyncEntries.get(key)!; -const asyncUsageOf = (edges: Array<[string, string]>) => - usageOfTree(asyncEntries, edges, async3Spec); +const asyncDirectionsOf = (edges: Array<[string, string]>) => + directionsOfTree(asyncEntries, edges, async3Directions); describe('getAsync3Direction', () => { - const usage = asyncUsageOf([ + const directionOf = asyncDirectionsOf([ ['#/operations/onSignup', '#/channels/signups'], ['#/operations/sendReceipt', '#/channels/receipts'], ['#/operations/onOrder/reply', '#/channels/orders'], @@ -186,35 +170,40 @@ describe('getAsync3Direction', () => { 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( - async3Spec.directionOf(asyncNode('#/channels/signups/messages/signup/payload'), usage) - ).toBe('request'); - expect(async3Spec.directionOf(asyncNode('#/channels/receipts/messages/receipt'), usage)).toBe( - 'response' - ); + expect(directionOf(asyncNode('#/channels/signups/messages/signup/payload'))).toEqual([ + 'request', + ]); + expect(directionOf(asyncNode('#/channels/receipts/messages/receipt'))).toEqual(['response']); }); it('flips the direction for a reply channel', () => { - expect(async3Spec.directionOf(asyncNode('#/channels/orders'), usage)).toBe('response'); + expect(directionOf(asyncNode('#/channels/orders'))).toEqual(['response']); }); it('reads the direction off the operation the change sits in', () => { - expect(async3Spec.directionOf(asyncNode('#/operations/sendReceipt'), usage)).toBe('response'); + expect(directionOf(asyncNode('#/operations/sendReceipt'))).toEqual(['response']); }); it('returns neutral for a channel no operation references', () => { - expect(async3Spec.directionOf(asyncNode('#/channels/signups'), asyncUsageOf([]))).toBe( - 'neutral' - ); + expect(asyncDirectionsOf([])(asyncNode('#/channels/signups'))).toEqual([]); + }); + + it('takes the direction of every referenced node it sits in', () => { + const directionOf = asyncDirectionsOf([ + ['#/operations/onSignup', '#/channels/signups'], + ['#/operations/sendReceipt', '#/channels/signups/messages/signup'], + ]); + expect(directionOf(asyncNode('#/channels/signups/messages/signup/payload'))).toEqual([ + 'request', + 'response', + ]); }); it('does not hang on a payload that refers back into its own channel', () => { - const recursive = asyncUsageOf([ + const recursive = asyncDirectionsOf([ ['#/channels/signups/messages/signup/payload', '#/channels/signups'], ['#/channels/signups', '#/channels/signups/messages/signup/payload'], ]); - expect(async3Spec.directionOf(asyncNode('#/channels/signups/messages/signup'), recursive)).toBe( - 'neutral' - ); + expect(recursive(asyncNode('#/channels/signups/messages/signup'))).toEqual([]); }); }); diff --git a/packages/core/src/diff/__tests__/detect.test.ts b/packages/core/src/diff/__tests__/judge.test.ts similarity index 71% rename from packages/core/src/diff/__tests__/detect.test.ts rename to packages/core/src/diff/__tests__/judge.test.ts index 33bdff129e..feabda0c20 100644 --- a/packages/core/src/diff/__tests__/detect.test.ts +++ b/packages/core/src/diff/__tests__/judge.test.ts @@ -1,36 +1,36 @@ import { defaultDiffRules } from '../../config/diff-recommended.js'; import { Location } from '../../ref-utils.js'; import { Source } from '../../resolve.js'; -import { handlersOf, initDiffRules, judgeChanges, matches } from '../detect.js'; +import { activeRules, appliesTo, flattenVisitor, judgeChanges } from '../judge.js'; import { oas3Rules } from '../rules/index.js'; -import { oas3Spec } from '../specs/oas3.js'; -import type { Change, DiffRule, Impact, Pair } from '../types.js'; -import { pairsOfTree, treeOf, usageOfTree } from './tree.js'; +import { oas3Directions } from '../specs/oas3.js'; +import type { Change, DiffNode, DiffRule, Impact } from '../types.js'; +import { directionsOfTree, diffNodesOfTree, treeOf } from './tree.js'; const source = new Source('api.yaml', ''); const defaults: Record = defaultDiffRules; const defaultImpact = (ruleId: string) => defaults[ruleId] ?? 'off'; const at = (pointer: string) => new Location(source, pointer); -const lonePair = (type: string, location: Location): Pair => { +const loneNode = (type: string, location: Location): DiffNode => { const key = location.pointer.slice(location.pointer.lastIndexOf('/') + 1); const node = { type, key, location, value: {}, parent: null, children: [] }; - return { base: node, revision: node, parent: null, children: [], occurrence: 1 }; + return { base: node, revision: node, parent: null, children: [], key: '#/' }; }; -// Nothing references anything: every direction comes from the node's own position. -const unlinked = { fromUsage: () => 'neutral' as const }; +// None of these lone nodes has a direction. +const unlinked = { directionOf: () => [] }; // What every finding passes through: which visitors run, what a report becomes, and how the // same finding under two directions is kept once. The rules themselves are covered by tests/e2e/diff. -describe('detectBreakingChanges', () => { +describe('judgeChanges', () => { it('leaves a change no rule reports on patch with no verdicts', () => { const changes: Change[] = [ { key: '#/info', kind: 'modified', property: 'title', - pair: lonePair('Info', at('#/info')), + node: loneNode('Info', at('#/info')), base: { location: at('#/info/title'), value: 'a' }, revision: { location: at('#/info/title'), value: 'b' }, }, @@ -39,7 +39,6 @@ describe('detectBreakingChanges', () => { const [change] = judgeChanges({ changes, specVersion: 'oas3_1', - spec: oas3Spec, ruleSets: [oas3Rules], impactOf: defaultImpact, ...unlinked, @@ -49,33 +48,12 @@ describe('detectBreakingChanges', () => { expect(change.verdicts).toEqual([]); }); - it('judges nothing for a specification without rules', () => { - const changes: Change[] = [ - { - key: '#/x', - kind: 'removed', - pair: lonePair('Operation', at('#/x')), - base: { location: at('#/x'), value: {} }, - }, - ]; - - const [change] = judgeChanges({ - changes, - specVersion: 'async2', - ruleSets: [oas3Rules], - impactOf: defaultImpact, - ...unlinked, - }); - - expect(change.impact).toBe('patch'); - }); - it('stamps the rule id on a report and defaults its location to the display side', () => { const changes: Change[] = [ { key: '#/paths/~1x/delete', kind: 'removed', - pair: lonePair('Operation', at('#/paths/~1x/delete')), + node: loneNode('Operation', at('#/paths/~1x/delete')), base: { location: at('#/paths/~1x/delete'), value: {} }, }, ]; @@ -83,7 +61,6 @@ describe('detectBreakingChanges', () => { const [change] = judgeChanges({ changes, specVersion: 'oas3_1', - spec: oas3Spec, ruleSets: [oas3Rules], impactOf: defaultImpact, ...unlinked, @@ -100,7 +77,7 @@ describe('detectBreakingChanges', () => { ]); }); - it('visits a component used in a request and a response once per direction, keeping each finding once', () => { + it('judges a component used in a request and a response for both directions', () => { const entries = treeOf(` #/ Root #/paths Paths @@ -118,7 +95,7 @@ describe('detectBreakingChanges', () => { #/components/schemas NamedSchemas #/components/schemas/Shared Schema `); - const fromUsage = usageOfTree( + const directionOf = directionsOfTree( entries, [ ['#/paths/~1x/get/parameters/{query:q}/schema', '#/components/schemas/Shared'], @@ -127,14 +104,14 @@ describe('detectBreakingChanges', () => { '#/components/schemas/Shared', ], ], - oas3Spec + oas3Directions ); const changes: Change[] = [ { key: '#/components/schemas/Shared', kind: 'modified', property: 'enum', - pair: pairsOfTree(entries).get(entries.get('#/components/schemas/Shared')!)!, + node: diffNodesOfTree(entries).get(entries.get('#/components/schemas/Shared')!)!, base: { location: at('#/components/schemas/Shared/enum'), value: ['a', 'b'] }, revision: { location: at('#/components/schemas/Shared/enum'), value: ['a', 'c'] }, }, @@ -143,15 +120,14 @@ describe('detectBreakingChanges', () => { const [change] = judgeChanges({ changes, specVersion: 'oas3_1', - spec: oas3Spec, - fromUsage, + directionOf, ruleSets: [oas3Rules], impactOf: defaultImpact, }); expect(change.verdicts.map((verdict) => verdict.ruleId)).toEqual([ - 'enum-values-added', 'enum-values-removed', + 'enum-values-added', ]); }); @@ -160,14 +136,14 @@ describe('detectBreakingChanges', () => { { key: '#/tags/{pets}', kind: 'added', - pair: lonePair('Tag', at('#/tags/0')), + node: loneNode('Tag', at('#/tags/0')), revision: { location: at('#/tags/0'), value: {} }, }, { key: '#/info', kind: 'modified', property: 'title', - pair: lonePair('Info', at('#/info')), + node: loneNode('Info', at('#/info')), base: { location: at('#/info/title'), value: 'a' }, revision: { location: at('#/info/title'), value: 'b' }, }, @@ -176,7 +152,6 @@ describe('detectBreakingChanges', () => { const [added, modified] = judgeChanges({ changes, specVersion: 'oas3_1', - spec: oas3Spec, ruleSets: [oas3Rules], impactOf: defaultImpact, ...unlinked, @@ -191,7 +166,7 @@ describe('detectBreakingChanges', () => { { key: '#/paths/~1x/delete', kind: 'removed', - pair: lonePair('Operation', at('#/paths/~1x/delete')), + node: loneNode('Operation', at('#/paths/~1x/delete')), base: { location: at('#/paths/~1x/delete'), value: {} }, }, ]; @@ -199,7 +174,6 @@ describe('detectBreakingChanges', () => { const [minor] = judgeChanges({ changes, specVersion: 'oas3_1', - spec: oas3Spec, ruleSets: [oas3Rules], impactOf: (ruleId: string) => (ruleId === 'operation-removed' ? 'minor' : 'off'), ...unlinked, @@ -207,7 +181,6 @@ describe('detectBreakingChanges', () => { const [off] = judgeChanges({ changes, specVersion: 'oas3_1', - spec: oas3Spec, ruleSets: [oas3Rules], impactOf: () => 'off' as const, ...unlinked, @@ -237,13 +210,13 @@ const schemaTree = treeOf(` #/components/schemas/Pet/properties/tags Schema #/components/schemas/Pet/properties/tags/items Schema `); -const schemaPairs = pairsOfTree(schemaTree); -const pairAt = (key: string) => schemaPairs.get(schemaTree.get(key)!)!; +const schemaDiffNodes = diffNodesOfTree(schemaTree); +const diffNodeAt = (key: string) => schemaDiffNodes.get(schemaTree.get(key)!)!; -describe('matches', () => { +describe('appliesTo', () => { it('matches the members of a properties map, at any depth, and nothing below a member', () => { const matched = [...schemaTree.keys()].filter((key) => - matches(['SchemaProperties', 'Schema'], pairAt(key)) + appliesTo(['SchemaProperties', 'Schema'], diffNodeAt(key)) ); expect(matched).toEqual([ @@ -256,54 +229,58 @@ describe('matches', () => { it('matches the alternatives of a oneOf and a flat key matches every node of the type', () => { expect( - matches(['OneOf', 'Schema'], pairAt('#/components/schemas/Pet/properties/owner/oneOf/0')) + appliesTo( + ['OneOf', 'Schema'], + diffNodeAt('#/components/schemas/Pet/properties/owner/oneOf/0') + ) ).toBe(true); expect( - matches( + appliesTo( ['OneOf', 'Schema'], - pairAt('#/components/schemas/Pet/properties/owner/oneOf/0/properties/login') + diffNodeAt('#/components/schemas/Pet/properties/owner/oneOf/0/properties/login') ) ).toBe(false); - expect(matches(['Schema'], pairAt('#/components/schemas/Pet/properties/tags/items'))).toBe( - true - ); - expect(matches([], pairAt('#/'))).toBe(true); + expect( + appliesTo(['Schema'], diffNodeAt('#/components/schemas/Pet/properties/tags/items')) + ).toBe(true); + expect(appliesTo([], diffNodeAt('#/'))).toBe(true); }); }); -describe('handlersOf', () => { - it('flattens nested keys to type paths, enter to the container itself and any to the empty path', () => { +describe('flattenVisitor', () => { + it('flattens nested keys to type paths and any to the empty path', () => { const visit = () => {}; - const handlers = handlersOf( - { any: visit, Schema: visit, SchemaProperties: { enter: visit, Schema: visit } }, - [], + const handlers = flattenVisitor( + { any: visit, Schema: visit, SchemaProperties: { Schema: visit } }, 'my-rule' ); - expect(handlers.map(({ path }) => path)).toEqual([ + expect(handlers.map(({ typePath }) => typePath)).toEqual([ [], ['Schema'], - ['SchemaProperties'], ['SchemaProperties', 'Schema'], ]); }); - it('rejects leave and skip, which a diff rule has no use for', () => { - expect(() => handlersOf({ Schema: { leave: () => {} } }, [], 'my-rule')).toThrow( - "Diff rule 'my-rule' uses 'leave'" + it('rejects the lint hooks and a nested any, which a diff rule has no use for', () => { + expect(() => flattenVisitor({ Schema: { enter: () => {} } }, 'my-rule')).toThrow( + "Diff rule 'my-rule' uses 'enter'" + ); + expect(() => flattenVisitor({ Schema: { any: () => {} } }, 'my-rule')).toThrow( + "Diff rule 'my-rule' uses 'any'" ); }); }); -describe('initDiffRules', () => { +describe('activeRules', () => { it('instantiates one visitor per rule under the rule id', () => { const rule: DiffRule = () => ({ Schema() {} }); expect( - initDiffRules([{ 'my-rule': rule }], () => 'major').map(({ id, impact, handlers }) => [ + activeRules([{ 'my-rule': rule }], () => 'major').map(({ id, impact, handlers }) => [ id, impact, - handlers.map(({ path }) => path), + handlers.map(({ typePath }) => typePath), ]) ).toEqual([['my-rule', 'major', [['Schema']]]]); }); diff --git a/packages/core/src/diff/__tests__/tree.ts b/packages/core/src/diff/__tests__/tree.ts index 89d4dcf9bf..268a86c143 100644 --- a/packages/core/src/diff/__tests__/tree.ts +++ b/packages/core/src/diff/__tests__/tree.ts @@ -1,8 +1,8 @@ -import type { NodeEntry, Reference } from '../../node-map/types.js'; +import type { NodeEntry, Reference } from '../../node-tree/types.js'; import { Location } from '../../ref-utils.js'; import { Source } from '../../resolve.js'; -import type { DiffSpec, Pair } from '../types.js'; -import { usageDirections } from '../usage.js'; +import { directionsOf } from '../direction.js'; +import type { DiffNode, Directions } from '../types.js'; const source = new Source('tree.yaml', ''); @@ -10,7 +10,7 @@ const source = new Source('tree.yaml', ''); * Builds nodes from a spelled-out tree for tests that need real node types rather than a * document. Each line is `key Type`, optionally followed by `name=value` fields; a node's * parent is the closest preceding line whose key is a prefix of it — which is what - * `collectNodes` records when it walks a document. + * `buildNodeTree` records when it walks a document. */ export function treeOf(nodes: string): Map { const entries = new Map(); @@ -37,16 +37,22 @@ export function treeOf(nodes: string): Map { return entries; } -/** The tree paired with itself: every node is both sides of its pair. */ -export function pairsOfTree(entries: Map): Map { - const pairs = new Map(); +/** The tree compared with itself: every entry is both sides of its node. */ +export function diffNodesOfTree(entries: Map): Map { + const diffNodes = new Map(); for (const entry of entries.values()) { - const parent = entry.parent ? pairs.get(entry.parent)! : null; - const pair: Pair = { base: entry, revision: entry, parent, children: [], occurrence: 1 }; - parent?.children.push(pair); - pairs.set(entry, pair); + const parent = entry.parent ? diffNodes.get(entry.parent)! : null; + const node: DiffNode = { + base: entry, + revision: entry, + parent, + children: [], + key: entry.location.pointer, + }; + parent?.children.push(node); + diffNodes.set(entry, node); } - return pairs; + return diffNodes; } export function referencesOfTree( @@ -56,11 +62,11 @@ export function referencesOfTree( return edges.map(([from, to]) => ({ from: entries.get(from)!, to: entries.get(to)! })); } -/** The usage directions of a tree paired with itself, for tests that only vary the edges. */ -export function usageOfTree( +/** The directions of a tree compared with itself, for tests that only vary the references. */ +export function directionsOfTree( entries: Map, edges: Array<[from: string, to: string]>, - spec: DiffSpec + directions: Directions ) { - return usageDirections(referencesOfTree(entries, edges), pairsOfTree(entries), spec); + return directionsOf(referencesOfTree(entries, edges), diffNodesOfTree(entries), directions); } diff --git a/packages/core/src/diff/changes.ts b/packages/core/src/diff/changes.ts index 57835131d4..9734f69dcd 100644 --- a/packages/core/src/diff/changes.ts +++ b/packages/core/src/diff/changes.ts @@ -1,70 +1,81 @@ -import type { NodeEntry } from '../node-map/types.js'; +import type { NodeEntry } from '../node-tree/types.js'; import { isRef } from '../ref-utils.js'; import { dequal } from '../utils/dequal.js'; import { isPlainObject } from '../utils/is-plain-object.js'; import { isScalar, isScalarArray } from '../utils/is-scalar.js'; -import { labelOf, nodeOf, typeOf } from './pairs.js'; -import type { Change, DiffSpec, LocatedNode, Pair } from './types.js'; +import { latestOf, typeOf } from './diff-tree.js'; +import type { Change, DiffNode, Identities, LocatedNode } from './types.js'; export function displaySide(change: Change): LocatedNode { return change.kind === 'removed' ? change.base : change.revision; } -/** Every change under the root; nothing under a node that was added or removed as a whole. */ -export function changesIn(root: Pair, spec: DiffSpec): Change[] { +/** Every change between the two documents; nothing below a node added or removed as a whole. */ +export function collectChanges(root: DiffNode, identities: Identities): Change[] { const changes: Change[] = []; - const visit = (pair: Pair) => { - changes.push(...changesOf(pair, spec)); - if (pair.base && pair.revision) pair.children.forEach(visit); + + const visit = (node: DiffNode) => { + const { key, base, revision } = node; + if (!base) { + changes.push({ key, node, kind: 'added', revision: located(latestOf(node)) }); + return; + } + if (!revision) { + changes.push({ key, node, kind: 'removed', base: located(base) }); + return; + } + + const before = comparableValues(node, base, identities); + const after = comparableValues(node, revision, identities); + + for (const property of Object.keys({ ...before, ...after })) { + if (dequal(before[property], after[property])) continue; + + changes.push({ + key, + node, + kind: 'modified', + property, + base: locatedProperty(base, property, before[property]), + revision: locatedProperty(revision, property, after[property]), + }); + } + + node.children.forEach(visit); }; - visit(root); - return changes; -} -export function changesOf(pair: Pair, spec: DiffSpec): Change[] { - const { base, revision } = pair; - const key = labelOf(pair, spec); - if (!base) return [{ key, pair, kind: 'added', revision: located(nodeOf(pair)) }]; - if (!revision) return [{ key, pair, kind: 'removed', base: located(base) }]; + visit(root); - const before = comparableValues(pair, base, spec); - const after = comparableValues(pair, revision, spec); - return [...new Set([...Object.keys(before), ...Object.keys(after)])] - .sort() - .filter((property) => !dequal(before[property], after[property])) - .map((property) => ({ - key, - pair, - kind: 'modified', - property, - base: locatedProperty(base, property, before[property]), - revision: locatedProperty(revision, property, after[property]), - })); + return changes; } /** * The node's own values — scalars, scalar arrays and `$ref`s; nested objects and arrays are - * pairs of their own. A value the segment hides, such as a path template, is compared too. + * nodes of their own. A value the segment hides, such as a path template, is compared too. */ -function comparableValues(pair: Pair, node: NodeEntry, spec: DiffSpec): Record { +function comparableValues( + node: DiffNode, + side: NodeEntry, + identities: Identities +): Record { const values: Record = {}; - if (isPlainObject(node.value)) { - for (const [name, item] of Object.entries(node.value)) { + if (isPlainObject(side.value)) { + for (const [name, item] of Object.entries(side.value)) { if (isRef(item) || isScalar(item) || isScalarArray(item)) values[name] = item; } } - const identity = pair.parent && spec.identityOf(node, typeOf(pair.parent)); + const identity = node.parent && identities[typeOf(node.parent)]?.(side); return { ...values, ...identity?.values }; } -function located(node: NodeEntry): LocatedNode { - return { location: node.location, value: node.value }; +function located(side: NodeEntry): LocatedNode { + return { location: side.location, value: side.value }; } // A value the node does not carry as a property (the path template) is located at the node. -function locatedProperty(node: NodeEntry, property: string, value: unknown): LocatedNode { - const isOwnProperty = isPlainObject(node.value) && property in node.value; - return { location: isOwnProperty ? node.location.child([property]) : node.location, value }; +function locatedProperty(side: NodeEntry, property: string, value: unknown): LocatedNode { + const isOwnProperty = isPlainObject(side.value) && property in side.value; + return { location: isOwnProperty ? side.location.child([property]) : side.location, value }; } diff --git a/packages/core/src/diff/detect.ts b/packages/core/src/diff/detect.ts deleted file mode 100644 index 9ecd66e3ad..0000000000 --- a/packages/core/src/diff/detect.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { NodeEntry } from '../node-map/types.js'; -import type { SpecVersion } from '../oas-types.js'; -import { displaySide } from './changes.js'; -import { highestImpact, impactRank } from './impact.js'; -import { nodeOf, typeOf } from './pairs.js'; -import type { - Change, - DiffReport, - DiffRule, - DiffSpec, - DiffVisitor, - Direction, - Impact, - InitializedDiffRule, - JudgedChange, - Pair, - RuleHandler, - RuleVerdict, -} from './types.js'; - -export function initDiffRules( - ruleSets: Record[], - impactOf: (ruleId: string) => Impact | 'off' -): InitializedDiffRule[] { - return ruleSets.flatMap((rules) => - Object.entries(rules).flatMap(([id, rule]) => { - const impact = impactOf(id); - return impact === 'off' ? [] : [{ id, impact, handlers: handlersOf(rule(), [], id) }]; - }) - ); -} - -export function handlersOf(visitor: DiffVisitor, path: string[], ruleId: string): RuleHandler[] { - return Object.entries(visitor).flatMap(([key, value]) => { - if (key === 'leave' || key === 'skip' || (key === 'enter' && path.length === 0)) { - throw new Error(`Diff rule '${ruleId}' uses '${key}', which diff visitors do not have.`); - } - if (typeof value !== 'function') return handlersOf(value, [...path, key], ruleId); - if (key === 'any') return [{ path: [], visit: value }]; - return [{ path: key === 'enter' ? path : [...path, key], visit: value }]; - }); -} - -/** - * Lint's nesting: `[A, B]` matches a node of type `B` when the first ancestor that is either - * an `A` or a `B` is an `A`. The empty path matches every change. - */ -export function matches(path: string[], pair: Pair): boolean { - if (path.length === 0) return true; - const type = path[path.length - 1]; - if (typeOf(pair) !== type) return false; - if (path.length === 1) return true; - - const container = nearest(pair.parent, [path[path.length - 2], type]); - return container !== undefined && matches(path.slice(0, -1), container); -} - -function nearest(pair: Pair | null, types: string[]): Pair | undefined { - for (let current = pair; current; current = current.parent) { - if (types.includes(typeOf(current))) return current; - } - return undefined; -} - -// What a change means when no rule spoke: something new is a minor, everything else a patch. -function unjudgedImpact(kind: Change['kind']): Impact { - return kind === 'added' ? 'minor' : 'patch'; -} - -function expandDirection(direction: Direction): Direction[] { - return direction === 'both' ? ['request', 'response'] : [direction]; -} - -export function judgeChanges(opts: { - changes: Change[]; - specVersion: SpecVersion; - spec?: DiffSpec; - ruleSets: Record[]; - impactOf: (ruleId: string) => Impact | 'off'; - fromUsage: (node: NodeEntry) => Direction; -}): JudgedChange[] { - const { changes, specVersion, spec, ruleSets, impactOf, fromUsage } = opts; - // Structural comparison works for every specification; only the families with a spec are - // judged, so elsewhere no rule runs and nothing is called breaking. - const rules = spec ? initDiffRules(ruleSets, impactOf) : []; - - return changes.map((change) => { - const verdicts: RuleVerdict[] = []; - const direction = spec?.directionOf(nodeOf(change.pair), fromUsage) ?? 'neutral'; - - for (const expandedDirection of expandDirection(direction)) { - for (const { id, impact, handlers } of rules) { - const report = ({ message, location = displaySide(change).location }: DiffReport) => { - // a node used both ways is visited twice; the same finding is kept once - if (!verdicts.some((verdict) => verdict.ruleId === id && verdict.message === message)) { - verdicts.push({ ruleId: id, impact, message, location }); - } - }; - const context = { report, direction: expandedDirection, specVersion }; - for (const { path, visit } of handlers) { - if (matches(path, change.pair)) visit(change, context); - } - } - } - - verdicts.sort( - (left, right) => - impactRank(right.impact) - impactRank(left.impact) || - left.ruleId.localeCompare(right.ruleId) - ); - - return { - ...change, - impact: - highestImpact(verdicts.map((verdict) => verdict.impact)) ?? unjudgedImpact(change.kind), - direction, - verdicts, - }; - }); -} diff --git a/packages/core/src/diff/diff-tree.ts b/packages/core/src/diff/diff-tree.ts new file mode 100644 index 0000000000..c78765bb61 --- /dev/null +++ b/packages/core/src/diff/diff-tree.ts @@ -0,0 +1,99 @@ +import type { NodeEntry } from '../node-tree/types.js'; +import { escapePointerFragment, joinPointer } from '../ref-utils.js'; +import type { DiffNode, Identities } from './types.js'; + +export function latestOf(node: DiffNode): NodeEntry { + return (node.revision ?? node.base)!; +} + +export function typeOf(node: DiffNode): string { + return latestOf(node).type; +} + +/** + * Matches the two documents up top-down into one tree: the roots meet, and the children of a + * node are matched by the segment their container gives them. Every node of either document gets + * a place, including those under a node that exists on one side only, so a reference site is + * always addressable. + */ +export function buildDiffTree( + base: NodeEntry, + revision: NodeEntry, + identities: Identities +): { root: DiffNode; diffNodeOf: Map } { + const diffNodeOf = new Map(); + + function addSubtree(node: DiffNode): DiffNode { + if (node.base) diffNodeOf.set(node.base, node); + if (node.revision) diffNodeOf.set(node.revision, node); + + const container = typeOf(node); + const baseGroups = groupBySegment(node.base?.children ?? [], container, identities); + const revisionGroups = groupBySegment(node.revision?.children ?? [], container, identities); + + for (const [segment, baseNodes] of baseGroups) { + matchSiblings(node, segment, baseNodes, revisionGroups.get(segment) ?? []); + } + + for (const [segment, revisionNodes] of revisionGroups) { + if (!baseGroups.has(segment)) { + matchSiblings(node, segment, [], revisionNodes); + } + } + + return node; + } + + function matchSiblings( + parent: DiffNode, + segment: string, + baseNodes: NodeEntry[], + revisionNodes: NodeEntry[] + ): void { + const sharedKey = joinPointer(parent.key, segment); + for (let index = 0; index < Math.max(baseNodes.length, revisionNodes.length); index++) { + const key = index === 0 ? sharedKey : `${sharedKey}#${index + 1}`; + + const add = (sides: Pick) => + parent.children.push(addSubtree({ ...sides, parent, children: [], key })); + + const baseNode = baseNodes[index]; + const revisionNode = revisionNodes[index]; + const typeChanged = baseNode && revisionNode && baseNode.type !== revisionNode.type; + if (typeChanged) { + // A different kind of node in the same place is a removal and an addition. + add({ base: baseNode }); + add({ revision: revisionNode }); + } else { + add({ base: baseNode, revision: revisionNode }); + } + } + } + + return { + root: addSubtree({ base, revision, parent: null, children: [], key: '#/' }), + diffNodeOf, + }; +} + +function groupBySegment( + nodes: NodeEntry[], + container: string, + identities: Identities +): Map { + const groups = new Map(); + + for (const node of nodes) { + const segment = + identities[container]?.(node)?.segment ?? escapePointerFragment(String(node.key)); + const group = groups.get(segment); + + if (group) { + group.push(node); + } else { + groups.set(segment, [node]); + } + } + + return groups; +} diff --git a/packages/core/src/diff/direction.ts b/packages/core/src/diff/direction.ts new file mode 100644 index 0000000000..9c5ecd57bd --- /dev/null +++ b/packages/core/src/diff/direction.ts @@ -0,0 +1,67 @@ +import type { NodeEntry, Reference } from '../node-tree/types.js'; +import type { DiffNode, Direction, Directions } from './types.js'; + +const bothDirections: Direction[] = ['request', 'response']; + +/** Both sides' directions, in one order so that equal sets compare equal. */ +export function mergeDirections(left: Direction[], right: Direction[]): Direction[] { + return bothDirections.filter( + (direction) => left.includes(direction) || right.includes(direction) + ); +} + +export function opposite(direction: Direction): Direction { + return direction === 'request' ? 'response' : 'request'; +} + +/** + * The way the data in a node travels. The node's position says it first; where it says nothing, + * the node takes the direction of every site that references it or one of its ancestors, over + * both documents, so a shared component keeps one direction. A site inside another referenced + * node borrows that node's direction, which the repeated passes settle: merging only ever widens + * a direction, so the answers stop changing after a few rounds, and a reference cycle simply + * contributes nothing. + */ +export function directionsOf( + references: Reference[], + diffNodeOf: Map, + directions: Directions +): (node: NodeEntry) => Direction[] { + const usage = new Map(); + + const directionOf = (node: NodeEntry): Direction[] => { + const position = positionOf(node, directions); + if (position) return [position]; + + let merged: Direction[] = []; + for (let current: NodeEntry | null = node; current; current = current.parent) { + merged = mergeDirections(merged, usage.get(diffNodeOf.get(current)!) ?? []); + } + return merged; + }; + + let widened = true; + while (widened) { + widened = false; + for (const { from, to } of references) { + const target = diffNodeOf.get(to)!; + const current = usage.get(target) ?? []; + const merged = mergeDirections(current, directionOf(from)); + if (merged.length !== current.length) { + usage.set(target, merged); + widened = true; + } + } + } + + return directionOf; +} + +function positionOf(node: NodeEntry, directions: Directions): Direction | undefined { + for (let current: NodeEntry | null = node; current; current = current.parent) { + const rule = directions[current.type]; + const direction = typeof rule === 'function' ? rule(current) : rule; + if (direction) return direction; + } + return undefined; +} diff --git a/packages/core/src/diff/impact.ts b/packages/core/src/diff/impact.ts index 2a6635ac87..fd363028fc 100644 --- a/packages/core/src/diff/impact.ts +++ b/packages/core/src/diff/impact.ts @@ -1,4 +1,4 @@ -import type { Impact } from './types.js'; +import type { Change, Impact } from './types.js'; /** Lowest first. An impact is the semver part a change requires to be bumped. */ export const impacts = ['patch', 'minor', 'major'] as const; @@ -14,3 +14,8 @@ export function highestImpact(candidates: Impact[]): Impact | undefined { undefined ); } + +// What a change means when no rule spoke: something new is a minor, everything else a patch. +export function defaultImpact(kind: Change['kind']): Impact { + return kind === 'added' ? 'minor' : 'patch'; +} diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index b90a253990..8750b27190 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -1,17 +1,17 @@ import type { Config } from '../config/index.js'; import { detectSpec, getMajorSpecVersion } from '../detect-spec.js'; -import { collectNodes } from '../node-map/collect.js'; +import { buildNodeTree } from '../node-tree/build.js'; import { getTypes, type SpecVersion } from '../oas-types.js'; import type { Document } from '../resolve.js'; import { normalizeTypes } from '../types/index.js'; import { HandledError } from '../utils/error.js'; -import { changesIn } from './changes.js'; -import { judgeChanges } from './detect.js'; +import { collectChanges } from './changes.js'; +import { buildDiffTree, typeOf } from './diff-tree.js'; +import { directionsOf } from './direction.js'; import { highestImpact } from './impact.js'; -import { pairDocuments, typeOf } from './pairs.js'; -import { diffSpecs, structuralSpec } from './specs/index.js'; +import { judgeChanges } from './judge.js'; +import { diffSpecs } from './specs/index.js'; import type { DiffResult, DiffSummary, Impact, JudgedChange } from './types.js'; -import { usageDirections } from './usage.js'; export function diffDocuments(opts: { base: Document; @@ -30,35 +30,34 @@ export function diffDocuments(opts: { } const collect = (document: Document, specVersion: SpecVersion) => { - return collectNodes({ + return buildNodeTree({ document, types: normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config), specVersion, }); }; - const spec = diffSpecs[family]; + const { identities = {}, directions = {} } = diffSpecs[family] ?? {}; const collectedBase = collect(base, baseVersion); const collectedRevision = collect(revision, revisionVersion); const references = [...collectedBase.references, ...collectedRevision.references]; - const { root, pairOf } = pairDocuments( + const { root, diffNodeOf } = buildDiffTree( collectedBase.root, collectedRevision.root, - spec ?? structuralSpec + identities ); - const fromUsage = usageDirections(references, pairOf, spec ?? structuralSpec); + const directionOf = directionsOf(references, diffNodeOf, directions); const changes = judgeChanges({ - changes: changesIn(root, spec ?? structuralSpec), + changes: collectChanges(root, identities), specVersion: revisionVersion, - spec, ruleSets: config.getDiffRulesForSpecVersion(family), impactOf: (ruleId) => config.getDiffImpact(ruleId, revisionVersion), - fromUsage, + directionOf, }); return { @@ -85,7 +84,7 @@ function requiredBump(changes: JudgedChange[]): Impact | undefined { const isVersionChange = change.kind === 'modified' && change.property === 'version' && - typeOf(change.pair) === 'Info'; + typeOf(change.node) === 'Info'; return !isVersionChange ? [change.impact] : []; }) diff --git a/packages/core/src/diff/judge.ts b/packages/core/src/diff/judge.ts new file mode 100644 index 0000000000..8508283b91 --- /dev/null +++ b/packages/core/src/diff/judge.ts @@ -0,0 +1,115 @@ +import type { NodeEntry } from '../node-tree/types.js'; +import type { SpecVersion } from '../oas-types.js'; +import { displaySide } from './changes.js'; +import { latestOf, typeOf } from './diff-tree.js'; +import { defaultImpact, highestImpact } from './impact.js'; +import type { + Change, + DiffNode, + DiffRule, + DiffRuleContext, + DiffVisit, + DiffVisitor, + Direction, + Impact, + JudgedChange, + RuleVerdict, +} from './types.js'; + +/** One handler of a rule with the node types it is nested under; `any` has none. */ +export type RuleHandler = { typePath: string[]; visit: DiffVisit }; +export type ActiveRule = { id: string; impact: Impact; handlers: RuleHandler[] }; + +/** The rules the configuration turns on, each with the impact it gives. */ +export function activeRules( + ruleSets: Record[], + impactOf: (ruleId: string) => Impact | 'off' +): ActiveRule[] { + const rules: ActiveRule[] = []; + + for (const ruleSet of ruleSets) { + for (const [id, rule] of Object.entries(ruleSet)) { + const impact = impactOf(id); + if (impact !== 'off') { + rules.push({ id, impact, handlers: flattenVisitor(rule(), id) }); + } + } + } + return rules; +} + +// A diff visitor sees whole changes, not a walk, so lint's hooks have nothing to run on. +const HOOKS = new Set(['enter', 'leave', 'skip']); + +/** + * A rule's visitor is nested the way lint visitors are. Flattened, each handler carries the + * types it is nested under, so a change can be checked against it without walking the visitor. + */ +export function flattenVisitor( + visitor: DiffVisitor, + ruleId: string, + outerTypes: string[] = [] +): RuleHandler[] { + return Object.entries(visitor).flatMap(([type, value]) => { + if (HOOKS.has(type) || (type === 'any' && outerTypes.length > 0)) { + throw new Error(`Diff rule '${ruleId}' uses '${type}' where diff visitors do not have it.`); + } + if (typeof value !== 'function') return flattenVisitor(value, ruleId, [...outerTypes, type]); + return [{ typePath: type === 'any' ? [] : [...outerTypes, type], visit: value }]; + }); +} + +/** + * Lint's nesting: `SchemaProperties › Schema` applies to a schema whose nearest enclosing + * `SchemaProperties` or `Schema` is a `SchemaProperties` — a property, not a schema further down + * inside one. The empty path applies to every node. + */ +export function appliesTo(typePath: string[], node: DiffNode): boolean { + if (typePath.length === 0) return true; + + const type = typePath[typePath.length - 1]; + if (typeOf(node) !== type) return false; + + const outerTypes = typePath.slice(0, -1); + if (outerTypes.length === 0) return true; + + const owner = nearest(node.parent, [outerTypes[outerTypes.length - 1], type]); + + return owner !== undefined && appliesTo(outerTypes, owner); +} + +function nearest(node: DiffNode | null, types: string[]): DiffNode | undefined { + for (let current = node; current; current = current.parent) { + if (types.includes(typeOf(current))) return current; + } + return undefined; +} + +export function judgeChanges(opts: { + changes: Change[]; + specVersion: SpecVersion; + ruleSets: Record[]; + impactOf: (ruleId: string) => Impact | 'off'; + directionOf: (node: NodeEntry) => Direction[]; +}): JudgedChange[] { + const { changes, specVersion, ruleSets, impactOf, directionOf } = opts; + const rules = activeRules(ruleSets, impactOf); + + return changes.map((change) => { + const verdicts: RuleVerdict[] = []; + const directions = directionOf(latestOf(change.node)); + + for (const { id, impact, handlers } of rules) { + const report: DiffRuleContext['report'] = ({ + message, + location = displaySide(change).location, + }) => verdicts.push({ ruleId: id, impact, message, location }); + for (const { typePath, visit } of handlers) { + if (appliesTo(typePath, change.node)) visit(change, { report, directions, specVersion }); + } + } + + const impact = highestImpact(verdicts.map((verdict) => verdict.impact)); + return { ...change, impact: impact ?? defaultImpact(change.kind), verdicts }; + }); +} diff --git a/packages/core/src/diff/pairs.ts b/packages/core/src/diff/pairs.ts deleted file mode 100644 index ad0201362b..0000000000 --- a/packages/core/src/diff/pairs.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { NodeEntry } from '../node-map/types.js'; -import { escapePointerFragment, joinPointer } from '../ref-utils.js'; -import type { DiffSpec, Pair } from './types.js'; - -export function nodeOf(pair: Pair): NodeEntry { - return (pair.revision ?? pair.base)!; -} - -export function typeOf(pair: Pair): string { - return nodeOf(pair).type; -} - -/** - * Matches the two documents up top-down: the roots are a pair, and the children of a pair are - * matched by the segment their container gives them. Every node gets a pair, including those - * under a node that exists on one side only, so a reference site is always addressable. - */ -export function pairDocuments( - base: NodeEntry, - revision: NodeEntry, - spec: DiffSpec -): { root: Pair; pairOf: Map } { - const pairOf = new Map(); - - function pairUp( - baseNode: NodeEntry | undefined, - revisionNode: NodeEntry | undefined, - parent: Pair | null, - occurrence: number - ): Pair { - const pair: Pair = { base: baseNode, revision: revisionNode, parent, children: [], occurrence }; - if (baseNode) pairOf.set(baseNode, pair); - if (revisionNode) pairOf.set(revisionNode, pair); - - const container = typeOf(pair); - const baseChildren = groupBySegment(baseNode?.children ?? [], container, spec); - const revisionChildren = groupBySegment(revisionNode?.children ?? [], container, spec); - - for (const segment of new Set([...baseChildren.keys(), ...revisionChildren.keys()])) { - const left = baseChildren.get(segment) ?? []; - const right = revisionChildren.get(segment) ?? []; - for (let index = 0; index < Math.max(left.length, right.length); index++) { - if (left[index] && right[index] && left[index].type !== right[index].type) { - // The same place holds a different kind of node: a removal and an addition. - pair.children.push(pairUp(left[index], undefined, pair, index + 1)); - pair.children.push(pairUp(undefined, right[index], pair, index + 1)); - } else { - pair.children.push(pairUp(left[index], right[index], pair, index + 1)); - } - } - } - - return pair; - } - - return { root: pairUp(base, revision, null, 1), pairOf }; -} - -function groupBySegment( - nodes: NodeEntry[], - container: string, - spec: DiffSpec -): Map { - const groups = new Map(); - for (const node of nodes) { - const segment = segmentOf(node, container, spec); - groups.set(segment, [...(groups.get(segment) ?? []), node]); - } - return groups; -} - -function segmentOf(node: NodeEntry, container: string, spec: DiffSpec): string { - return spec.identityOf(node, container)?.segment ?? escapePointerFragment(String(node.key)); -} - -/** The report key: the pair's segments joined like a pointer, `#n` marking the n-th sibling with the same segment. */ -export function labelOf(pair: Pair, spec: DiffSpec): string { - if (!pair.parent) return '#/'; - const own = joinPointer( - labelOf(pair.parent, spec), - segmentOf(nodeOf(pair), typeOf(pair.parent), spec) - ); - return pair.occurrence === 1 ? own : `${own}#${pair.occurrence}`; -} diff --git a/packages/core/src/diff/rules/__tests__/constraints.test.ts b/packages/core/src/diff/rules/__tests__/constraints.test.ts deleted file mode 100644 index fadc1d8481..0000000000 --- a/packages/core/src/diff/rules/__tests__/constraints.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { isScalarArray } from '../../../utils/is-scalar.js'; -import { - constraintDirection, - effectiveTypes, - isTypeSetNarrowed, - isTypeSetWidened, -} from '../constraints.js'; - -describe('diff constraints', () => { - 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/core/src/diff/rules/__tests__/schema-type-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts new file mode 100644 index 0000000000..468fc7531c --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts @@ -0,0 +1,29 @@ +import { acceptedTypes, typesNotIn } from '../schema-type-changed.js'; + +const lost = (before: unknown, after: unknown) => + typesNotIn(acceptedTypes(before), acceptedTypes(after)); + +describe('schema-type-changed', () => { + it('finds the types one side accepts and the other does not', () => { + // integer → number accepts more and loses nothing. + expect(lost('integer', 'number')).toEqual([]); + expect(lost('number', 'integer')).toEqual(['number']); + // string → number is incompatible both ways. + expect(lost('string', 'number')).toEqual(['string']); + expect(lost('number', 'string')).toEqual(['number']); + // Adding a type loses nothing; dropping one loses it. + expect(lost('string', ['string', 'number'])).toEqual([]); + expect(lost(['string', 'number'], 'string')).toEqual(['number']); + }); + + it('reads 3.0 `nullable` as the 3.1 null type, so the two spellings match', () => { + const from30 = acceptedTypes('string', true); + const from31 = acceptedTypes(['string', 'null']); + + expect(from30).toEqual(['string', 'null']); + expect(typesNotIn(from30, from31)).toEqual([]); + expect(typesNotIn(from31, from30)).toEqual([]); + // Dropping nullability loses the null type. + expect(typesNotIn(from30, acceptedTypes('string'))).toEqual(['null']); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/utils.test.ts b/packages/core/src/diff/rules/__tests__/utils.test.ts new file mode 100644 index 0000000000..6ad9a07ebb --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/utils.test.ts @@ -0,0 +1,20 @@ +import { acceptsLess } from '../utils.js'; + +describe('acceptsLess', () => { + it('tells a constraint that accepts less from one that accepts more', () => { + // A bound that leaves less room accepts less. + expect(acceptsLess('maxLength', 100, 10)).toBe(true); + expect(acceptsLess('maxLength', 10, 100)).toBe(false); + expect(acceptsLess('minimum', 0, 10)).toBe(true); + expect(acceptsLess('minimum', 10, 0)).toBe(false); + // Presence alone decides when one side has no constraint. + expect(acceptsLess('maxLength', undefined, 10)).toBe(true); + expect(acceptsLess('maxLength', 10, undefined)).toBe(false); + // Equivalence of a pattern or format cannot be computed, so assume the worst. + expect(acceptsLess('pattern', '^a', '^b')).toBe(true); + expect(acceptsLess('format', undefined, 'uuid')).toBe(true); + // Closing an open object accepts less; opening it accepts more. + expect(acceptsLess('additionalProperties', true, false)).toBe(true); + expect(acceptsLess('additionalProperties', false, true)).toBe(false); + }); +}); diff --git a/packages/core/src/diff/rules/additional-properties-changed.ts b/packages/core/src/diff/rules/additional-properties-changed.ts new file mode 100644 index 0000000000..696212bf38 --- /dev/null +++ b/packages/core/src/diff/rules/additional-properties-changed.ts @@ -0,0 +1,3 @@ +import { constraintRule } from './utils.js'; + +export const AdditionalPropertiesChanged = constraintRule(['additionalProperties']); diff --git a/packages/core/src/diff/rules/channel-address-changed.ts b/packages/core/src/diff/rules/channel-address-changed.ts new file mode 100644 index 0000000000..8f591e9471 --- /dev/null +++ b/packages/core/src/diff/rules/channel-address-changed.ts @@ -0,0 +1,10 @@ +import type { DiffRule } from '../types.js'; + +export const ChannelAddressChanged: DiffRule = () => ({ + Channel(change, { report }) { + if (change.kind !== 'modified' || change.property !== 'address') return; + report({ + message: `The channel address changed from '${change.base.value}' to '${change.revision.value}'.`, + }); + }, +}); diff --git a/packages/core/src/diff/rules/channel.ts b/packages/core/src/diff/rules/channel-removed.ts similarity index 53% rename from packages/core/src/diff/rules/channel.ts rename to packages/core/src/diff/rules/channel-removed.ts index caecde5fc5..19e9267a83 100644 --- a/packages/core/src/diff/rules/channel.ts +++ b/packages/core/src/diff/rules/channel-removed.ts @@ -1,7 +1,7 @@ import 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 direction. +// A channel is where AsyncAPI messages travel, so both sides of it break together and the +// rule does not read the direction. export const ChannelRemoved: DiffRule = () => ({ Channel(change, { report }) { if (change.kind === 'removed') report({ message: 'The channel was removed.' }); @@ -10,12 +10,3 @@ export const ChannelRemoved: DiffRule = () => ({ if (change.kind === 'removed') report({ message: 'Every channel was removed.' }); }, }); - -export const ChannelAddressChanged: DiffRule = () => ({ - Channel(change, { report }) { - if (change.kind !== 'modified' || change.property !== 'address') return; - report({ - message: `The channel address changed from '${change.base.value}' to '${change.revision.value}'.`, - }); - }, -}); diff --git a/packages/core/src/diff/rules/constraints.ts b/packages/core/src/diff/rules/constraints.ts deleted file mode 100644 index 691a2d80d9..0000000000 --- a/packages/core/src/diff/rules/constraints.ts +++ /dev/null @@ -1,100 +0,0 @@ -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/core/src/diff/rules/enum-values-added.ts b/packages/core/src/diff/rules/enum-values-added.ts new file mode 100644 index 0000000000..8bc4f38c9f --- /dev/null +++ b/packages/core/src/diff/rules/enum-values-added.ts @@ -0,0 +1,11 @@ +import type { DiffRule } from '../types.js'; +import { itemsOnlyIn } from './utils.js'; + +export const EnumValuesAdded: DiffRule = () => ({ + Schema(change, { report, directions }) { + if (change.kind !== 'modified' || change.property !== 'enum') return; + if (!directions.includes('response')) return; + const added = itemsOnlyIn(change.revision.value, change.base.value); + if (added.length) report({ message: `Enum values added: ${added.join(', ')}.` }); + }, +}); diff --git a/packages/core/src/diff/rules/enum-values-removed.ts b/packages/core/src/diff/rules/enum-values-removed.ts new file mode 100644 index 0000000000..290c044ce4 --- /dev/null +++ b/packages/core/src/diff/rules/enum-values-removed.ts @@ -0,0 +1,11 @@ +import type { DiffRule } from '../types.js'; +import { itemsOnlyIn } from './utils.js'; + +export const EnumValuesRemoved: DiffRule = () => ({ + Schema(change, { report, directions }) { + if (change.kind !== 'modified' || change.property !== 'enum') return; + if (!directions.includes('request')) return; + const removed = itemsOnlyIn(change.base.value, change.revision.value); + if (removed.length) report({ message: `Enum values removed: ${removed.join(', ')}.` }); + }, +}); diff --git a/packages/core/src/diff/rules/index.ts b/packages/core/src/diff/rules/index.ts index 50cb1a21c4..6abcfcc412 100644 --- a/packages/core/src/diff/rules/index.ts +++ b/packages/core/src/diff/rules/index.ts @@ -1,36 +1,37 @@ import type { DiffRule } from '../types.js'; -import { ChannelAddressChanged, ChannelRemoved } from './channel.js'; -import { MessageContentTypeChanged, MessageRemoved } from './message.js'; -import { OperationActionChanged, OperationRemoved, PathRemoved } from './operation.js'; -import { - ParameterAddedRequired, - ParameterBecameRequired, - ParameterRemoved, - ParameterSerializationChanged, -} from './parameter.js'; -import { RefTargetChanged } from './ref.js'; -import { RequestBodyBecameRequired, RequestBodyRemoved } from './request-body.js'; -import { MediaTypeRemoved, ResponseHeaderRemoved, ResponseRemoved } from './response.js'; -import { - AdditionalPropertiesChanged, - EnumValuesAdded, - EnumValuesRemoved, - NumericRangeChanged, - PropertyRemovedFromResponse, - RequiredPropertiesAdded, - RequiredPropertiesRemoved, - SchemaCombinatorChanged, - SchemaFormatChanged, - SchemaTypeChanged, - StringLengthChanged, -} from './schema.js'; -import { - SecurityRequirementAdded, - SecuritySchemeChanged, - SecuritySchemeRemoved, - SecurityScopesAdded, -} from './security.js'; -import { ServerRemoved } from './server.js'; +import { AdditionalPropertiesChanged } from './additional-properties-changed.js'; +import { ChannelAddressChanged } from './channel-address-changed.js'; +import { ChannelRemoved } from './channel-removed.js'; +import { EnumValuesAdded } from './enum-values-added.js'; +import { EnumValuesRemoved } from './enum-values-removed.js'; +import { MediaTypeRemoved } from './media-type-removed.js'; +import { MessageContentTypeChanged } from './message-content-type-changed.js'; +import { MessageRemoved } from './message-removed.js'; +import { NumericRangeChanged } from './numeric-range-changed.js'; +import { OperationActionChanged } from './operation-action-changed.js'; +import { OperationRemoved } from './operation-removed.js'; +import { ParameterAddedRequired } from './parameter-added-required.js'; +import { ParameterBecameRequired } from './parameter-became-required.js'; +import { ParameterRemoved } from './parameter-removed.js'; +import { ParameterSerializationChanged } from './parameter-serialization-changed.js'; +import { PathRemoved } from './path-removed.js'; +import { PropertyRemovedFromResponse } from './property-removed-from-response.js'; +import { RefTargetChanged } from './ref-target-changed.js'; +import { RequestBodyBecameRequired } from './request-body-became-required.js'; +import { RequestBodyRemoved } from './request-body-removed.js'; +import { RequiredPropertiesAdded } from './required-properties-added.js'; +import { RequiredPropertiesRemoved } from './required-properties-removed.js'; +import { ResponseHeaderRemoved } from './response-header-removed.js'; +import { ResponseRemoved } from './response-removed.js'; +import { SchemaCombinatorChanged } from './schema-combinator-changed.js'; +import { SchemaFormatChanged } from './schema-format-changed.js'; +import { SchemaTypeChanged } from './schema-type-changed.js'; +import { SecurityRequirementAdded } from './security-requirement-added.js'; +import { SecuritySchemeChanged } from './security-scheme-changed.js'; +import { SecuritySchemeRemoved } from './security-scheme-removed.js'; +import { SecurityScopesAdded } from './security-scopes-added.js'; +import { ServerRemoved } from './server-removed.js'; +import { StringLengthChanged } from './string-length-changed.js'; /** * Every rule over a `Schema` node, shared by both registries: an AsyncAPI payload is the same diff --git a/packages/core/src/diff/rules/media-type-removed.ts b/packages/core/src/diff/rules/media-type-removed.ts new file mode 100644 index 0000000000..fcdca42a63 --- /dev/null +++ b/packages/core/src/diff/rules/media-type-removed.ts @@ -0,0 +1,7 @@ +import type { DiffRule } from '../types.js'; + +export const MediaTypeRemoved: DiffRule = () => ({ + MediaType(change, { report }) { + if (change.kind === 'removed') report({ message: 'Media type was removed.' }); + }, +}); diff --git a/packages/core/src/diff/rules/message-content-type-changed.ts b/packages/core/src/diff/rules/message-content-type-changed.ts new file mode 100644 index 0000000000..1d4496741b --- /dev/null +++ b/packages/core/src/diff/rules/message-content-type-changed.ts @@ -0,0 +1,10 @@ +import type { DiffRule } from '../types.js'; + +export const MessageContentTypeChanged: DiffRule = () => ({ + Message(change, { report }) { + if (change.kind !== 'modified' || change.property !== 'contentType') return; + report({ + message: `The message content type changed from '${change.base.value}' to '${change.revision.value}'.`, + }); + }, +}); diff --git a/packages/core/src/diff/rules/message.ts b/packages/core/src/diff/rules/message-removed.ts similarity index 53% rename from packages/core/src/diff/rules/message.ts rename to packages/core/src/diff/rules/message-removed.ts index 3192b60438..70f21678b1 100644 --- a/packages/core/src/diff/rules/message.ts +++ b/packages/core/src/diff/rules/message-removed.ts @@ -8,12 +8,3 @@ export const MessageRemoved: DiffRule = () => ({ if (change.kind === 'removed') report({ message: 'Every message of the channel was removed.' }); }, }); - -export const MessageContentTypeChanged: DiffRule = () => ({ - Message(change, { report }) { - if (change.kind !== 'modified' || change.property !== 'contentType') return; - report({ - message: `The message content type changed from '${change.base.value}' to '${change.revision.value}'.`, - }); - }, -}); diff --git a/packages/core/src/diff/rules/numeric-range-changed.ts b/packages/core/src/diff/rules/numeric-range-changed.ts new file mode 100644 index 0000000000..3499276fbc --- /dev/null +++ b/packages/core/src/diff/rules/numeric-range-changed.ts @@ -0,0 +1,9 @@ +import { constraintRule } from './utils.js'; + +export const NumericRangeChanged = constraintRule([ + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + 'multipleOf', +]); diff --git a/packages/core/src/diff/rules/operation.ts b/packages/core/src/diff/rules/operation-action-changed.ts similarity index 57% rename from packages/core/src/diff/rules/operation.ts rename to packages/core/src/diff/rules/operation-action-changed.ts index 4dd0422608..ee67f515a6 100644 --- a/packages/core/src/diff/rules/operation.ts +++ b/packages/core/src/diff/rules/operation-action-changed.ts @@ -1,17 +1,5 @@ import type { DiffRule } from '../types.js'; -export const OperationRemoved: DiffRule = () => ({ - Operation(change, { report }) { - if (change.kind === 'removed') report({ message: 'Operation was removed.' }); - }, -}); - -export const PathRemoved: DiffRule = () => ({ - PathItem(change, { report }) { - if (change.kind === 'removed') report({ message: '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 = () => ({ diff --git a/packages/core/src/diff/rules/operation-removed.ts b/packages/core/src/diff/rules/operation-removed.ts new file mode 100644 index 0000000000..e2f5bfe871 --- /dev/null +++ b/packages/core/src/diff/rules/operation-removed.ts @@ -0,0 +1,7 @@ +import type { DiffRule } from '../types.js'; + +export const OperationRemoved: DiffRule = () => ({ + Operation(change, { report }) { + if (change.kind === 'removed') report({ message: 'Operation was removed.' }); + }, +}); diff --git a/packages/core/src/diff/rules/parameter-added-required.ts b/packages/core/src/diff/rules/parameter-added-required.ts new file mode 100644 index 0000000000..128b37840d --- /dev/null +++ b/packages/core/src/diff/rules/parameter-added-required.ts @@ -0,0 +1,23 @@ +import { isPlainObject } from '../../utils/is-plain-object.js'; +import type { DiffRule } from '../types.js'; + +function hasRequiredParameter(parameters: unknown[]): boolean { + return parameters.some((parameter) => isPlainObject(parameter) && parameter.required === true); +} + +// The first parameter of an operation arrives with the whole `parameters` list, so that change +// lands on the list rather than on a parameter. +export const ParameterAddedRequired: DiffRule = () => ({ + Parameter(change, { report, directions }) { + if (change.kind !== 'added' || !directions.includes('request')) return; + if (hasRequiredParameter([change.revision.value])) { + report({ message: 'A new required parameter was added.' }); + } + }, + ParameterList(change, { report, directions }) { + if (change.kind !== 'added' || !directions.includes('request')) return; + if (Array.isArray(change.revision.value) && hasRequiredParameter(change.revision.value)) { + report({ message: 'A new required parameter was added.' }); + } + }, +}); diff --git a/packages/core/src/diff/rules/parameter-became-required.ts b/packages/core/src/diff/rules/parameter-became-required.ts new file mode 100644 index 0000000000..07e5026146 --- /dev/null +++ b/packages/core/src/diff/rules/parameter-became-required.ts @@ -0,0 +1,10 @@ +import type { DiffRule } from '../types.js'; + +export const ParameterBecameRequired: DiffRule = () => ({ + Parameter(change, { report, directions }) { + if (change.kind !== 'modified' || change.property !== 'required') return; + if (directions.includes('request') && change.revision.value === true) { + report({ message: 'Parameter became required.' }); + } + }, +}); diff --git a/packages/core/src/diff/rules/parameter-removed.ts b/packages/core/src/diff/rules/parameter-removed.ts new file mode 100644 index 0000000000..014b8f45e1 --- /dev/null +++ b/packages/core/src/diff/rules/parameter-removed.ts @@ -0,0 +1,16 @@ +import type { DiffRule } from '../types.js'; + +// The last parameter of an operation leaves with the whole `parameters` list, so that change +// lands on the list rather than on a parameter. +export const ParameterRemoved: DiffRule = () => ({ + Parameter(change, { report, directions }) { + if (change.kind === 'removed' && directions.includes('request')) { + report({ message: 'Parameter was removed.' }); + } + }, + ParameterList(change, { report, directions }) { + if (change.kind === 'removed' && directions.includes('request')) { + report({ message: 'Every parameter was removed.' }); + } + }, +}); diff --git a/packages/core/src/diff/rules/parameter-serialization-changed.ts b/packages/core/src/diff/rules/parameter-serialization-changed.ts new file mode 100644 index 0000000000..b7c31d52f6 --- /dev/null +++ b/packages/core/src/diff/rules/parameter-serialization-changed.ts @@ -0,0 +1,15 @@ +import type { DiffRule } from '../types.js'; + +// How a value is put on the wire is part of the contract: a client that encoded +// the old way is not understood after the change. +const SERIALIZATION = new Set(['style', 'explode', 'allowReserved', 'allowEmptyValue']); + +export const ParameterSerializationChanged: DiffRule = () => ({ + Parameter(change, { report, directions }) { + if (change.kind !== 'modified' || !SERIALIZATION.has(change.property)) return; + if (!directions.includes('request')) return; + report({ + message: `Parameter \`${change.property}\` changed from '${change.base.value}' to '${change.revision.value}'.`, + }); + }, +}); diff --git a/packages/core/src/diff/rules/parameter.ts b/packages/core/src/diff/rules/parameter.ts deleted file mode 100644 index 2e903d1d5e..0000000000 --- a/packages/core/src/diff/rules/parameter.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { isPlainObject } from '../../utils/is-plain-object.js'; -import type { DiffRule } from '../types.js'; -import { becameTrue } from './constraints.js'; - -// 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 = () => ({ - Parameter(change, { report, direction }) { - if (change.kind === 'removed' && direction === 'request') { - report({ message: 'Parameter was removed.' }); - } - }, - ParameterList(change, { report, direction }) { - if (change.kind === 'removed' && direction === 'request') { - report({ message: 'Every parameter was removed.' }); - } - }, -}); - -function hasRequiredParameter(parameters: unknown[]): boolean { - return parameters.some((parameter) => isPlainObject(parameter) && parameter.required === true); -} - -export const ParameterAddedRequired: DiffRule = () => ({ - Parameter(change, { report, direction }) { - if (change.kind !== 'added' || direction !== 'request') return; - if (hasRequiredParameter([change.revision.value])) { - report({ message: 'A new required parameter was added.' }); - } - }, - ParameterList(change, { report, direction }) { - if (change.kind !== 'added' || direction !== 'request') return; - if (Array.isArray(change.revision.value) && hasRequiredParameter(change.revision.value)) { - report({ message: 'A new required parameter was added.' }); - } - }, -}); - -export const ParameterBecameRequired: DiffRule = () => ({ - Parameter(change, { report, direction }) { - if (change.kind !== 'modified' || change.property !== 'required' || direction !== 'request') - return; - if (becameTrue(change.base.value, change.revision.value)) { - report({ message: 'Parameter became required.' }); - } - }, -}); - -// How a value is put on the wire is part of the contract: a client that encoded -// the old way is not understood after the change. -const SERIALIZATION = new Set(['style', 'explode', 'allowReserved', 'allowEmptyValue']); - -export const ParameterSerializationChanged: DiffRule = () => ({ - Parameter(change, { report, direction }) { - if ( - change.kind !== 'modified' || - !SERIALIZATION.has(change.property) || - direction !== 'request' - ) - return; - report({ - message: `Parameter \`${change.property}\` changed from '${change.base.value}' to '${change.revision.value}'.`, - }); - }, -}); diff --git a/packages/core/src/diff/rules/path-removed.ts b/packages/core/src/diff/rules/path-removed.ts new file mode 100644 index 0000000000..7a54929162 --- /dev/null +++ b/packages/core/src/diff/rules/path-removed.ts @@ -0,0 +1,7 @@ +import type { DiffRule } from '../types.js'; + +export const PathRemoved: DiffRule = () => ({ + PathItem(change, { report }) { + if (change.kind === 'removed') report({ message: 'Path was removed.' }); + }, +}); diff --git a/packages/core/src/diff/rules/property-removed-from-response.ts b/packages/core/src/diff/rules/property-removed-from-response.ts new file mode 100644 index 0000000000..4f54fa5b28 --- /dev/null +++ b/packages/core/src/diff/rules/property-removed-from-response.ts @@ -0,0 +1,12 @@ +import type { DiffRule } from '../types.js'; + +// A member of a `properties` map; an alternative of a `oneOf` is a schema too, but not a property. +export const PropertyRemovedFromResponse: DiffRule = () => ({ + SchemaProperties: { + Schema(change, { report, directions }) { + if (change.kind === 'removed' && directions.includes('response')) { + report({ message: 'Schema property was removed.' }); + } + }, + }, +}); diff --git a/packages/core/src/diff/rules/ref.ts b/packages/core/src/diff/rules/ref-target-changed.ts similarity index 100% rename from packages/core/src/diff/rules/ref.ts rename to packages/core/src/diff/rules/ref-target-changed.ts diff --git a/packages/core/src/diff/rules/request-body-became-required.ts b/packages/core/src/diff/rules/request-body-became-required.ts new file mode 100644 index 0000000000..8aa3ca7ce4 --- /dev/null +++ b/packages/core/src/diff/rules/request-body-became-required.ts @@ -0,0 +1,10 @@ +import type { DiffRule } from '../types.js'; + +export const RequestBodyBecameRequired: DiffRule = () => ({ + RequestBody(change, { report, directions }) { + if (change.kind !== 'modified' || change.property !== 'required') return; + if (directions.includes('request') && change.revision.value === true) { + report({ message: 'The request body became required.' }); + } + }, +}); diff --git a/packages/core/src/diff/rules/request-body-removed.ts b/packages/core/src/diff/rules/request-body-removed.ts new file mode 100644 index 0000000000..7993e7efad --- /dev/null +++ b/packages/core/src/diff/rules/request-body-removed.ts @@ -0,0 +1,9 @@ +import type { DiffRule } from '../types.js'; + +export const RequestBodyRemoved: DiffRule = () => ({ + RequestBody(change, { report, directions }) { + if (change.kind === 'removed' && directions.includes('request')) { + report({ message: 'The request body was removed.' }); + } + }, +}); diff --git a/packages/core/src/diff/rules/request-body.ts b/packages/core/src/diff/rules/request-body.ts deleted file mode 100644 index 674007680e..0000000000 --- a/packages/core/src/diff/rules/request-body.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { DiffRule } from '../types.js'; -import { becameTrue } from './constraints.js'; - -export const RequestBodyBecameRequired: DiffRule = () => ({ - RequestBody(change, { report, direction }) { - if (change.kind !== 'modified' || change.property !== 'required' || direction !== 'request') - return; - if (becameTrue(change.base.value, change.revision.value)) { - report({ message: 'The request body became required.' }); - } - }, -}); - -export const RequestBodyRemoved: DiffRule = () => ({ - RequestBody(change, { report, direction }) { - if (change.kind === 'removed' && direction === 'request') { - report({ message: 'The request body was removed.' }); - } - }, -}); diff --git a/packages/core/src/diff/rules/required-properties-added.ts b/packages/core/src/diff/rules/required-properties-added.ts new file mode 100644 index 0000000000..239ac65751 --- /dev/null +++ b/packages/core/src/diff/rules/required-properties-added.ts @@ -0,0 +1,11 @@ +import type { DiffRule } from '../types.js'; +import { itemsOnlyIn } from './utils.js'; + +export const RequiredPropertiesAdded: DiffRule = () => ({ + Schema(change, { report, directions }) { + if (change.kind !== 'modified' || change.property !== 'required') return; + if (!directions.includes('request')) return; + const added = itemsOnlyIn(change.revision.value, change.base.value); + if (added.length) report({ message: `Properties became required: ${added.join(', ')}.` }); + }, +}); diff --git a/packages/core/src/diff/rules/required-properties-removed.ts b/packages/core/src/diff/rules/required-properties-removed.ts new file mode 100644 index 0000000000..ea220aa347 --- /dev/null +++ b/packages/core/src/diff/rules/required-properties-removed.ts @@ -0,0 +1,13 @@ +import type { DiffRule } from '../types.js'; +import { itemsOnlyIn } from './utils.js'; + +export const RequiredPropertiesRemoved: DiffRule = () => ({ + Schema(change, { report, directions }) { + if (change.kind !== 'modified' || change.property !== 'required') return; + if (!directions.includes('response')) return; + const removed = itemsOnlyIn(change.base.value, change.revision.value); + if (removed.length) { + report({ message: `Properties are no longer required: ${removed.join(', ')}.` }); + } + }, +}); diff --git a/packages/core/src/diff/rules/response-header-removed.ts b/packages/core/src/diff/rules/response-header-removed.ts new file mode 100644 index 0000000000..711a19dc8d --- /dev/null +++ b/packages/core/src/diff/rules/response-header-removed.ts @@ -0,0 +1,16 @@ +import type { DiffRule } from '../types.js'; + +// Dropping every header collapses into a single change on the map, dropping one lands on +// the header itself. +export const ResponseHeaderRemoved: DiffRule = () => ({ + Header(change, { report, directions }) { + if (change.kind === 'removed' && directions.includes('response')) { + report({ message: 'A response header was removed.' }); + } + }, + HeadersMap(change, { report, directions }) { + if (change.kind === 'removed' && directions.includes('response')) { + report({ message: 'The response headers were removed.' }); + } + }, +}); diff --git a/packages/core/src/diff/rules/response-removed.ts b/packages/core/src/diff/rules/response-removed.ts new file mode 100644 index 0000000000..218b29c680 --- /dev/null +++ b/packages/core/src/diff/rules/response-removed.ts @@ -0,0 +1,7 @@ +import type { DiffRule } from '../types.js'; + +export const ResponseRemoved: DiffRule = () => ({ + Response(change, { report }) { + if (change.kind === 'removed') report({ message: 'Response was removed.' }); + }, +}); diff --git a/packages/core/src/diff/rules/response.ts b/packages/core/src/diff/rules/response.ts deleted file mode 100644 index cd5fdf9862..0000000000 --- a/packages/core/src/diff/rules/response.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { DiffRule } from '../types.js'; - -export const ResponseRemoved: DiffRule = () => ({ - Response(change, { report }) { - if (change.kind === 'removed') report({ message: 'Response was removed.' }); - }, -}); - -export const MediaTypeRemoved: DiffRule = () => ({ - MediaType(change, { report }) { - if (change.kind === 'removed') report({ message: 'Media type was removed.' }); - }, -}); - -// Dropping every header collapses into a single change on the map, dropping one lands on -// the header itself. -export const ResponseHeaderRemoved: DiffRule = () => ({ - Header(change, { report, direction }) { - if (change.kind === 'removed' && direction === 'response') { - report({ message: 'A response header was removed.' }); - } - }, - HeadersMap(change, { report, direction }) { - if (change.kind === 'removed' && direction === 'response') { - report({ message: 'The response headers were removed.' }); - } - }, -}); diff --git a/packages/core/src/diff/rules/schema-combinator-changed.ts b/packages/core/src/diff/rules/schema-combinator-changed.ts new file mode 100644 index 0000000000..de0d402dd2 --- /dev/null +++ b/packages/core/src/diff/rules/schema-combinator-changed.ts @@ -0,0 +1,23 @@ +import type { DiffRule, DiffVisit } from '../types.js'; +import { breakingDirection } from './utils.js'; + +// `oneOf`/`anyOf` list alternatives, so dropping one accepts less; `allOf` combines +// constraints, so adding one accepts less. Only the OpenAPI type tree names each list after +// its keyword; the shared JSON Schema tree calls them all `SchemaList`, so there the rule +// could not tell the keywords apart and is not registered. +function combinatorChanged(combinator: 'allOf' | 'anyOf' | 'oneOf'): DiffVisit { + return (change, { report, directions }) => { + if (change.kind === 'modified') return; + const removed = change.kind === 'removed'; + const acceptsLess = combinator === 'allOf' ? !removed : removed; + if (directions.includes(breakingDirection(acceptsLess))) { + report({ message: `A \`${combinator}\` subschema was ${removed ? 'removed' : 'added'}.` }); + } + }; +} + +export const SchemaCombinatorChanged: DiffRule = () => ({ + AllOf: { Schema: combinatorChanged('allOf') }, + AnyOf: { Schema: combinatorChanged('anyOf') }, + OneOf: { Schema: combinatorChanged('oneOf') }, +}); diff --git a/packages/core/src/diff/rules/schema-format-changed.ts b/packages/core/src/diff/rules/schema-format-changed.ts new file mode 100644 index 0000000000..75802d3a4f --- /dev/null +++ b/packages/core/src/diff/rules/schema-format-changed.ts @@ -0,0 +1,3 @@ +import { constraintRule } from './utils.js'; + +export const SchemaFormatChanged = constraintRule(['format']); diff --git a/packages/core/src/diff/rules/schema-type-changed.ts b/packages/core/src/diff/rules/schema-type-changed.ts new file mode 100644 index 0000000000..2cead37231 --- /dev/null +++ b/packages/core/src/diff/rules/schema-type-changed.ts @@ -0,0 +1,44 @@ +import { fieldOf } from '../../node-tree/access.js'; +import type { DiffRule } from '../types.js'; + +// `integer` accepts a subset of what `number` does, so it is the one implicit widening among +// the JSON Schema primitive types. +const WIDER_TYPE: Partial> = { integer: 'number' }; + +/** + * The types a schema accepts, folding OpenAPI 3.0's `nullable: true` into the 3.1 spelling + * (`type: [..., 'null']`) so the two compare as equal. + */ +export function acceptedTypes(type: unknown, nullable?: unknown): string[] { + const declared = Array.isArray(type) ? type : type === undefined ? [] : [type]; + const types = declared.filter((value): value is string => typeof value === 'string'); + return nullable === true && !types.includes('null') ? [...types, 'null'] : types; +} + +/** The types of `types` that `accepted` does not take. */ +export function typesNotIn(types: string[], accepted: string[]): string[] { + return types.filter((type) => { + const wider = WIDER_TYPE[type]; + return !accepted.includes(type) && (wider === undefined || !accepted.includes(wider)); + }); +} + +export const SchemaTypeChanged: DiffRule = () => ({ + Schema(change, { report, directions }) { + if (change.kind !== 'modified' || 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 = acceptedTypes(change.base.value, fieldOf(change.node.base, 'nullable')); + const after = acceptedTypes(change.revision.value, fieldOf(change.node.revision, 'nullable')); + // An absent `type` accepts anything, so there is nothing to narrow or widen. + if (!before.length || !after.length) return; + const described = `from '${before.join(' | ')}' to '${after.join(' | ')}'`; + + if (directions.includes('request') && typesNotIn(before, after).length) { + report({ message: `Schema type narrowed ${described}.` }); + } + if (directions.includes('response') && typesNotIn(after, before).length) { + report({ message: `Schema type widened ${described}.` }); + } + }, +}); diff --git a/packages/core/src/diff/rules/schema.ts b/packages/core/src/diff/rules/schema.ts deleted file mode 100644 index 1e8e95d366..0000000000 --- a/packages/core/src/diff/rules/schema.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { fieldOf } from '../../node-map/access.js'; -import type { DiffRule, DiffVisit, Direction } from '../types.js'; -import { - addedItems, - constraintDirection, - effectiveTypes, - isTypeSetNarrowed, - isTypeSetWidened, - missingItems, - type ConstraintDirection, -} from './constraints.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 breaks(moved: ConstraintDirection, direction: Direction): boolean { - return ( - (moved === 'tighter' && direction === 'request') || - (moved === 'looser' && direction === 'response') - ); -} - -export const SchemaTypeChanged: DiffRule = () => ({ - Schema(change, { report, direction }) { - if (change.kind !== 'modified' || 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, fieldOf(change.pair.base, 'nullable')); - const after = effectiveTypes(change.revision.value, fieldOf(change.pair.revision, 'nullable')); - const described = `from '${[...before].join(' | ')}' to '${[...after].join(' | ')}'`; - - if (direction === 'request' && isTypeSetNarrowed(before, after)) { - report({ message: `Schema type narrowed ${described}.` }); - } - if (direction === 'response' && isTypeSetWidened(before, after)) { - report({ message: `Schema type widened ${described}.` }); - } - }, -}); - -export const EnumValuesRemoved: DiffRule = () => ({ - Schema(change, { report, direction }) { - if (change.kind !== 'modified' || change.property !== 'enum' || direction !== 'request') return; - const removed = missingItems(change.base.value, change.revision.value); - if (removed.length) report({ message: `Enum values removed: ${removed.join(', ')}.` }); - }, -}); - -export const EnumValuesAdded: DiffRule = () => ({ - Schema(change, { report, direction }) { - if (change.kind !== 'modified' || change.property !== 'enum' || direction !== 'response') - return; - const added = addedItems(change.base.value, change.revision.value); - if (added.length) report({ message: `Enum values added: ${added.join(', ')}.` }); - }, -}); - -export const RequiredPropertiesAdded: DiffRule = () => ({ - Schema(change, { report, direction }) { - if (change.kind !== 'modified' || change.property !== 'required' || direction !== 'request') - return; - const added = addedItems(change.base.value, change.revision.value); - if (added.length) report({ message: `Properties became required: ${added.join(', ')}.` }); - }, -}); - -export const RequiredPropertiesRemoved: DiffRule = () => ({ - Schema(change, { report, direction }) { - if (change.kind !== 'modified' || change.property !== 'required' || direction !== 'response') - return; - const removed = missingItems(change.base.value, change.revision.value); - if (removed.length) - report({ message: `Properties are no longer required: ${removed.join(', ')}.` }); - }, -}); - -// A member of a `properties` map; an alternative of a `oneOf` is a schema too, but not a property. -export const PropertyRemovedFromResponse: DiffRule = () => ({ - SchemaProperties: { - Schema(change, { report, direction }) { - if (change.kind === 'removed' && direction === 'response') { - report({ message: '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 direction, decides the verdict. The groups stay - * separate rules so a report can name the constraint that actually moved. - */ -function constraintRule(properties: string[]): DiffRule { - const watched = new Set(properties); - return () => ({ - Schema(change, { report, direction }) { - if (change.kind !== 'modified' || !watched.has(change.property)) return; - const before = change.base.value; - const after = change.revision.value; - if (breaks(constraintDirection(change.property, before, after), direction)) { - report({ message: describeConstraint(change.property, before, after) }); - } - }, - }); -} - -export const NumericRangeChanged = constraintRule([ - 'minimum', - 'maximum', - 'exclusiveMinimum', - 'exclusiveMaximum', - 'multipleOf', -]); -export const StringLengthChanged = constraintRule(['minLength', 'maxLength', 'pattern']); -export const SchemaFormatChanged = constraintRule(['format']); -export const AdditionalPropertiesChanged = constraintRule(['additionalProperties']); - -// `oneOf`/`anyOf` list alternatives, so dropping one accepts less; `allOf` combines -// constraints, so adding one accepts less. Only the OpenAPI type tree names each list after -// its keyword; the shared JSON Schema tree calls them all `SchemaList`, so there the rule -// could not tell the keywords apart and is not registered. -function combinatorChanged(combinator: 'allOf' | 'anyOf' | 'oneOf'): DiffVisit { - return (change, { report, direction }) => { - if (change.kind === 'modified') return; - const removed = change.kind === 'removed'; - const acceptsLess = combinator === 'allOf' ? !removed : removed; - if (breaks(acceptsLess ? 'tighter' : 'looser', direction)) { - report({ message: `A \`${combinator}\` subschema was ${removed ? 'removed' : 'added'}.` }); - } - }; -} - -export const SchemaCombinatorChanged: DiffRule = () => ({ - AllOf: { Schema: combinatorChanged('allOf') }, - AnyOf: { Schema: combinatorChanged('anyOf') }, - OneOf: { Schema: combinatorChanged('oneOf') }, -}); diff --git a/packages/core/src/diff/rules/security-requirement-added.ts b/packages/core/src/diff/rules/security-requirement-added.ts new file mode 100644 index 0000000000..24312df5fc --- /dev/null +++ b/packages/core/src/diff/rules/security-requirement-added.ts @@ -0,0 +1,22 @@ +import type { DiffRule } from '../types.js'; + +// Security sits outside the request/response split, so the rule does not read the direction: +// introducing authentication breaks every client either way. + +/** + * A `security` list that appears where there was none lands on the list. `security: []` + * states that no authentication is needed, so a first entry filling that list lands on the + * entry; one more entry in a list that already had one only offers another way to + * authenticate, which no existing client has to follow. + */ +export const SecurityRequirementAdded: DiffRule = () => ({ + SecurityRequirementList(change, { report }) { + if (change.kind === 'added') report({ message: 'The API now requires authentication.' }); + }, + SecurityRequirement(change, { report }) { + const list = change.node.parent?.base?.value; + if (change.kind === 'added' && Array.isArray(list) && list.length === 0) { + report({ message: 'The API now requires authentication.' }); + } + }, +}); diff --git a/packages/core/src/diff/rules/security-scheme-changed.ts b/packages/core/src/diff/rules/security-scheme-changed.ts new file mode 100644 index 0000000000..85a839b18b --- /dev/null +++ b/packages/core/src/diff/rules/security-scheme-changed.ts @@ -0,0 +1,26 @@ +import { fieldOf } from '../../node-tree/access.js'; +import type { DiffRule } from '../types.js'; + +const SCHEME_IDENTITY = new Set([ + 'type', + 'scheme', + 'in', + 'name', + 'bearerFormat', + 'openIdConnectUrl', +]); + +export const SecuritySchemeChanged: DiffRule = () => ({ + SecurityScheme(change, { report }) { + if (change.kind !== 'modified' || !SCHEME_IDENTITY.has(change.property)) return; + + // Switching the scheme's `type` drags its other fields along (an apiKey has + // `in`/`name`, a bearer has `scheme`), so the type change speaks for them all. + const typeChanged = fieldOf(change.node.base, 'type') !== fieldOf(change.node.revision, 'type'); + if (typeChanged && change.property !== 'type') return; + + report({ + message: `Security scheme \`${change.property}\` changed from '${change.base.value}' to '${change.revision.value}'.`, + }); + }, +}); diff --git a/packages/core/src/diff/rules/security-scheme-removed.ts b/packages/core/src/diff/rules/security-scheme-removed.ts new file mode 100644 index 0000000000..b1fe29ece3 --- /dev/null +++ b/packages/core/src/diff/rules/security-scheme-removed.ts @@ -0,0 +1,7 @@ +import type { DiffRule } from '../types.js'; + +export const SecuritySchemeRemoved: DiffRule = () => ({ + SecurityScheme(change, { report }) { + if (change.kind === 'removed') report({ message: 'A security scheme was removed.' }); + }, +}); diff --git a/packages/core/src/diff/rules/security-scopes-added.ts b/packages/core/src/diff/rules/security-scopes-added.ts new file mode 100644 index 0000000000..2b32cac0d3 --- /dev/null +++ b/packages/core/src/diff/rules/security-scopes-added.ts @@ -0,0 +1,12 @@ +import type { DiffRule } from '../types.js'; +import { itemsOnlyIn } from './utils.js'; + +export const SecurityScopesAdded: DiffRule = () => ({ + SecurityRequirement(change, { report }) { + // A requirement's properties are scheme names, and each value is its scope list. + if (change.kind !== 'modified') return; + const added = itemsOnlyIn(change.revision.value, change.base.value); + if (!added.length) return; + report({ message: `Scheme \`${change.property}\` requires new scopes: ${added.join(', ')}.` }); + }, +}); diff --git a/packages/core/src/diff/rules/security.ts b/packages/core/src/diff/rules/security.ts deleted file mode 100644 index b4b750c33f..0000000000 --- a/packages/core/src/diff/rules/security.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { fieldOf } from '../../node-map/access.js'; -import type { DiffRule } from '../types.js'; -import { addedItems } from './constraints.js'; - -// Security sits outside the request/response split, so these rules do not read -// the direction: introducing authentication breaks every client either way. - -/** - * A `security` list that appears where there was none lands on the list. `security: []` - * states that no authentication is needed, so a first entry filling that list lands on the - * entry; one more entry in a list that already had one only offers another way to - * authenticate, which no existing client has to follow. - */ -export const SecurityRequirementAdded: DiffRule = () => ({ - SecurityRequirementList: { - enter(change, { report }) { - if (change.kind === 'added') report({ message: 'The API now requires authentication.' }); - }, - SecurityRequirement(change, { report }) { - const list = change.pair.parent?.base?.value; - if (change.kind === 'added' && Array.isArray(list) && list.length === 0) { - report({ message: 'The API now requires authentication.' }); - } - }, - }, -}); - -const SCHEME_IDENTITY = new Set([ - 'type', - 'scheme', - 'in', - 'name', - 'bearerFormat', - 'openIdConnectUrl', -]); - -export const SecuritySchemeChanged: DiffRule = () => ({ - SecurityScheme(change, { report }) { - if (change.kind !== 'modified' || !SCHEME_IDENTITY.has(change.property)) return; - - // Switching the scheme's `type` drags its other fields along (an apiKey has - // `in`/`name`, a bearer has `scheme`), so the type change speaks for them all. - const typeChanged = fieldOf(change.pair.base, 'type') !== fieldOf(change.pair.revision, 'type'); - if (typeChanged && change.property !== 'type') return; - - report({ - message: `Security scheme \`${change.property}\` changed from '${change.base.value}' to '${change.revision.value}'.`, - }); - }, -}); - -export const SecuritySchemeRemoved: DiffRule = () => ({ - SecurityScheme(change, { report }) { - if (change.kind === 'removed') report({ message: 'A security scheme was removed.' }); - }, -}); - -export const SecurityScopesAdded: DiffRule = () => ({ - SecurityRequirement(change, { report }) { - // A requirement's properties are scheme names, and each value is its scope list. - if (change.kind !== 'modified') return; - const added = addedItems(change.base.value, change.revision.value); - if (!added.length) return; - report({ message: `Scheme \`${change.property}\` requires new scopes: ${added.join(', ')}.` }); - }, -}); diff --git a/packages/core/src/diff/rules/server.ts b/packages/core/src/diff/rules/server-removed.ts similarity index 100% rename from packages/core/src/diff/rules/server.ts rename to packages/core/src/diff/rules/server-removed.ts diff --git a/packages/core/src/diff/rules/string-length-changed.ts b/packages/core/src/diff/rules/string-length-changed.ts new file mode 100644 index 0000000000..2ff481a278 --- /dev/null +++ b/packages/core/src/diff/rules/string-length-changed.ts @@ -0,0 +1,3 @@ +import { constraintRule } from './utils.js'; + +export const StringLengthChanged = constraintRule(['minLength', 'maxLength', 'pattern']); diff --git a/packages/core/src/diff/rules/utils.ts b/packages/core/src/diff/rules/utils.ts new file mode 100644 index 0000000000..f871cf75db --- /dev/null +++ b/packages/core/src/diff/rules/utils.ts @@ -0,0 +1,73 @@ +import type { DiffRule, Direction } from '../types.js'; + +/** The items of `list` that `other` does not have. */ +export function itemsOnlyIn(list: unknown, other: unknown): unknown[] { + if (!Array.isArray(list)) return []; + const otherItems = Array.isArray(other) ? other : []; + return list.filter((item) => !otherItems.includes(item)); +} + +/** Accepting less breaks the clients that send the data; accepting more, the ones that read it. */ +export function breakingDirection(acceptsLess: boolean): Direction { + return acceptsLess ? 'request' : 'response'; +} + +const LOWER_BOUNDS = new Set([ + 'minimum', + 'exclusiveMinimum', + 'minLength', + 'minItems', + 'minProperties', +]); +const UPPER_BOUNDS = new Set([ + 'maximum', + 'exclusiveMaximum', + 'maxLength', + 'maxItems', + 'maxProperties', +]); +// Equivalence of these cannot be decided by comparing values, so any change to one is taken +// as accepting less rather than guessed at. +const OPAQUE = new Set(['pattern', 'format', 'multipleOf']); + +/** Whether a schema accepts less once `constraint` moved from `before` to `after`. */ +export function acceptsLess(constraint: string, before: unknown, after: unknown): boolean { + if (before === undefined) return true; // a new constraint + if (after === undefined) return false; // one dropped + // Only opening a closed object accepts more; swapping in a schema is narrower than an open one. + if (constraint === 'additionalProperties') return !(before === false && after === true); + if (OPAQUE.has(constraint)) return true; + + if (typeof before === 'number' && typeof after === 'number') { + if (LOWER_BOUNDS.has(constraint)) return after > before; + if (UPPER_BOUNDS.has(constraint)) return after < before; + } + // `exclusiveMinimum`/`exclusiveMaximum` are booleans in OpenAPI 3.0. + if (typeof before === 'boolean' && typeof after === 'boolean') return after; + + return true; +} + +function describeConstraint(constraint: string, before: unknown, after: unknown): string { + if (before === undefined) return `\`${constraint}\` was added with value '${after}'.`; + if (after === undefined) return `\`${constraint}\` was removed.`; + return `\`${constraint}\` changed from '${before}' to '${after}'.`; +} + +/** + * A rule over one group of constraints on a value. The groups stay separate rules so a report + * can name the constraint that actually moved. + */ +export function constraintRule(constraints: string[]): DiffRule { + const watched = new Set(constraints); + return () => ({ + Schema(change, { report, directions }) { + if (change.kind !== 'modified' || !watched.has(change.property)) return; + const before = change.base.value; + const after = change.revision.value; + if (directions.includes(breakingDirection(acceptsLess(change.property, before, after)))) { + report({ message: describeConstraint(change.property, before, after) }); + } + }, + }); +} diff --git a/packages/core/src/diff/specs/async3.ts b/packages/core/src/diff/specs/async3.ts index 469f2905cc..da9cf798f6 100644 --- a/packages/core/src/diff/specs/async3.ts +++ b/packages/core/src/diff/specs/async3.ts @@ -1,40 +1,24 @@ -import { enclosing, fieldOf } from '../../node-map/access.js'; -import type { NodeEntry } from '../../node-map/types.js'; -import type { DiffSpec, Direction } from '../types.js'; -import { opposite } from './direction.js'; +import { enclosing, fieldOf } from '../../node-tree/access.js'; +import { opposite } from '../direction.js'; +import type { Direction, Directions } from '../types.js'; /** * `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 it is judged as a response. */ -function actionDirection(action: unknown): Direction { +function actionDirection(action: unknown): Direction | undefined { if (action === 'receive') return 'request'; if (action === 'send') return 'response'; - return 'neutral'; + return undefined; } -function operationDirection(node: NodeEntry): Direction { - const operation = enclosing(node, 'Operation'); - if (!operation) return 'neutral'; - - const direction = actionDirection(fieldOf(operation, 'action')); +// Channels and their messages sit outside the operations, so they take the direction of the +// operations that reference them. +export const async3Directions: Directions = { + Operation: (operation) => actionDirection(fieldOf(operation, 'action')), // A reply answers the operation, so it travels back the other way. - return enclosing(node, 'OperationReply') ? opposite(direction) : direction; -} - -export const async3Spec: DiffSpec = { - // Channels, messages and operations are named maps, so the node's own key is its identity. - identityOf: () => undefined, - // Channels and their messages sit outside the operations, so a change deep inside a payload - // is answered by the nearest ancestor an operation references. - directionOf: (node, fromUsage) => { - const own = operationDirection(node); - if (own !== 'neutral') return own; - - for (let current: NodeEntry | null = node; current; current = current.parent) { - const direction = fromUsage(current); - if (direction !== 'neutral') return direction; - } - return 'neutral'; + OperationReply: (reply) => { + const direction = actionDirection(fieldOf(enclosing(reply, 'Operation'), 'action')); + return direction && opposite(direction); }, }; diff --git a/packages/core/src/diff/specs/direction.ts b/packages/core/src/diff/specs/direction.ts deleted file mode 100644 index d330f7ab4a..0000000000 --- a/packages/core/src/diff/specs/direction.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Direction } from '../types.js'; - -export function mergeDirections(a: Direction, b: Direction): Direction { - if (a === b) return a; - if (a === 'neutral') return b; - if (b === 'neutral') return a; - return 'both'; -} - -export function opposite(direction: Direction): Direction { - if (direction === 'request') return 'response'; - if (direction === 'response') return 'request'; - return direction; -} diff --git a/packages/core/src/diff/specs/index.ts b/packages/core/src/diff/specs/index.ts index 39b98ecf7f..141e44cfcc 100644 --- a/packages/core/src/diff/specs/index.ts +++ b/packages/core/src/diff/specs/index.ts @@ -1,16 +1,13 @@ import type { SpecMajorVersion } from '../../oas-types.js'; -import type { DiffSpec } from '../types.js'; -import { async3Spec } from './async3.js'; -import { oas3Spec } from './oas3.js'; +import type { Directions, Identities } from '../types.js'; +import { async3Directions } from './async3.js'; +import { oas3Directions, oas3Identities } from './oas3.js'; -/** A document of a family without its own spec is still compared, just never judged. */ -export const structuralSpec: DiffSpec = { - identityOf: () => undefined, - directionOf: () => 'neutral', -}; - -/** Only these families have diff rules. */ -export const diffSpecs: Partial> = { - oas3: oas3Spec, - async3: async3Spec, +/** A family missing here is still compared, by its structure alone, and never judged. */ +export const diffSpecs: Partial< + Record +> = { + oas3: { identities: oas3Identities, directions: oas3Directions }, + // Channels, messages and operations are named maps, so a node's own key is its identity. + async3: { identities: {}, directions: async3Directions }, }; diff --git a/packages/core/src/diff/specs/oas3.ts b/packages/core/src/diff/specs/oas3.ts index dc4e292bed..ffc0b07240 100644 --- a/packages/core/src/diff/specs/oas3.ts +++ b/packages/core/src/diff/specs/oas3.ts @@ -1,36 +1,55 @@ -import type { NodeEntry } from '../../node-map/types.js'; +import { enclosing, fieldOf } from '../../node-tree/access.js'; +import type { NodeEntry } from '../../node-tree/types.js'; import { escapePointerFragment } from '../../ref-utils.js'; import { isPlainObject } from '../../utils/is-plain-object.js'; import { templateParameterNames, templateShape } from '../../utils/path-template.js'; -import type { DiffSpec, Direction, NodeIdentity } from '../types.js'; -import { opposite } from './direction.js'; +import { opposite } from '../direction.js'; +import type { Direction, Directions, Identities, NodeIdentity } from '../types.js'; -/** - * Where the specification gives a node an identity of its own, the segment says it instead of - * the position or the name the document happens to use. - */ -const identityByContainer: Record NodeIdentity | undefined> = { - // "Templated paths with the same hierarchy but different templated names MUST NOT exist - // as they are identical" (OpenAPI, Paths Object) — the shape is the identity, the names are not. - Paths: (node) => ({ - segment: escapePointerFragment(templateShape(String(node.key))), - values: { path: node.key }, - }), +export const oas3Identities: Identities = { + Paths: pathIdentity, ParameterList: parameterIdentity, - ServerList: (node) => named(stringField(node, 'url')), - TagList: (node) => named(stringField(node, 'name')), - SecurityRequirementList: (node) => - isPlainObject(node.value) - ? { segment: `{${Object.keys(node.value).sort().map(escapePointerFragment).join('+')}}` } - : undefined, + ServerList: (server) => named(fieldOf(server, 'url')), + TagList: (tag) => named(fieldOf(tag, 'name')), + SecurityRequirementList: schemeNamesIdentity, +}; + +export const oas3Directions: Directions = { + RequestBody: side('request'), + ParameterList: side('request'), + Parameter: side('request'), + Responses: side('response'), + Response: side('response'), }; -function parameterIdentity(node: NodeEntry): NodeIdentity | undefined { - const location = stringField(node, 'in'); - const name = stringField(node, 'name'); - if (location === undefined || name === undefined) return undefined; +/** + * The data a node describes travels `direction` — the other way round under a callback or a + * webhook, which the API sends to the consumer itself. A component is compared at its own + * place, so its position says nothing and only the sites that reference it tell. + */ +function side(direction: Direction) { + return (node: NodeEntry): Direction | undefined => { + if (enclosing(node, 'Components')) return undefined; + const sentByApi = enclosing(node, 'CallbacksMap') ?? enclosing(node, 'WebhooksMap'); + return sentByApi ? opposite(direction) : direction; + }; +} + +// "Templated paths with the same hierarchy but different templated names MUST NOT exist +// as they are identical" (OpenAPI, Paths Object) — the shape is the identity, the names are not. +function pathIdentity(path: NodeEntry): NodeIdentity { + return { + segment: escapePointerFragment(templateShape(String(path.key))), + values: { path: path.key }, + }; +} + +function parameterIdentity(parameter: NodeEntry): NodeIdentity | undefined { + const location = fieldOf(parameter, 'in'); + const name = fieldOf(parameter, 'name'); + if (typeof location !== 'string' || typeof name !== 'string') return undefined; return location === 'path' - ? { segment: pathParameterSegment(name, node) } + ? { segment: pathParameterSegment(name, parameter) } : { segment: `{${escapePointerFragment(location)}:${escapePointerFragment(name)}}` }; } @@ -40,70 +59,18 @@ function parameterIdentity(node: NodeEntry): NodeIdentity | undefined { * identity. A name the template does not contain (an invalid document, or a callback whose * key is a runtime expression) falls back to the name. */ -function pathParameterSegment(name: string, node: NodeEntry): string { - const position = templateParameterNames(pathTemplateOf(node)).indexOf(name); +function pathParameterSegment(name: string, parameter: NodeEntry): string { + const pathTemplate = String(enclosing(parameter, 'PathItem')?.key ?? ''); + const position = templateParameterNames(pathTemplate).indexOf(name); return position === -1 ? `{path:${escapePointerFragment(name)}}` : `{path:${position}}`; } -function pathTemplateOf(node: NodeEntry): string { - for (let current = node.parent; current; current = current.parent) { - if (current.type === 'PathItem') return String(current.key); - } - return ''; -} - -/** - * The reusable component a node belongs to. 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. - */ -function componentRoot(node: NodeEntry): NodeEntry | undefined { - for (let current: NodeEntry | null = node; current; current = current.parent) { - if (current.parent?.parent?.type === 'Components') return current; - } - return undefined; -} - -// 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. Only -// the containing map is listed, never the entry inside it — counting both would flip twice. -const INVERTING_TYPES = new Set(['CallbacksMap', 'WebhooksMap']); - -// Walking out to the root, the outermost answer wins and every inversion above it applies. -function positionDirection(node: NodeEntry): Direction { - let direction: Direction = 'neutral'; - - for (let current: NodeEntry | null = node; current; current = current.parent) { - if (RESPONSE_TYPES.has(current.type)) direction = 'response'; - else if (REQUEST_TYPES.has(current.type)) direction = 'request'; - else if (INVERTING_TYPES.has(current.type)) direction = opposite(direction); - } - - return direction; -} - -export const oas3Spec: DiffSpec = { - identityOf: (node, container) => identityByContainer[container]?.(node), - referenceTarget: componentRoot, - // A component is compared at its own place, so its direction comes from the sites that - // reference it rather than from its own position. - directionOf: (node, fromUsage) => { - const component = componentRoot(node); - return component ? fromUsage(component) : positionDirection(node); - }, -}; - -function stringField(node: NodeEntry, name: string): string | undefined { - const value = isPlainObject(node.value) ? node.value[name] : undefined; - return typeof value === 'string' ? value : undefined; +function schemeNamesIdentity(requirement: NodeEntry): NodeIdentity | undefined { + if (!isPlainObject(requirement.value)) return undefined; + const names = Object.keys(requirement.value).sort().map(escapePointerFragment); + return { segment: `{${names.join('+')}}` }; } -function named(value: string | undefined): NodeIdentity | undefined { - return value === undefined ? undefined : { segment: `{${escapePointerFragment(value)}}` }; +function named(value: unknown): NodeIdentity | undefined { + return typeof value === 'string' ? { segment: `{${escapePointerFragment(value)}}` } : undefined; } diff --git a/packages/core/src/diff/types.ts b/packages/core/src/diff/types.ts index 539b84e350..b285795420 100644 --- a/packages/core/src/diff/types.ts +++ b/packages/core/src/diff/types.ts @@ -1,116 +1,70 @@ -import type { NodeEntry } from '../node-map/types.js'; +import type { NodeEntry } from '../node-tree/types.js'; import type { SpecVersion } from '../oas-types.js'; import type { Location } from '../ref-utils.js'; export type Impact = 'patch' | 'minor' | 'major'; -/** Which way the data in a node travels; decides whether "accepts less" or "returns more" is the breaking side. */ -export type Direction = 'request' | 'response' | 'both' | 'neutral'; +/** Decides which side breaks: a request accepting less, or a response returning more. */ +export type Direction = 'request' | 'response'; -/** The same logical node on both sides; one side is missing when the node was added or removed. */ -export interface Pair { +/** One place in both documents; `base` or `revision` is missing where it was added or removed. */ +export type DiffNode = { base?: NodeEntry; revision?: NodeEntry; - parent: Pair | null; - children: Pair[]; - /** Position among the siblings that share this pair's segment; the label carries it as `#n`. */ - occurrence: number; -} + parent: DiffNode | null; + children: DiffNode[]; + /** Segments from the root joined like a pointer; `#n` marks the n-th sibling with one segment. */ + key: string; +}; -/** What the specification says about a node that the structure alone does not. */ -export interface NodeIdentity { +export type NodeIdentity = { /** Replaces the node's own key when the two documents are matched up. */ segment: string; /** Values the segment hides, compared as if the node carried them. */ values?: Record; -} - -/** Everything the comparison needs to know about one specification family. */ -export interface DiffSpec { - identityOf(node: NodeEntry, container: string): NodeIdentity | undefined; - /** The node a reference to this one lands on, when it is not the node itself. */ - referenceTarget?: (node: NodeEntry) => NodeEntry | undefined; - directionOf(node: NodeEntry, fromUsage: (node: NodeEntry) => Direction): Direction; -} +}; -export interface LocatedNode { - location: Location; - value: unknown; -} +/** Per container type: the identity of a child, where its key or position is not it. */ +export type Identities = Partial NodeIdentity | undefined>>; -interface ChangeBase { - /** The report key, derived from the pair; the pair itself is what a rule reads. */ - key: string; - pair: Pair; -} +/** Per node type: which way the data below it travels; the nearest type that says it wins. */ +export type Directions = Partial< + Record Direction | undefined)> +>; -export type Change = - | (ChangeBase & { kind: 'added'; revision: LocatedNode }) - | (ChangeBase & { kind: 'removed'; base: LocatedNode }) - | (ChangeBase & { - kind: 'modified'; - property: string; - base: LocatedNode; - revision: LocatedNode; - }); +export type LocatedNode = { location: Location; value: unknown }; -export interface DiffReport { - message: string; - /** Defaults to the change's display side; a rule may point at a finer node. */ - location?: Location; -} +export type Change = { key: string; node: DiffNode } & ( + | { kind: 'added'; revision: LocatedNode } + | { kind: 'removed'; base: LocatedNode } + | { kind: 'modified'; property: string; base: LocatedNode; revision: LocatedNode } +); -export interface DiffRuleContext { - report: (report: DiffReport) => void; - direction: Direction; // never 'both': a node used both ways is visited once per direction +export type DiffRuleContext = { + /** `location` defaults to the changed side; a rule may point at a finer node. */ + report: (report: { message: string; location?: Location }) => void; + /** Empty where the data travels neither way. */ + directions: Direction[]; specVersion: SpecVersion; -} +}; export type DiffVisit = (change: Change, context: DiffRuleContext) => void; /** - * Keyed by node type name, nested the way lint visitors are: `SchemaProperties: { Schema }` - * runs for a schema that is a member of a properties map. `enter` inside a nested object is - * the handler of the container itself; `any` runs for every change. + * Keyed by node type and nested the way lint visitors are: `SchemaProperties: { Schema }` runs + * for a schema that is a member of a properties map. `any`, at the top only, runs for every change. */ export type DiffVisitor = { [type: string]: DiffVisit | DiffVisitor }; export type DiffRule = () => DiffVisitor; -/** One handler of a rule with the type path it is nested under; `any` has the empty path. */ -export interface RuleHandler { - path: string[]; - visit: DiffVisit; -} +export type RuleVerdict = { ruleId: string; impact: Impact; message: string; location: Location }; +export type JudgedChange = Change & { impact: Impact; verdicts: RuleVerdict[] }; +export type DiffSummary = Record; -export interface InitializedDiffRule { - id: string; - impact: Impact; - handlers: RuleHandler[]; -} - -export interface RuleVerdict { - ruleId: string; - impact: Impact; - message: string; - location: Location; -} - -export type JudgedChange = Change & { - impact: Impact; - direction: Direction; - verdicts: RuleVerdict[]; -}; - -export interface DiffSummary { - major: number; - minor: number; - patch: number; -} - -export interface DiffResult { +export type DiffResult = { version: '1'; specVersions: { base: SpecVersion; revision: SpecVersion }; summary: DiffSummary; bump?: Impact; changes: JudgedChange[]; -} +}; diff --git a/packages/core/src/diff/usage.ts b/packages/core/src/diff/usage.ts deleted file mode 100644 index 36f7787b26..0000000000 --- a/packages/core/src/diff/usage.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { NodeEntry, Reference } from '../node-map/types.js'; -import { mergeDirections } from './specs/direction.js'; -import type { DiffSpec, Direction, Pair } from './types.js'; - -/** - * The direction every referenced node gets from the sites that reference it, over both - * documents at once so a shared component keeps one direction. A site inside another - * referenced node borrows that node's direction, which the repeated passes settle: merging - * only ever widens a direction, so the answers stop changing after a few rounds, and a - * reference cycle simply contributes nothing. - */ -export function usageDirections( - references: Reference[], - pairOf: Map, - spec: DiffSpec -): (node: NodeEntry) => Direction { - const directions = new Map(); - const fromUsage = (node: NodeEntry): Direction => { - const pair = pairOf.get(node); - return (pair && directions.get(pair)) ?? 'neutral'; - }; - - const edges = references.flatMap(({ from, to }) => { - const target = pairOf.get(spec.referenceTarget?.(to) ?? to); - return target ? [{ from, target }] : []; - }); - - for (let settled = false; !settled; ) { - settled = true; - for (const { from, target } of edges) { - const merged = mergeDirections( - directions.get(target) ?? 'neutral', - spec.directionOf(from, fromUsage) - ); - if (merged !== directions.get(target)) { - directions.set(target, merged); - settled = false; - } - } - } - - return fromUsage; -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b204e3bc1a..3fbb6e8707 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -133,17 +133,16 @@ export { logger, type LoggerInterface } from './logger.js'; export { diffDocuments } from './diff/index.js'; export { type Change, + type DiffNode, type DiffResult, type DiffSummary, - type Direction, type Impact, type JudgedChange, type LocatedNode, - type Pair, } from './diff/types.js'; export { impactRank, impacts } from './diff/impact.js'; export { displaySide } from './diff/changes.js'; -export { typeOf } from './diff/pairs.js'; +export { typeOf } from './diff/diff-tree.js'; export { HandledError, AbortFlowError } from './utils/error.js'; export { isSupportedExtension } from './utils/is-supported-extension.js'; export { isBrowser } from './env.js'; diff --git a/packages/core/src/node-map/__tests__/collect.test.ts b/packages/core/src/node-tree/__tests__/build.test.ts similarity index 78% rename from packages/core/src/node-map/__tests__/collect.test.ts rename to packages/core/src/node-tree/__tests__/build.test.ts index 8ef04d7042..af08cd04cd 100644 --- a/packages/core/src/node-map/__tests__/collect.test.ts +++ b/packages/core/src/node-tree/__tests__/build.test.ts @@ -5,19 +5,30 @@ import { detectSpec } from '../../detect-spec.js'; import { getTypes } from '../../oas-types.js'; import { makeDocumentFromString } from '../../resolve.js'; import { normalizeTypes } from '../../types/index.js'; -import { collectNodes } from '../collect.js'; +import { buildNodeTree } from '../build.js'; import type { NodeEntry } from '../types.js'; -async function collect(yaml: string) { +async function build(yaml: string) { const document = makeDocumentFromString(yaml, 'api.yaml'); const config = await createConfig({}); const specVersion = detectSpec(document.parsed); const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - return collectNodes({ document, types, specVersion }); + return buildNodeTree({ document, types, specVersion }); } -/** `pointer key Type` per node — the map every reader starts from. */ -function tree(nodes: Map): string { +/** Every node under the root by its pointer, in the order the walker reached them. */ +function byPointer(root: NodeEntry): Map { + const nodes = new Map(); + const visit = (node: NodeEntry) => { + nodes.set(node.location.pointer, node); + node.children.forEach(visit); + }; + visit(root); + return nodes; +} + +/** `pointer key Type` per node. */ +function outline(nodes: Map): string { return [...nodes].map(([pointer, node]) => `${pointer} ${node.key} ${node.type}`).join('\n'); } @@ -43,11 +54,11 @@ const PETSTORE = outdent` id: { type: string } `; -describe('collectNodes', () => { - it('records every object and array node under its pointer, with the walker location and key', async () => { - const { nodes } = await collect(PETSTORE); +describe('buildNodeTree', () => { + it('builds every object and array node into the tree, with the walker location and key', async () => { + const nodes = byPointer((await build(PETSTORE)).root); - expect(tree(nodes)).toMatchInlineSnapshot(` + expect(outline(nodes)).toMatchInlineSnapshot(` "#/ Root #/info info Info #/paths paths Paths @@ -75,7 +86,7 @@ describe('collectNodes', () => { }); it('keeps the node itself as the value, children included', async () => { - const { nodes } = await collect(PETSTORE); + const nodes = byPointer((await build(PETSTORE)).root); expect(nodes.get('#/paths/~1pets/get/parameters/0')?.value).toEqual({ name: 'limit', @@ -86,9 +97,9 @@ describe('collectNodes', () => { }); }); -describe('collectNodes references', () => { +describe('buildNodeTree references', () => { it('resolves a $ref to the node it points at, wherever the $ref is written', async () => { - const { references } = await collect(outdent` + const { references } = await build(outdent` openapi: 3.1.0 info: { title: T, version: '1' } paths: diff --git a/packages/core/src/node-map/access.ts b/packages/core/src/node-tree/access.ts similarity index 100% rename from packages/core/src/node-map/access.ts rename to packages/core/src/node-tree/access.ts diff --git a/packages/core/src/node-map/collect.ts b/packages/core/src/node-tree/build.ts similarity index 87% rename from packages/core/src/node-map/collect.ts rename to packages/core/src/node-tree/build.ts index fa7aa71e3d..5d1a4891fc 100644 --- a/packages/core/src/node-map/collect.ts +++ b/packages/core/src/node-tree/build.ts @@ -6,17 +6,13 @@ import { normalizeVisitors } from '../visitors.js'; import { walkDocument, type UserContext, type WalkContext } from '../walk.js'; import type { NodeEntry, Reference } from './types.js'; -export interface DocumentNodes { - root: NodeEntry; - nodes: Map; - references: Reference[]; -} +export type NodeTree = { root: NodeEntry; references: Reference[] }; -export function collectNodes(opts: { +export function buildNodeTree(opts: { document: Document; types: Record; specVersion: SpecVersion; -}): DocumentNodes { +}): NodeTree { const { document, types, specVersion } = opts; const nodes = new Map(); const refSites: Array<{ from: NodeEntry; to: string }> = []; @@ -49,7 +45,7 @@ export function collectNodes(opts: { document, rootType: types.Root, normalizedVisitors: normalizeVisitors( - [{ severity: 'warn', ruleId: 'node-map', visitor }], + [{ severity: 'warn', ruleId: 'node-tree', visitor }], types ), // An empty map leaves every `$ref` unresolved, so each node is visited once, where it is written. @@ -62,7 +58,7 @@ export function collectNodes(opts: { return target ? [{ from, to: target }] : []; }); - return { root: nodes.get('#/')!, nodes, references }; + return { root: nodes.get('#/')!, references }; } function parentPointer(pointer: string): string { diff --git a/packages/core/src/node-map/types.ts b/packages/core/src/node-tree/types.ts similarity index 71% rename from packages/core/src/node-map/types.ts rename to packages/core/src/node-tree/types.ts index 06032c48b2..bc3a673ff3 100644 --- a/packages/core/src/node-map/types.ts +++ b/packages/core/src/node-tree/types.ts @@ -2,16 +2,13 @@ import type { Location } from '../ref-utils.js'; export type NodeValue = Record | unknown[]; -export interface NodeEntry { +export type NodeEntry = { type: string; key: string | number; location: Location; value: NodeValue; parent: NodeEntry | null; children: NodeEntry[]; -} +}; -export interface Reference { - from: NodeEntry; - to: NodeEntry; -} +export type Reference = { from: NodeEntry; to: NodeEntry }; diff --git a/packages/core/src/utils/__tests__/is-scalar.test.ts b/packages/core/src/utils/__tests__/is-scalar.test.ts new file mode 100644 index 0000000000..aba787b442 --- /dev/null +++ b/packages/core/src/utils/__tests__/is-scalar.test.ts @@ -0,0 +1,10 @@ +import { isScalarArray } from '../is-scalar.js'; + +describe('isScalarArray', () => { + 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); + }); +}); From 89d510bf8018eb6f696c33aacd0616696d903ac8 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:58:40 +0300 Subject: [PATCH 45/57] fix(diff): judge $ref list items by their target, resolve percent-encoded refs, and settle directions in linear time --- packages/cli/src/commands/diff/index.ts | 19 +++++- .../core/src/diff/__tests__/changes.test.ts | 10 ++-- .../core/src/diff/__tests__/direction.test.ts | 10 ++-- .../core/src/diff/__tests__/judge.test.ts | 21 +++---- packages/core/src/diff/__tests__/tree.ts | 4 +- packages/core/src/diff/changes.ts | 12 ++-- packages/core/src/diff/diff-tree.ts | 58 +++++++++++++------ packages/core/src/diff/direction.ts | 42 ++++++++------ packages/core/src/diff/index.ts | 4 +- packages/core/src/diff/judge.ts | 58 +++++++++---------- .../core/src/diff/rules/ref-target-changed.ts | 7 +-- .../src/node-tree/__tests__/build.test.ts | 31 +++++++++- packages/core/src/node-tree/build.ts | 50 +++++++++------- packages/core/src/node-tree/types.ts | 2 + tests/e2e/diff/cli.test.ts | 6 ++ .../diff/oas3-allof-ref-replaced/base.yaml | 25 ++++++++ .../oas3-allof-ref-replaced/revision.yaml | 25 ++++++++ .../diff/oas3-allof-ref-replaced/snapshot.txt | 13 +++++ .../oas3-parameter-ref-replaced/base.yaml | 23 ++++++++ .../oas3-parameter-ref-replaced/revision.yaml | 23 ++++++++ .../oas3-parameter-ref-replaced/snapshot.txt | 14 +++++ .../oas3-parameter-refs-reordered/base.yaml | 24 ++++++++ .../revision.yaml | 24 ++++++++ .../snapshot.txt | 5 ++ .../diff/oas3-ref-target-changed/snapshot.txt | 4 +- tests/e2e/diff/rules.test.ts | 14 +++++ tests/e2e/diff/unresolved-ref/base.yaml | 19 ++++++ tests/e2e/diff/unresolved-ref/revision.yaml | 19 ++++++ 28 files changed, 439 insertions(+), 127 deletions(-) create mode 100644 tests/e2e/diff/oas3-allof-ref-replaced/base.yaml create mode 100644 tests/e2e/diff/oas3-allof-ref-replaced/revision.yaml create mode 100644 tests/e2e/diff/oas3-allof-ref-replaced/snapshot.txt create mode 100644 tests/e2e/diff/oas3-parameter-ref-replaced/base.yaml create mode 100644 tests/e2e/diff/oas3-parameter-ref-replaced/revision.yaml create mode 100644 tests/e2e/diff/oas3-parameter-ref-replaced/snapshot.txt create mode 100644 tests/e2e/diff/oas3-parameter-refs-reordered/base.yaml create mode 100644 tests/e2e/diff/oas3-parameter-refs-reordered/revision.yaml create mode 100644 tests/e2e/diff/oas3-parameter-refs-reordered/snapshot.txt create mode 100644 tests/e2e/diff/unresolved-ref/base.yaml create mode 100644 tests/e2e/diff/unresolved-ref/revision.yaml diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts index 7e6a37ec72..f76bef87a5 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -24,8 +24,23 @@ export async function handleDiff({ argv, config, collectSpecData }: CommandArgs< config.skipDiffRules(argv['skip-rule']); - const { bundle: baseDocument } = await bundle({ config, ref: basePath }); - const { bundle: revisionDocument } = await bundle({ config, ref: revisionPath }); + const { bundle: baseDocument, problems: baseProblems } = await bundle({ config, ref: basePath }); + const { bundle: revisionDocument, problems: revisionProblems } = await bundle({ + config, + ref: revisionPath, + }); + + const bundleProblems = [...baseProblems, ...revisionProblems]; + if (bundleProblems.length) { + formatProblems(bundleProblems, { + format: 'codeframe', + totals: getTotals(bundleProblems), + command: 'bundle', + }); + logger.warn( + '⚠️ The problems above leave parts of the descriptions unresolved, so the diff may miss changes there or judge them without knowing whether they are in a request or a response.\n' + ); + } collectSpecData?.(revisionDocument); diff --git a/packages/core/src/diff/__tests__/changes.test.ts b/packages/core/src/diff/__tests__/changes.test.ts index 3431012e51..9b83372601 100644 --- a/packages/core/src/diff/__tests__/changes.test.ts +++ b/packages/core/src/diff/__tests__/changes.test.ts @@ -88,14 +88,14 @@ describe('collectChanges', () => { ]); }); - it('compares a $ref the way it compares a scalar', () => { - const base = documentOf({ a: { value: { schema: { $ref: '#/A' } } } }); - const revision = documentOf({ a: { value: { schema: { $ref: '#/B' } } } }); + it('compares the target of a $ref node like any other value of it', () => { + const base = documentOf({ schema: { value: { $ref: '#/A' } } }); + const revision = documentOf({ schema: { value: { $ref: '#/B' } } }); const [change] = changesBetween(base, revision); - expect(change.kind === 'modified' && change.property).toBe('schema'); - expect(change.kind === 'modified' && change.revision.value).toEqual({ $ref: '#/B' }); + expect(change.kind === 'modified' && change.property).toBe('$ref'); + expect(change.kind === 'modified' && change.revision.value).toBe('#/B'); }); it('emits nothing when the two documents are identical', () => { diff --git a/packages/core/src/diff/__tests__/direction.test.ts b/packages/core/src/diff/__tests__/direction.test.ts index acb02bcdaa..66f0e6023b 100644 --- a/packages/core/src/diff/__tests__/direction.test.ts +++ b/packages/core/src/diff/__tests__/direction.test.ts @@ -50,9 +50,9 @@ const entries = treeOf(` #/components/schemas/Orphan Schema `); const node = (key: string) => entries.get(key)!; -const directionsOf = (edges: Array<[string, string]>) => +const resolveWithReferences = (edges: Array<[string, string]>) => directionsOfTree(entries, edges, oas3Directions); -const unreferenced = directionsOf([]); +const unreferenced = resolveWithReferences([]); describe('mergeDirections', () => { it('merges directions', () => { @@ -100,7 +100,7 @@ describe('getOas3Direction', () => { }); it('derives component direction from usage sites', () => { - const directionOf = directionsOf([ + const directionOf = resolveWithReferences([ [ '#/paths/~1p/get/responses/200/content/application~1json/schema', '#/components/schemas/Pet', @@ -110,7 +110,7 @@ describe('getOas3Direction', () => { }); it('derives both when a component is used on both sides', () => { - const directionOf = directionsOf([ + const directionOf = resolveWithReferences([ [ '#/paths/~1p/get/responses/200/content/application~1json/schema', '#/components/schemas/Pet', @@ -121,7 +121,7 @@ describe('getOas3Direction', () => { }); it('resolves transitive usage through other components, cycle-safe', () => { - const directionOf = directionsOf([ + const directionOf = resolveWithReferences([ [ '#/paths/~1p/get/responses/200/content/application~1json/schema', '#/components/schemas/Pet', diff --git a/packages/core/src/diff/__tests__/judge.test.ts b/packages/core/src/diff/__tests__/judge.test.ts index feabda0c20..abb6f23f07 100644 --- a/packages/core/src/diff/__tests__/judge.test.ts +++ b/packages/core/src/diff/__tests__/judge.test.ts @@ -1,7 +1,7 @@ import { defaultDiffRules } from '../../config/diff-recommended.js'; import { Location } from '../../ref-utils.js'; import { Source } from '../../resolve.js'; -import { activeRules, appliesTo, flattenVisitor, judgeChanges } from '../judge.js'; +import { activeHandlers, appliesTo, flattenVisitor, judgeChanges } from '../judge.js'; import { oas3Rules } from '../rules/index.js'; import { oas3Directions } from '../specs/oas3.js'; import type { Change, DiffNode, DiffRule, Impact } from '../types.js'; @@ -272,16 +272,17 @@ describe('flattenVisitor', () => { }); }); -describe('activeRules', () => { - it('instantiates one visitor per rule under the rule id', () => { - const rule: DiffRule = () => ({ Schema() {} }); +describe('activeHandlers', () => { + it('gives every handler of an enabled rule its id and configured impact', () => { + const rule: DiffRule = () => ({ Schema() {}, SchemaProperties: { Schema() {} } }); expect( - activeRules([{ 'my-rule': rule }], () => 'major').map(({ id, impact, handlers }) => [ - id, - impact, - handlers.map(({ typePath }) => typePath), - ]) - ).toEqual([['my-rule', 'major', [['Schema']]]]); + activeHandlers([{ 'my-rule': rule, 'off-rule': rule }], (ruleId) => + ruleId === 'my-rule' ? 'major' : 'off' + ).map(({ ruleId, impact, typePath }) => [ruleId, impact, typePath]) + ).toEqual([ + ['my-rule', 'major', ['Schema']], + ['my-rule', 'major', ['SchemaProperties', 'Schema']], + ]); }); }); diff --git a/packages/core/src/diff/__tests__/tree.ts b/packages/core/src/diff/__tests__/tree.ts index 268a86c143..68e7c2f508 100644 --- a/packages/core/src/diff/__tests__/tree.ts +++ b/packages/core/src/diff/__tests__/tree.ts @@ -1,7 +1,7 @@ import type { NodeEntry, Reference } from '../../node-tree/types.js'; import { Location } from '../../ref-utils.js'; import { Source } from '../../resolve.js'; -import { directionsOf } from '../direction.js'; +import { resolveDirections } from '../direction.js'; import type { DiffNode, Directions } from '../types.js'; const source = new Source('tree.yaml', ''); @@ -68,5 +68,5 @@ export function directionsOfTree( edges: Array<[from: string, to: string]>, directions: Directions ) { - return directionsOf(referencesOfTree(entries, edges), diffNodesOfTree(entries), directions); + return resolveDirections(referencesOfTree(entries, edges), diffNodesOfTree(entries), directions); } diff --git a/packages/core/src/diff/changes.ts b/packages/core/src/diff/changes.ts index 9734f69dcd..d5a2b2a27b 100644 --- a/packages/core/src/diff/changes.ts +++ b/packages/core/src/diff/changes.ts @@ -1,9 +1,8 @@ import type { NodeEntry } from '../node-tree/types.js'; -import { isRef } from '../ref-utils.js'; import { dequal } from '../utils/dequal.js'; import { isPlainObject } from '../utils/is-plain-object.js'; import { isScalar, isScalarArray } from '../utils/is-scalar.js'; -import { latestOf, typeOf } from './diff-tree.js'; +import { identityOf, latestOf, typeOf } from './diff-tree.js'; import type { Change, DiffNode, Identities, LocatedNode } from './types.js'; export function displaySide(change: Change): LocatedNode { @@ -50,7 +49,7 @@ export function collectChanges(root: DiffNode, identities: Identities): Change[] } /** - * The node's own values — scalars, scalar arrays and `$ref`s; nested objects and arrays are + * The node's own values — scalars and scalar arrays; nested objects, arrays and `$ref`s are * nodes of their own. A value the segment hides, such as a path template, is compared too. */ function comparableValues( @@ -62,16 +61,17 @@ function comparableValues( if (isPlainObject(side.value)) { for (const [name, item] of Object.entries(side.value)) { - if (isRef(item) || isScalar(item) || isScalarArray(item)) values[name] = item; + if (isScalar(item) || isScalarArray(item)) values[name] = item; } } - const identity = node.parent && identities[typeOf(node.parent)]?.(side); + const identity = node.parent && identityOf(side, typeOf(node.parent), identities); return { ...values, ...identity?.values }; } +// A `$ref` added or removed stands for what it points at, so a rule reads the target's value. function located(side: NodeEntry): LocatedNode { - return { location: side.location, value: side.value }; + return { location: side.location, value: side.target?.value ?? side.value }; } // A value the node does not carry as a property (the path template) is located at the node. diff --git a/packages/core/src/diff/diff-tree.ts b/packages/core/src/diff/diff-tree.ts index c78765bb61..bfef35fcac 100644 --- a/packages/core/src/diff/diff-tree.ts +++ b/packages/core/src/diff/diff-tree.ts @@ -1,6 +1,6 @@ import type { NodeEntry } from '../node-tree/types.js'; import { escapePointerFragment, joinPointer } from '../ref-utils.js'; -import type { DiffNode, Identities } from './types.js'; +import type { DiffNode, Identities, NodeIdentity } from './types.js'; export function latestOf(node: DiffNode): NodeEntry { return (node.revision ?? node.base)!; @@ -23,7 +23,13 @@ export function buildDiffTree( ): { root: DiffNode; diffNodeOf: Map } { const diffNodeOf = new Map(); - function addSubtree(node: DiffNode): DiffNode { + function addNode( + parent: DiffNode | null, + key: string, + sides: Pick + ): DiffNode { + const node: DiffNode = { ...sides, parent, children: [], key }; + parent?.children.push(node); if (node.base) diffNodeOf.set(node.base, node); if (node.revision) diffNodeOf.set(node.revision, node); @@ -53,27 +59,44 @@ export function buildDiffTree( const sharedKey = joinPointer(parent.key, segment); for (let index = 0; index < Math.max(baseNodes.length, revisionNodes.length); index++) { const key = index === 0 ? sharedKey : `${sharedKey}#${index + 1}`; + const base = baseNodes[index]; + const revision = revisionNodes[index]; - const add = (sides: Pick) => - parent.children.push(addSubtree({ ...sides, parent, children: [], key })); - - const baseNode = baseNodes[index]; - const revisionNode = revisionNodes[index]; - const typeChanged = baseNode && revisionNode && baseNode.type !== revisionNode.type; - if (typeChanged) { + if (base && revision && base.type !== revision.type) { // A different kind of node in the same place is a removal and an addition. - add({ base: baseNode }); - add({ revision: revisionNode }); + addNode(parent, key, { base }); + addNode(parent, key, { revision }); } else { - add({ base: baseNode, revision: revisionNode }); + addNode(parent, key, { base, revision }); } } } - return { - root: addSubtree({ base, revision, parent: null, children: [], key: '#/' }), - diffNodeOf, - }; + return { root: addNode(null, '#/', { base, revision }), diffNodeOf }; +} + +/** + * What the specification says identifies a child of a container. A `$ref` is identified by what + * it points at, read at the place it is written, so a referenced parameter still has its name. + */ +export function identityOf( + child: NodeEntry, + container: string, + identities: Identities +): NodeIdentity | undefined { + const subject = child.target ? { ...child.target, key: child.key, parent: child.parent } : child; + return identities[container]?.(subject); +} + +// A list item has no name of its own: an inline one is known by its position, a `$ref` by the +// node it points at, so reordering references is not a change. +function segmentOf(child: NodeEntry, container: string, identities: Identities): string { + const identity = identityOf(child, container, identities); + if (identity) return identity.segment; + if (typeof child.key === 'number' && child.target) { + return `{${escapePointerFragment(child.target.location.pointer)}}`; + } + return escapePointerFragment(String(child.key)); } function groupBySegment( @@ -84,8 +107,7 @@ function groupBySegment( const groups = new Map(); for (const node of nodes) { - const segment = - identities[container]?.(node)?.segment ?? escapePointerFragment(String(node.key)); + const segment = segmentOf(node, container, identities); const group = groups.get(segment); if (group) { diff --git a/packages/core/src/diff/direction.ts b/packages/core/src/diff/direction.ts index 9c5ecd57bd..0ba58639e5 100644 --- a/packages/core/src/diff/direction.ts +++ b/packages/core/src/diff/direction.ts @@ -16,13 +16,10 @@ export function opposite(direction: Direction): Direction { /** * The way the data in a node travels. The node's position says it first; where it says nothing, - * the node takes the direction of every site that references it or one of its ancestors, over - * both documents, so a shared component keeps one direction. A site inside another referenced - * node borrows that node's direction, which the repeated passes settle: merging only ever widens - * a direction, so the answers stop changing after a few rounds, and a reference cycle simply - * contributes nothing. + * the node takes the directions its referenced ancestors are used in, over both documents, so a + * shared component keeps one direction. */ -export function directionsOf( +export function resolveDirections( references: Reference[], diffNodeOf: Map, directions: Directions @@ -40,20 +37,31 @@ export function directionsOf( return merged; }; - let widened = true; - while (widened) { - widened = false; - for (const { from, to } of references) { - const target = diffNodeOf.get(to)!; - const current = usage.get(target) ?? []; - const merged = mergeDirections(current, directionOf(from)); - if (merged.length !== current.length) { - usage.set(target, merged); - widened = true; - } + // A site inside a referenced node borrows that node's usage, so each time a usage widens, the + // references made from inside it are taken again. Usage only ever widens, so this ends, and a + // reference cycle simply contributes nothing. + const madeInside = new Map(); + for (const reference of references) { + for (let current: NodeEntry | null = reference.from; current; current = current.parent) { + const node = diffNodeOf.get(current)!; + const made = madeInside.get(node); + if (made) made.push(reference); + else madeInside.set(node, [reference]); } } + const pending = [...references]; + while (pending.length) { + const { from, to } = pending.pop()!; + const target = diffNodeOf.get(to)!; + const known = usage.get(target) ?? []; + const widened = mergeDirections(known, directionOf(from)); + if (widened.length === known.length) continue; + + usage.set(target, widened); + for (const reference of madeInside.get(target) ?? []) pending.push(reference); + } + return directionOf; } diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index 8750b27190..ed9c1b9f5a 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -7,7 +7,7 @@ import { normalizeTypes } from '../types/index.js'; import { HandledError } from '../utils/error.js'; import { collectChanges } from './changes.js'; import { buildDiffTree, typeOf } from './diff-tree.js'; -import { directionsOf } from './direction.js'; +import { resolveDirections } from './direction.js'; import { highestImpact } from './impact.js'; import { judgeChanges } from './judge.js'; import { diffSpecs } from './specs/index.js'; @@ -50,7 +50,7 @@ export function diffDocuments(opts: { identities ); - const directionOf = directionsOf(references, diffNodeOf, directions); + const directionOf = resolveDirections(references, diffNodeOf, directions); const changes = judgeChanges({ changes: collectChanges(root, identities), diff --git a/packages/core/src/diff/judge.ts b/packages/core/src/diff/judge.ts index 8508283b91..e97845bcbc 100644 --- a/packages/core/src/diff/judge.ts +++ b/packages/core/src/diff/judge.ts @@ -16,26 +16,31 @@ import type { RuleVerdict, } from './types.js'; -/** One handler of a rule with the node types it is nested under; `any` has none. */ -export type RuleHandler = { typePath: string[]; visit: DiffVisit }; -export type ActiveRule = { id: string; impact: Impact; handlers: RuleHandler[] }; +/** One handler of an enabled rule, with the node types it is nested under; `any` has none. */ +export type RuleHandler = { + ruleId: string; + impact: Impact; + typePath: string[]; + visit: DiffVisit; +}; -/** The rules the configuration turns on, each with the impact it gives. */ -export function activeRules( +/** The handlers of every rule the configuration turns on, in rule order. */ +export function activeHandlers( ruleSets: Record[], impactOf: (ruleId: string) => Impact | 'off' -): ActiveRule[] { - const rules: ActiveRule[] = []; +): RuleHandler[] { + const handlers: RuleHandler[] = []; for (const ruleSet of ruleSets) { - for (const [id, rule] of Object.entries(ruleSet)) { - const impact = impactOf(id); - if (impact !== 'off') { - rules.push({ id, impact, handlers: flattenVisitor(rule(), id) }); + for (const [ruleId, rule] of Object.entries(ruleSet)) { + const impact = impactOf(ruleId); + if (impact === 'off') continue; + for (const { typePath, visit } of flattenVisitor(rule(), ruleId)) { + handlers.push({ ruleId, impact, typePath, visit }); } } } - return rules; + return handlers; } // A diff visitor sees whole changes, not a walk, so lint's hooks have nothing to run on. @@ -49,7 +54,7 @@ export function flattenVisitor( visitor: DiffVisitor, ruleId: string, outerTypes: string[] = [] -): RuleHandler[] { +): Pick[] { return Object.entries(visitor).flatMap(([type, value]) => { if (HOOKS.has(type) || (type === 'any' && outerTypes.length > 0)) { throw new Error(`Diff rule '${ruleId}' uses '${type}' where diff visitors do not have it.`); @@ -65,17 +70,13 @@ export function flattenVisitor( * inside one. The empty path applies to every node. */ export function appliesTo(typePath: string[], node: DiffNode): boolean { - if (typePath.length === 0) return true; - - const type = typePath[typePath.length - 1]; - if (typeOf(node) !== type) return false; - - const outerTypes = typePath.slice(0, -1); - if (outerTypes.length === 0) return true; + let current: DiffNode | undefined = node; - const owner = nearest(node.parent, [outerTypes[outerTypes.length - 1], type]); - - return owner !== undefined && appliesTo(outerTypes, owner); + for (let index = typePath.length - 1; index >= 0; index--) { + if (current === undefined || typeOf(current) !== typePath[index]) return false; + if (index > 0) current = nearest(current.parent, [typePath[index - 1], typePath[index]]); + } + return true; } function nearest(node: DiffNode | null, types: string[]): DiffNode | undefined { @@ -93,20 +94,19 @@ export function judgeChanges(opts: { directionOf: (node: NodeEntry) => Direction[]; }): JudgedChange[] { const { changes, specVersion, ruleSets, impactOf, directionOf } = opts; - const rules = activeRules(ruleSets, impactOf); + const handlers = activeHandlers(ruleSets, impactOf); return changes.map((change) => { const verdicts: RuleVerdict[] = []; const directions = directionOf(latestOf(change.node)); - for (const { id, impact, handlers } of rules) { + for (const { ruleId, impact, typePath, visit } of handlers) { + if (!appliesTo(typePath, change.node)) continue; const report: DiffRuleContext['report'] = ({ message, location = displaySide(change).location, - }) => verdicts.push({ ruleId: id, impact, message, location }); - for (const { typePath, visit } of handlers) { - if (appliesTo(typePath, change.node)) visit(change, { report, directions, specVersion }); - } + }) => verdicts.push({ ruleId, impact, message, location }); + visit(change, { report, directions, specVersion }); } const impact = highestImpact(verdicts.map((verdict) => verdict.impact)); diff --git a/packages/core/src/diff/rules/ref-target-changed.ts b/packages/core/src/diff/rules/ref-target-changed.ts index 3e0590e456..119f655e0a 100644 --- a/packages/core/src/diff/rules/ref-target-changed.ts +++ b/packages/core/src/diff/rules/ref-target-changed.ts @@ -1,17 +1,14 @@ -import { isRef } from '../../ref-utils.js'; import type { DiffRule } from '../types.js'; function describeTarget(value: unknown): string { - if (isRef(value)) return `'${value.$ref}'`; - return value === undefined ? 'an inline definition' : `'${value}'`; + return typeof value === 'string' ? `'${value}'` : 'an inline definition'; } // Key-aligned comparison cannot verify whether two different targets are // content-equivalent — the conservative verdict is breaking. export const RefTargetChanged: DiffRule = () => ({ any(change, { report }) { - if (change.kind !== 'modified') return; - if (!isRef(change.base.value) && !isRef(change.revision.value)) return; + if (change.kind !== 'modified' || change.property !== '$ref') return; report({ message: `The reference target changed from ${describeTarget(change.base.value)} to ${describeTarget( change.revision.value diff --git a/packages/core/src/node-tree/__tests__/build.test.ts b/packages/core/src/node-tree/__tests__/build.test.ts index af08cd04cd..a45d6bfac2 100644 --- a/packages/core/src/node-tree/__tests__/build.test.ts +++ b/packages/core/src/node-tree/__tests__/build.test.ts @@ -71,6 +71,7 @@ describe('buildNodeTree', () => { #/paths/~1pets/get/responses/200 200 Response #/paths/~1pets/get/responses/200/content content MediaTypesMap #/paths/~1pets/get/responses/200/content/application~1json application/json MediaType + #/paths/~1pets/get/responses/200/content/application~1json/schema schema Schema #/components components Components #/components/schemas schemas NamedSchemas #/components/schemas/Pet Pet Schema @@ -120,8 +121,34 @@ describe('buildNodeTree references', () => { `); expect(references.map(({ from, to }) => [from.location.pointer, to.location.pointer])).toEqual([ - ['#/paths/~1p/get/parameters', '#/components/parameters/Limit'], - ['#/paths/~1p/get/responses/200/content/application~1json', '#/components/schemas/Pet'], + ['#/paths/~1p/get/parameters/0', '#/components/parameters/Limit'], + [ + '#/paths/~1p/get/responses/200/content/application~1json/schema', + '#/components/schemas/Pet', + ], + ]); + expect(references.map(({ from }) => from.type)).toEqual(['Parameter', 'Schema']); + }); + + it('resolves a $ref whose pointer is percent-encoded', async () => { + const { references } = await build(outdent` + openapi: 3.1.0 + info: { title: T, version: '1' } + paths: + /p: + get: + responses: + '200': + content: + application/json: + schema: { $ref: '#/components/schemas/Pet%20Name' } + components: + schemas: + Pet Name: { type: object } + `); + + expect(references.map(({ to }) => to.location.pointer)).toEqual([ + '#/components/schemas/Pet Name', ]); }); }); diff --git a/packages/core/src/node-tree/build.ts b/packages/core/src/node-tree/build.ts index 5d1a4891fc..e441e1b9cc 100644 --- a/packages/core/src/node-tree/build.ts +++ b/packages/core/src/node-tree/build.ts @@ -1,4 +1,5 @@ import type { SpecVersion } from '../oas-types.js'; +import { escapePointerFragment, joinPointer, parseRef } from '../ref-utils.js'; import type { Document } from '../resolve.js'; import type { NormalizedNodeType } from '../types/index.js'; import { isPlainObject } from '../utils/is-plain-object.js'; @@ -15,27 +16,23 @@ export function buildNodeTree(opts: { }): NodeTree { const { document, types, specVersion } = opts; const nodes = new Map(); - const refSites: Array<{ from: NodeEntry; to: string }> = []; + const refs: Array<{ entry: NodeEntry; ref: string }> = []; + + const add = (value: NodeEntry['value'], { location, type, key }: UserContext): NodeEntry => { + const parent = nodes.get(parentPointer(location.pointer)) ?? null; + const entry: NodeEntry = { type: type.name, key, location, value, parent, children: [] }; + parent?.children.push(entry); + nodes.set(location.pointer, entry); + return entry; + }; const visitor = { - any(node: unknown, { location, type, key }: UserContext) { - if (!isPlainObject(node) && !Array.isArray(node)) return; - const parent = nodes.get(parentPointer(location.pointer)) ?? null; - const entry: NodeEntry = { - type: type.name, - key, - location, - value: node, - parent, - children: [], - }; - parent?.children.push(entry); - nodes.set(location.pointer, entry); + any(node: unknown, context: UserContext) { + if (isPlainObject(node) || Array.isArray(node)) add(node, context); }, - ref(node: { $ref: string }, { location }: UserContext) { - // The node holding the reference has been entered already; its target may come later. - const from = nodes.get(parentPointer(location.pointer)); - if (from) refSites.push({ from, to: node.$ref }); + // A `$ref` is a node of the type its place expects; the node it points at may come later. + ref(node: { $ref: string }, context: UserContext) { + refs.push({ entry: add(node, context), ref: node.$ref }); }, }; @@ -53,14 +50,23 @@ export function buildNodeTree(opts: { ctx: walkContext, }); - const references = refSites.flatMap(({ from, to }) => { - const target = nodes.get(to); - return target ? [{ from, to: target }] : []; - }); + const references: Reference[] = []; + for (const { entry, ref } of refs) { + entry.target = nodes.get(pointerOf(ref)); + if (entry.target) references.push({ from: entry, to: entry.target }); + } return { root: nodes.get('#/')!, references }; } +// A `$ref` may percent-encode its pointer (`Pet%20Name`), while a location spells it as written. +function pointerOf(ref: string): string { + return parseRef(ref).pointer.reduce( + (pointer, segment) => joinPointer(pointer, escapePointerFragment(segment)), + '#/' + ); +} + function parentPointer(pointer: string): string { const lastSlash = pointer.lastIndexOf('/'); return lastSlash <= 1 ? '#/' : pointer.slice(0, lastSlash); diff --git a/packages/core/src/node-tree/types.ts b/packages/core/src/node-tree/types.ts index bc3a673ff3..207b43ad16 100644 --- a/packages/core/src/node-tree/types.ts +++ b/packages/core/src/node-tree/types.ts @@ -9,6 +9,8 @@ export type NodeEntry = { value: NodeValue; parent: NodeEntry | null; children: NodeEntry[]; + /** For a `$ref`, the node it points at; absent when it does not resolve. */ + target?: NodeEntry; }; export type Reference = { from: NodeEntry; to: NodeEntry }; diff --git a/tests/e2e/diff/cli.test.ts b/tests/e2e/diff/cli.test.ts index 935d6e186c..4e48fd6e17 100644 --- a/tests/e2e/diff/cli.test.ts +++ b/tests/e2e/diff/cli.test.ts @@ -45,6 +45,12 @@ describe('diff command', () => { expect(output).toContain('--check-version was skipped'); }); + test('warns that an unresolved reference leaves part of the description unjudged', () => { + const output = runDiff('unresolved-ref'); + expect(output).toContain("Can't resolve $ref"); + expect(output).toContain('the diff may miss changes there'); + }); + test('refuses to compare across specification families', () => { const output = runDiff('cross-family'); expect(output).toContain('Cannot compare oas2 with oas3_1.'); diff --git a/tests/e2e/diff/oas3-allof-ref-replaced/base.yaml b/tests/e2e/diff/oas3-allof-ref-replaced/base.yaml new file mode 100644 index 0000000000..4f1d7b178d --- /dev/null +++ b/tests/e2e/diff/oas3-allof-ref-replaced/base.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: allof-ref-replaced + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Named' +components: + schemas: + Named: + type: object + properties: + name: { type: string } + Tagged: + type: object + properties: + tag: { type: string } diff --git a/tests/e2e/diff/oas3-allof-ref-replaced/revision.yaml b/tests/e2e/diff/oas3-allof-ref-replaced/revision.yaml new file mode 100644 index 0000000000..4a2ce561b2 --- /dev/null +++ b/tests/e2e/diff/oas3-allof-ref-replaced/revision.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: allof-ref-replaced + version: '1.0' +paths: + /pets: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Tagged' +components: + schemas: + Named: + type: object + properties: + name: { type: string } + Tagged: + type: object + properties: + tag: { type: string } diff --git a/tests/e2e/diff/oas3-allof-ref-replaced/snapshot.txt b/tests/e2e/diff/oas3-allof-ref-replaced/snapshot.txt new file mode 100644 index 0000000000..a6c71935a6 --- /dev/null +++ b/tests/e2e/diff/oas3-allof-ref-replaced/snapshot.txt @@ -0,0 +1,13 @@ +GET /pets + ✖ major removed responses/200/content/application/json/schema/allOf/{#/components/schemas/Named} + A `allOf` subschema was removed. (schema-combinator-changed) + at base.yaml:15:21 + ✔ minor added responses/200/content/application/json/schema/allOf/{#/components/schemas/Tagged} + at revision.yaml:15:21 + +1 major, 1 minor, 0 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-parameter-ref-replaced/base.yaml b/tests/e2e/diff/oas3-parameter-ref-replaced/base.yaml new file mode 100644 index 0000000000..e5f53d4764 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-ref-replaced/base.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: parameter-ref-replaced + version: '1.0' +paths: + /pets: + get: + parameters: + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: OK +components: + parameters: + Limit: + name: limit + in: query + schema: { type: integer } + Owner: + name: owner + in: query + required: true + schema: { type: string } diff --git a/tests/e2e/diff/oas3-parameter-ref-replaced/revision.yaml b/tests/e2e/diff/oas3-parameter-ref-replaced/revision.yaml new file mode 100644 index 0000000000..bf742be803 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-ref-replaced/revision.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: parameter-ref-replaced + version: '1.0' +paths: + /pets: + get: + parameters: + - $ref: '#/components/parameters/Owner' + responses: + '200': + description: OK +components: + parameters: + Limit: + name: limit + in: query + schema: { type: integer } + Owner: + name: owner + in: query + required: true + schema: { type: string } diff --git a/tests/e2e/diff/oas3-parameter-ref-replaced/snapshot.txt b/tests/e2e/diff/oas3-parameter-ref-replaced/snapshot.txt new file mode 100644 index 0000000000..a18698e55b --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-ref-replaced/snapshot.txt @@ -0,0 +1,14 @@ +GET /pets + ✖ major removed parameters/{query:limit} + Parameter was removed. (parameter-removed) + at base.yaml:9:11 + ✖ major added parameters/{query:owner} + A new required parameter was added. (parameter-added-required) + at revision.yaml:9:11 + +2 major, 0 minor, 0 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 2 major changes. diff --git a/tests/e2e/diff/oas3-parameter-refs-reordered/base.yaml b/tests/e2e/diff/oas3-parameter-refs-reordered/base.yaml new file mode 100644 index 0000000000..5b0fecc515 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-refs-reordered/base.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: parameter-refs-reordered + version: '1.0' +paths: + /pets: + get: + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Owner' + responses: + '200': + description: OK +components: + parameters: + Limit: + name: limit + in: query + schema: { type: integer } + Owner: + name: owner + in: query + required: true + schema: { type: string } diff --git a/tests/e2e/diff/oas3-parameter-refs-reordered/revision.yaml b/tests/e2e/diff/oas3-parameter-refs-reordered/revision.yaml new file mode 100644 index 0000000000..6f5f36f227 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-refs-reordered/revision.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: parameter-refs-reordered + version: '1.0' +paths: + /pets: + get: + parameters: + - $ref: '#/components/parameters/Owner' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: OK +components: + parameters: + Limit: + name: limit + in: query + schema: { type: integer } + Owner: + name: owner + in: query + required: true + schema: { type: string } diff --git a/tests/e2e/diff/oas3-parameter-refs-reordered/snapshot.txt b/tests/e2e/diff/oas3-parameter-refs-reordered/snapshot.txt new file mode 100644 index 0000000000..c8e7c5c153 --- /dev/null +++ b/tests/e2e/diff/oas3-parameter-refs-reordered/snapshot.txt @@ -0,0 +1,5 @@ +0 major, 0 minor, 0 patch. + + +base.yaml vs revision.yaml: diff processed in ms + diff --git a/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt b/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt index 28b88a1fa1..0feccb60af 100644 --- a/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt @@ -1,7 +1,7 @@ GET /pets - ✖ major modified responses/200/content/application/json · schema + ✖ major modified responses/200/content/application/json/schema · $ref 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 + at revision.yaml:14:23 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/rules.test.ts b/tests/e2e/diff/rules.test.ts index 82b0a9797e..3deb567962 100644 --- a/tests/e2e/diff/rules.test.ts +++ b/tests/e2e/diff/rules.test.ts @@ -31,6 +31,11 @@ const OPENAPI: RuleCase[] = [ ruleId: 'parameter-added-required', describes: 'a new required query parameter appears', }, + { + fixture: 'oas3-parameter-ref-replaced', + ruleId: 'parameter-added-required', + describes: 'a referenced parameter is swapped for a required one', + }, { fixture: 'oas3-parameter-became-required', ruleId: 'parameter-became-required', @@ -128,6 +133,11 @@ const OPENAPI: RuleCase[] = [ ruleId: 'schema-combinator-changed', describes: 'a request oneOf drops an accepted subschema', }, + { + fixture: 'oas3-allof-ref-replaced', + ruleId: 'schema-combinator-changed', + describes: 'a referenced `allOf` member of a response is swapped for another', + }, { fixture: 'oas3-ref-target-changed', ruleId: 'ref-target-changed', @@ -238,6 +248,10 @@ const NON_BREAKING: { fixture: string; describes: string }[] = [ fixture: 'async3-sent-payload-required-added', describes: 'a sent payload requires one more property', }, + { + fixture: 'oas3-parameter-refs-reordered', + describes: 'referenced parameters are listed in another order', + }, ]; describe('diff rules', () => { diff --git a/tests/e2e/diff/unresolved-ref/base.yaml b/tests/e2e/diff/unresolved-ref/base.yaml new file mode 100644 index 0000000000..6f19cbef3a --- /dev/null +++ b/tests/e2e/diff/unresolved-ref/base.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: Pets + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Missing' + responses: + '201': + description: Created +components: + schemas: + Pet: + type: object diff --git a/tests/e2e/diff/unresolved-ref/revision.yaml b/tests/e2e/diff/unresolved-ref/revision.yaml new file mode 100644 index 0000000000..6f19cbef3a --- /dev/null +++ b/tests/e2e/diff/unresolved-ref/revision.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: Pets + version: '1.0' +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Missing' + responses: + '201': + description: Created +components: + schemas: + Pet: + type: object From 29522f4875b09aa516375f7953f2bf2b4b7d7321 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:15:52 +0300 Subject: [PATCH 46/57] refactor(diff): read refs through their target in one place, identify children by a segment alone, and derive diff families from the rule registry --- .../diff/format/__tests__/diff-node.ts | 2 +- packages/core/src/config/builtIn.ts | 7 +- packages/core/src/config/config.ts | 13 +-- packages/core/src/config/types.ts | 5 +- .../core/src/diff/__tests__/changes.test.ts | 2 +- .../src/diff/__tests__/diff-documents.test.ts | 4 +- .../core/src/diff/__tests__/diff-tree.test.ts | 6 +- .../core/src/diff/__tests__/judge.test.ts | 104 +++++++++++------- packages/core/src/diff/__tests__/tree.ts | 2 +- packages/core/src/diff/changes.ts | 43 ++++---- packages/core/src/diff/diff-tree.ts | 38 +++---- packages/core/src/diff/index.ts | 7 +- packages/core/src/diff/judge.ts | 6 +- packages/core/src/diff/rules/index.ts | 17 ++- packages/core/src/diff/specs/index.ts | 8 +- packages/core/src/diff/specs/oas3.ts | 29 +++-- packages/core/src/diff/types.ts | 17 ++- packages/core/src/node-tree/access.ts | 8 +- .../oas3-path-parameter-renamed/snapshot.txt | 2 +- 19 files changed, 167 insertions(+), 153 deletions(-) diff --git a/packages/cli/src/commands/diff/format/__tests__/diff-node.ts b/packages/cli/src/commands/diff/format/__tests__/diff-node.ts index 69f828b8ce..9ac34bf91a 100644 --- a/packages/cli/src/commands/diff/format/__tests__/diff-node.ts +++ b/packages/cli/src/commands/diff/format/__tests__/diff-node.ts @@ -4,5 +4,5 @@ import type { DiffNode, Location } from '@redocly/openapi-core'; export function loneNode(type: string, location: Location): DiffNode { const key = location.pointer.slice(location.pointer.lastIndexOf('/') + 1); const node = { type, key, location, value: {}, parent: null, children: [] }; - return { base: node, revision: node, parent: null, children: [], key: '#/' }; + return { base: node, revision: node, parent: null, children: [], label: '#/' }; } diff --git a/packages/core/src/config/builtIn.ts b/packages/core/src/config/builtIn.ts index 5a2fa92e91..c8f507609b 100644 --- a/packages/core/src/config/builtIn.ts +++ b/packages/core/src/config/builtIn.ts @@ -5,7 +5,7 @@ import { decorators as oas2Decorators } from '../decorators/oas2/index.js'; import { decorators as oas3Decorators } from '../decorators/oas3/index.js'; import { decorators as openrpc1Decorators } from '../decorators/openrpc/index.js'; import { decorators as overlay1Decorators } from '../decorators/overlay1/index.js'; -import { async3Rules as async3DiffRules, oas3Rules as oas3DiffRules } from '../diff/rules/index.js'; +import { diffRuleSets } from '../diff/rules/index.js'; import { rules as arazzo1Rules, preprocessors as arazzoPreprocessors, @@ -82,9 +82,6 @@ export const defaultPlugin: Plugin<'built-in'> = { overlay1: overlay1Decorators, openrpc1: openrpc1Decorators, }, - diff: { - oas3: oas3DiffRules, - async3: async3DiffRules, - }, + diff: diffRuleSets, configs: builtInConfigs, }; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 042513b99d..666f5ffe70 100755 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2,6 +2,7 @@ import type { RecheckConfig } from '@redocly/config'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { isDiffFamily } from '../diff/rules/index.js'; import type { DiffRule, Impact } from '../diff/types.js'; import { stringifyYaml } from '../js-yaml/index.js'; import { @@ -358,15 +359,9 @@ export class Config { // TODO: add rules for redocly.yaml / entities? /** The diff rule sets every plugin offers for that specification family. */ - getDiffRulesForSpecVersion(version: SpecMajorVersion): Record[] { - switch (version) { - case 'oas3': - return this.plugins.map((plugin) => plugin.diff?.oas3).filter(isDefined); - case 'async3': - return this.plugins.map((plugin) => plugin.diff?.async3).filter(isDefined); - default: - return []; - } + getDiffRulesForSpecVersion(family: SpecMajorVersion): Record[] { + if (!isDiffFamily(family)) return []; + return this.plugins.map((plugin) => plugin.diff?.[family]).filter(isDefined); } getRulesForSpecVersion(version: SpecMajorVersion) { diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 32ef0849f1..147b21e03f 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -1,7 +1,7 @@ import type { ApiConfig, RecheckConfig, RedoclyConfig } from '@redocly/config'; import type { JSONSchema } from 'json-schema-to-ts'; -import type { Async3DiffRuleId, DiffRuleId, Oas3DiffRuleId } from '../diff/rules/index.js'; +import type { DiffRuleId, DiffRuleSets } from '../diff/rules/index.js'; import type { DiffRule, Impact } from '../diff/types.js'; import type { SpecMajorVersion, @@ -199,8 +199,7 @@ export type RulesConfig = { export type CustomRulesConfig = RulesConfig; export type DiffRulesConfig = { - oas3?: RuleMap; - async3?: RuleMap; + [Family in keyof DiffRuleSets]?: RuleMap; }; export type AssertionContext = Partial & SkipFunctionContext & { node: any }; diff --git a/packages/core/src/diff/__tests__/changes.test.ts b/packages/core/src/diff/__tests__/changes.test.ts index 9b83372601..c83359bb73 100644 --- a/packages/core/src/diff/__tests__/changes.test.ts +++ b/packages/core/src/diff/__tests__/changes.test.ts @@ -36,7 +36,7 @@ function documentOf(children: Record, revision: ReturnType -) => collectChanges(buildDiffTree(base.get('#/')!, revision.get('#/')!, {}).root, {}); +) => collectChanges(buildDiffTree(base.get('#/')!, revision.get('#/')!, {}).root); /** `kind key · property base-pointer → revision-pointer` per change. */ function summarize(changes: ReturnType): string[] { diff --git a/packages/core/src/diff/__tests__/diff-documents.test.ts b/packages/core/src/diff/__tests__/diff-documents.test.ts index 60dca62b7d..adf32d348b 100644 --- a/packages/core/src/diff/__tests__/diff-documents.test.ts +++ b/packages/core/src/diff/__tests__/diff-documents.test.ts @@ -194,7 +194,7 @@ describe('diffDocuments', () => { // the path template and the parameter name are reported as changes of their own, // both keyed by the path's shape. expect(report(result)).toMatchInlineSnapshot(` - "patch modified #/paths/~1pet~1{0} · path + "patch modified #/paths/~1pet~1{0} · key at base.yaml #/paths/~1pet~1{id} → rev.yaml #/paths/~1pet~1{petId} patch modified #/paths/~1pet~1{0}/get/parameters/{path:0} · name at base.yaml #/paths/~1pet~1{id}/get/parameters/0/name → rev.yaml #/paths/~1pet~1{petId}/get/parameters/0/name" @@ -238,7 +238,7 @@ describe('diffDocuments', () => { const result = diffDocuments({ base, revision, config }); expect(report(result)).toMatchInlineSnapshot(` - "patch modified #/paths/~1a~1{0}~1b · path + "patch modified #/paths/~1a~1{0}~1b · key at base.yaml #/paths/~1a~1{x}~1b → rev.yaml #/paths/~1a~1{y}~1b minor added #/paths/~1a~1{0}~1b#2 at rev.yaml #/paths/~1a~1{z}~1b" diff --git a/packages/core/src/diff/__tests__/diff-tree.test.ts b/packages/core/src/diff/__tests__/diff-tree.test.ts index 8928855f92..66cbf15c4a 100644 --- a/packages/core/src/diff/__tests__/diff-tree.test.ts +++ b/packages/core/src/diff/__tests__/diff-tree.test.ts @@ -18,12 +18,12 @@ async function nodesOf(yaml: string) { return buildNodeTree({ document, types, specVersion }).root; } -/** Every node's key, the document compared with itself. */ +/** Every node's label, the document compared with itself. */ async function labelsOf(yaml: string): Promise> { const root = await nodesOf(yaml); const labels = new Map(); const visit = (node: DiffNode) => { - labels.set(node.key, node); + labels.set(node.label, node); node.children.forEach(visit); }; visit(buildDiffTree(root, root, oas3Identities).root); @@ -124,7 +124,7 @@ describe('buildDiffTree', () => { .flatMap((node) => node.children) .find((node) => node.base?.type === 'ParameterList')!; - expect(list.children.map((node) => [node.key, node.base?.key, node.revision?.key])).toEqual([ + expect(list.children.map((node) => [node.label, node.base?.key, node.revision?.key])).toEqual([ ['#/paths/~1p/get/parameters/{query:a}', 0, 0], ['#/paths/~1p/get/parameters/{query:a}#2', 1, undefined], ]); diff --git a/packages/core/src/diff/__tests__/judge.test.ts b/packages/core/src/diff/__tests__/judge.test.ts index abb6f23f07..e6ee65a677 100644 --- a/packages/core/src/diff/__tests__/judge.test.ts +++ b/packages/core/src/diff/__tests__/judge.test.ts @@ -1,7 +1,7 @@ import { defaultDiffRules } from '../../config/diff-recommended.js'; import { Location } from '../../ref-utils.js'; import { Source } from '../../resolve.js'; -import { activeHandlers, appliesTo, flattenVisitor, judgeChanges } from '../judge.js'; +import { appliesTo, judgeChanges } from '../judge.js'; import { oas3Rules } from '../rules/index.js'; import { oas3Directions } from '../specs/oas3.js'; import type { Change, DiffNode, DiffRule, Impact } from '../types.js'; @@ -15,7 +15,7 @@ const at = (pointer: string) => new Location(source, pointer); const loneNode = (type: string, location: Location): DiffNode => { const key = location.pointer.slice(location.pointer.lastIndexOf('/') + 1); const node = { type, key, location, value: {}, parent: null, children: [] }; - return { base: node, revision: node, parent: null, children: [], key: '#/' }; + return { base: node, revision: node, parent: null, children: [], label: '#/' }; }; // None of these lone nodes has a direction. @@ -161,6 +161,66 @@ describe('judgeChanges', () => { expect(modified.impact).toBe('patch'); }); + it('runs a nested handler for a member only and an any handler for every change', () => { + const entries = treeOf(` + #/ Root + #/components Components + #/components/schemas NamedSchemas + #/components/schemas/Pet Schema + #/components/schemas/Pet/properties SchemaProperties + #/components/schemas/Pet/properties/name Schema + `); + const nodes = diffNodesOfTree(entries); + const removedAt = (pointer: string): Change => ({ + key: pointer, + kind: 'removed', + node: nodes.get(entries.get(pointer)!)!, + base: { location: at(pointer), value: {} }, + }); + const rule: DiffRule = () => ({ + any(_change, { report }) { + report({ message: 'any' }); + }, + SchemaProperties: { + Schema(_change, { report }) { + report({ message: 'property' }); + }, + }, + }); + + const judged = judgeChanges({ + changes: [ + removedAt('#/components/schemas/Pet'), + removedAt('#/components/schemas/Pet/properties/name'), + ], + specVersion: 'oas3_1', + ruleSets: [{ 'my-rule': rule }], + impactOf: () => 'major', + ...unlinked, + }); + + expect(judged.map((change) => change.verdicts.map((verdict) => verdict.message))).toEqual([ + ['any'], + ['any', 'property'], + ]); + }); + + it('rejects the lint hooks and a nested any, which a diff rule has no use for', () => { + const judgeWith = (rule: DiffRule) => () => + judgeChanges({ + changes: [], + specVersion: 'oas3_1', + ruleSets: [{ 'my-rule': rule }], + impactOf: () => 'major', + ...unlinked, + }); + + expect(judgeWith(() => ({ Schema: { enter() {} } }))).toThrow( + "Diff rule 'my-rule' uses 'enter'" + ); + expect(judgeWith(() => ({ Schema: { any() {} } }))).toThrow("Diff rule 'my-rule' uses 'any'"); + }); + it('stamps the configured impact on a verdict and skips a rule set to off', () => { const changes: Change[] = [ { @@ -246,43 +306,3 @@ describe('appliesTo', () => { expect(appliesTo([], diffNodeAt('#/'))).toBe(true); }); }); - -describe('flattenVisitor', () => { - it('flattens nested keys to type paths and any to the empty path', () => { - const visit = () => {}; - const handlers = flattenVisitor( - { any: visit, Schema: visit, SchemaProperties: { Schema: visit } }, - 'my-rule' - ); - - expect(handlers.map(({ typePath }) => typePath)).toEqual([ - [], - ['Schema'], - ['SchemaProperties', 'Schema'], - ]); - }); - - it('rejects the lint hooks and a nested any, which a diff rule has no use for', () => { - expect(() => flattenVisitor({ Schema: { enter: () => {} } }, 'my-rule')).toThrow( - "Diff rule 'my-rule' uses 'enter'" - ); - expect(() => flattenVisitor({ Schema: { any: () => {} } }, 'my-rule')).toThrow( - "Diff rule 'my-rule' uses 'any'" - ); - }); -}); - -describe('activeHandlers', () => { - it('gives every handler of an enabled rule its id and configured impact', () => { - const rule: DiffRule = () => ({ Schema() {}, SchemaProperties: { Schema() {} } }); - - expect( - activeHandlers([{ 'my-rule': rule, 'off-rule': rule }], (ruleId) => - ruleId === 'my-rule' ? 'major' : 'off' - ).map(({ ruleId, impact, typePath }) => [ruleId, impact, typePath]) - ).toEqual([ - ['my-rule', 'major', ['Schema']], - ['my-rule', 'major', ['SchemaProperties', 'Schema']], - ]); - }); -}); diff --git a/packages/core/src/diff/__tests__/tree.ts b/packages/core/src/diff/__tests__/tree.ts index 68e7c2f508..7469a3247e 100644 --- a/packages/core/src/diff/__tests__/tree.ts +++ b/packages/core/src/diff/__tests__/tree.ts @@ -47,7 +47,7 @@ export function diffNodesOfTree(entries: Map): Map { - const { key, base, revision } = node; + const { label: key, base, revision } = node; if (!base) { changes.push({ key, node, kind: 'added', revision: located(latestOf(node)) }); return; @@ -24,8 +25,20 @@ export function collectChanges(root: DiffNode, identities: Identities): Change[] return; } - const before = comparableValues(node, base, identities); - const after = comparableValues(node, revision, identities); + // Map entries matched by an identity other than their key, such as paths of the same shape. + if (typeof base.key === 'string' && base.key !== revision.key) { + changes.push({ + key, + node, + kind: 'modified', + property: 'key', + base: { location: base.location, value: base.key }, + revision: { location: revision.location, value: revision.key }, + }); + } + + const before = comparableValues(base); + const after = comparableValues(revision); for (const property of Object.keys({ ...before, ...after })) { if (dequal(before[property], after[property])) continue; @@ -48,15 +61,8 @@ export function collectChanges(root: DiffNode, identities: Identities): Change[] return changes; } -/** - * The node's own values — scalars and scalar arrays; nested objects, arrays and `$ref`s are - * nodes of their own. A value the segment hides, such as a path template, is compared too. - */ -function comparableValues( - node: DiffNode, - side: NodeEntry, - identities: Identities -): Record { +/** The node's own scalars and scalar arrays; nested objects, arrays and `$ref`s are nodes of their own. */ +function comparableValues(side: NodeEntry): Record { const values: Record = {}; if (isPlainObject(side.value)) { @@ -65,16 +71,15 @@ function comparableValues( } } - const identity = node.parent && identityOf(side, typeOf(node.parent), identities); - return { ...values, ...identity?.values }; + return values; } // A `$ref` added or removed stands for what it points at, so a rule reads the target's value. function located(side: NodeEntry): LocatedNode { - return { location: side.location, value: side.target?.value ?? side.value }; + return { location: side.location, value: valueOf(side) }; } -// A value the node does not carry as a property (the path template) is located at the node. +// A property one side does not have is located at the node itself. function locatedProperty(side: NodeEntry, property: string, value: unknown): LocatedNode { const isOwnProperty = isPlainObject(side.value) && property in side.value; return { location: isOwnProperty ? side.location.child([property]) : side.location, value }; diff --git a/packages/core/src/diff/diff-tree.ts b/packages/core/src/diff/diff-tree.ts index bfef35fcac..650503dbdc 100644 --- a/packages/core/src/diff/diff-tree.ts +++ b/packages/core/src/diff/diff-tree.ts @@ -1,6 +1,6 @@ import type { NodeEntry } from '../node-tree/types.js'; import { escapePointerFragment, joinPointer } from '../ref-utils.js'; -import type { DiffNode, Identities, NodeIdentity } from './types.js'; +import type { DiffNode, Identities } from './types.js'; export function latestOf(node: DiffNode): NodeEntry { return (node.revision ?? node.base)!; @@ -25,10 +25,10 @@ export function buildDiffTree( function addNode( parent: DiffNode | null, - key: string, + label: string, sides: Pick ): DiffNode { - const node: DiffNode = { ...sides, parent, children: [], key }; + const node: DiffNode = { ...sides, parent, children: [], label }; parent?.children.push(node); if (node.base) diffNodeOf.set(node.base, node); if (node.revision) diffNodeOf.set(node.revision, node); @@ -56,18 +56,18 @@ export function buildDiffTree( baseNodes: NodeEntry[], revisionNodes: NodeEntry[] ): void { - const sharedKey = joinPointer(parent.key, segment); + const sharedLabel = joinPointer(parent.label, segment); for (let index = 0; index < Math.max(baseNodes.length, revisionNodes.length); index++) { - const key = index === 0 ? sharedKey : `${sharedKey}#${index + 1}`; + const label = index === 0 ? sharedLabel : `${sharedLabel}#${index + 1}`; const base = baseNodes[index]; const revision = revisionNodes[index]; if (base && revision && base.type !== revision.type) { // A different kind of node in the same place is a removal and an addition. - addNode(parent, key, { base }); - addNode(parent, key, { revision }); + addNode(parent, label, { base }); + addNode(parent, label, { revision }); } else { - addNode(parent, key, { base, revision }); + addNode(parent, label, { base, revision }); } } } @@ -75,24 +75,12 @@ export function buildDiffTree( return { root: addNode(null, '#/', { base, revision }), diffNodeOf }; } -/** - * What the specification says identifies a child of a container. A `$ref` is identified by what - * it points at, read at the place it is written, so a referenced parameter still has its name. - */ -export function identityOf( - child: NodeEntry, - container: string, - identities: Identities -): NodeIdentity | undefined { - const subject = child.target ? { ...child.target, key: child.key, parent: child.parent } : child; - return identities[container]?.(subject); -} - -// A list item has no name of its own: an inline one is known by its position, a `$ref` by the -// node it points at, so reordering references is not a change. +// A child the specification does not identify keeps its key. A list item has no name of its +// own: an inline one is known by its position, a `$ref` by the node it points at, so reordering +// references is not a change. function segmentOf(child: NodeEntry, container: string, identities: Identities): string { - const identity = identityOf(child, container, identities); - if (identity) return identity.segment; + const identity = identities[container]?.(child); + if (identity) return identity; if (typeof child.key === 'number' && child.target) { return `{${escapePointerFragment(child.target.location.pointer)}}`; } diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index ed9c1b9f5a..8f0532a56d 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -10,6 +10,7 @@ import { buildDiffTree, typeOf } from './diff-tree.js'; import { resolveDirections } from './direction.js'; import { highestImpact } from './impact.js'; import { judgeChanges } from './judge.js'; +import { isDiffFamily } from './rules/index.js'; import { diffSpecs } from './specs/index.js'; import type { DiffResult, DiffSummary, Impact, JudgedChange } from './types.js'; @@ -37,7 +38,9 @@ export function diffDocuments(opts: { }); }; - const { identities = {}, directions = {} } = diffSpecs[family] ?? {}; + const { identities, directions } = isDiffFamily(family) + ? diffSpecs[family] + : { identities: {}, directions: {} }; const collectedBase = collect(base, baseVersion); const collectedRevision = collect(revision, revisionVersion); @@ -53,7 +56,7 @@ export function diffDocuments(opts: { const directionOf = resolveDirections(references, diffNodeOf, directions); const changes = judgeChanges({ - changes: collectChanges(root, identities), + changes: collectChanges(root), specVersion: revisionVersion, ruleSets: config.getDiffRulesForSpecVersion(family), impactOf: (ruleId) => config.getDiffImpact(ruleId, revisionVersion), diff --git a/packages/core/src/diff/judge.ts b/packages/core/src/diff/judge.ts index e97845bcbc..c51994bd09 100644 --- a/packages/core/src/diff/judge.ts +++ b/packages/core/src/diff/judge.ts @@ -17,7 +17,7 @@ import type { } from './types.js'; /** One handler of an enabled rule, with the node types it is nested under; `any` has none. */ -export type RuleHandler = { +type RuleHandler = { ruleId: string; impact: Impact; typePath: string[]; @@ -25,7 +25,7 @@ export type RuleHandler = { }; /** The handlers of every rule the configuration turns on, in rule order. */ -export function activeHandlers( +function activeHandlers( ruleSets: Record[], impactOf: (ruleId: string) => Impact | 'off' ): RuleHandler[] { @@ -50,7 +50,7 @@ const HOOKS = new Set(['enter', 'leave', 'skip']); * A rule's visitor is nested the way lint visitors are. Flattened, each handler carries the * types it is nested under, so a change can be checked against it without walking the visitor. */ -export function flattenVisitor( +function flattenVisitor( visitor: DiffVisitor, ruleId: string, outerTypes: string[] = [] diff --git a/packages/core/src/diff/rules/index.ts b/packages/core/src/diff/rules/index.ts index 6abcfcc412..216e424bab 100644 --- a/packages/core/src/diff/rules/index.ts +++ b/packages/core/src/diff/rules/index.ts @@ -88,10 +88,19 @@ export const async3Rules = { ...schemaRules, } satisfies Record; -export type Oas3DiffRuleId = keyof typeof oas3Rules; -export type Async3DiffRuleId = keyof typeof async3Rules; -export type DiffRuleId = Oas3DiffRuleId | Async3DiffRuleId; +/** The specification families diff rules exist for. */ +export const diffRuleSets = { oas3: oas3Rules, async3: async3Rules }; + +export type DiffRuleSets = typeof diffRuleSets; +export type DiffFamily = keyof DiffRuleSets; +export type DiffRuleId = { + [Family in DiffFamily]: keyof DiffRuleSets[Family] & string; +}[DiffFamily]; + +export function isDiffFamily(family: string): family is DiffFamily { + return Object.hasOwn(diffRuleSets, family); +} export const diffRuleIds: string[] = [ - ...new Set([...Object.keys(oas3Rules), ...Object.keys(async3Rules)]), + ...new Set(Object.values(diffRuleSets).flatMap((rules) => Object.keys(rules))), ]; diff --git a/packages/core/src/diff/specs/index.ts b/packages/core/src/diff/specs/index.ts index 141e44cfcc..4c8ec8b11e 100644 --- a/packages/core/src/diff/specs/index.ts +++ b/packages/core/src/diff/specs/index.ts @@ -1,12 +1,10 @@ -import type { SpecMajorVersion } from '../../oas-types.js'; +import type { DiffFamily } from '../rules/index.js'; import type { Directions, Identities } from '../types.js'; import { async3Directions } from './async3.js'; import { oas3Directions, oas3Identities } from './oas3.js'; -/** A family missing here is still compared, by its structure alone, and never judged. */ -export const diffSpecs: Partial< - Record -> = { +/** Every family with diff rules; any other family is compared by its structure alone. */ +export const diffSpecs: Record = { oas3: { identities: oas3Identities, directions: oas3Directions }, // Channels, messages and operations are named maps, so a node's own key is its identity. async3: { identities: {}, directions: async3Directions }, diff --git a/packages/core/src/diff/specs/oas3.ts b/packages/core/src/diff/specs/oas3.ts index ffc0b07240..6decda88ce 100644 --- a/packages/core/src/diff/specs/oas3.ts +++ b/packages/core/src/diff/specs/oas3.ts @@ -1,10 +1,10 @@ -import { enclosing, fieldOf } from '../../node-tree/access.js'; +import { enclosing, fieldOf, valueOf } from '../../node-tree/access.js'; import type { NodeEntry } from '../../node-tree/types.js'; import { escapePointerFragment } from '../../ref-utils.js'; import { isPlainObject } from '../../utils/is-plain-object.js'; import { templateParameterNames, templateShape } from '../../utils/path-template.js'; import { opposite } from '../direction.js'; -import type { Direction, Directions, Identities, NodeIdentity } from '../types.js'; +import type { Direction, Directions, Identities } from '../types.js'; export const oas3Identities: Identities = { Paths: pathIdentity, @@ -37,20 +37,17 @@ function side(direction: Direction) { // "Templated paths with the same hierarchy but different templated names MUST NOT exist // as they are identical" (OpenAPI, Paths Object) — the shape is the identity, the names are not. -function pathIdentity(path: NodeEntry): NodeIdentity { - return { - segment: escapePointerFragment(templateShape(String(path.key))), - values: { path: path.key }, - }; +function pathIdentity(path: NodeEntry): string { + return escapePointerFragment(templateShape(String(path.key))); } -function parameterIdentity(parameter: NodeEntry): NodeIdentity | undefined { +function parameterIdentity(parameter: NodeEntry): string | undefined { const location = fieldOf(parameter, 'in'); const name = fieldOf(parameter, 'name'); if (typeof location !== 'string' || typeof name !== 'string') return undefined; return location === 'path' - ? { segment: pathParameterSegment(name, parameter) } - : { segment: `{${escapePointerFragment(location)}:${escapePointerFragment(name)}}` }; + ? pathParameterSegment(name, parameter) + : `{${escapePointerFragment(location)}:${escapePointerFragment(name)}}`; } /** @@ -65,12 +62,12 @@ function pathParameterSegment(name: string, parameter: NodeEntry): string { return position === -1 ? `{path:${escapePointerFragment(name)}}` : `{path:${position}}`; } -function schemeNamesIdentity(requirement: NodeEntry): NodeIdentity | undefined { - if (!isPlainObject(requirement.value)) return undefined; - const names = Object.keys(requirement.value).sort().map(escapePointerFragment); - return { segment: `{${names.join('+')}}` }; +function schemeNamesIdentity(requirement: NodeEntry): string | undefined { + const value = valueOf(requirement); + if (!isPlainObject(value)) return undefined; + return `{${Object.keys(value).sort().map(escapePointerFragment).join('+')}}`; } -function named(value: unknown): NodeIdentity | undefined { - return typeof value === 'string' ? { segment: `{${escapePointerFragment(value)}}` } : undefined; +function named(value: unknown): string | undefined { + return typeof value === 'string' ? `{${escapePointerFragment(value)}}` : undefined; } diff --git a/packages/core/src/diff/types.ts b/packages/core/src/diff/types.ts index b285795420..f68778096d 100644 --- a/packages/core/src/diff/types.ts +++ b/packages/core/src/diff/types.ts @@ -14,18 +14,14 @@ export type DiffNode = { parent: DiffNode | null; children: DiffNode[]; /** Segments from the root joined like a pointer; `#n` marks the n-th sibling with one segment. */ - key: string; + label: string; }; -export type NodeIdentity = { - /** Replaces the node's own key when the two documents are matched up. */ - segment: string; - /** Values the segment hides, compared as if the node carried them. */ - values?: Record; -}; - -/** Per container type: the identity of a child, where its key or position is not it. */ -export type Identities = Partial NodeIdentity | undefined>>; +/** + * Per container type: the segment that identifies a child, where its key or position does not. + * It replaces the child's key when the two documents are matched up. + */ +export type Identities = Partial string | undefined>>; /** Per node type: which way the data below it travels; the nearest type that says it wins. */ export type Directions = Partial< @@ -34,6 +30,7 @@ export type Directions = Partial< export type LocatedNode = { location: Location; value: unknown }; +/** `key` is the label of the node the change is on. */ export type Change = { key: string; node: DiffNode } & ( | { kind: 'added'; revision: LocatedNode } | { kind: 'removed'; base: LocatedNode } diff --git a/packages/core/src/node-tree/access.ts b/packages/core/src/node-tree/access.ts index bb57f91b9d..632061a0de 100644 --- a/packages/core/src/node-tree/access.ts +++ b/packages/core/src/node-tree/access.ts @@ -9,6 +9,12 @@ export function enclosing(node: NodeEntry | null, type: string): NodeEntry | und return undefined; } +/** What the node stands for: a `$ref` stands for the node it points at. */ +export function valueOf(node: NodeEntry): NodeEntry['value'] { + return node.target?.value ?? node.value; +} + export function fieldOf(node: NodeEntry | undefined, name: string): unknown { - return node && isPlainObject(node.value) ? node.value[name] : undefined; + const value = node && valueOf(node); + return isPlainObject(value) ? value[name] : undefined; } diff --git a/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt b/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt index caaacb50ba..36cb9cbb3b 100644 --- a/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt +++ b/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt @@ -1,5 +1,5 @@ /pets/{petId} - · patch modified path + · patch modified key at revision.yaml:7:5 GET /pets/{petId} From 9480477e32291963f634c5af217a488dde7fc113 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:12:05 +0300 Subject: [PATCH 47/57] test(diff): unit-test each diff rule on the Cafe API and judge an OpenAPI 3.0 nullable change as a type change --- packages/core/__tests__/utils.ts | 25 ++ .../core/src/diff/__tests__/changes.test.ts | 251 +++++++---- .../src/diff/__tests__/diff-config.test.ts | 92 ---- .../src/diff/__tests__/diff-documents.test.ts | 247 ----------- .../core/src/diff/__tests__/diff-tree.test.ts | 243 +++++----- .../core/src/diff/__tests__/direction.test.ts | 354 +++++++-------- .../diff/__tests__/documented-rules.test.ts | 25 -- .../core/src/diff/__tests__/impact.test.ts | 14 +- .../core/src/diff/__tests__/index.test.ts | 417 ++++++++++++++++++ .../core/src/diff/__tests__/judge.test.ts | 376 +++++----------- .../core/src/diff/__tests__/rules.test.ts | 33 -- packages/core/src/diff/__tests__/tree.ts | 72 --- packages/core/src/diff/__tests__/utils.ts | 72 +++ .../additional-properties-changed.test.ts | 138 ++++++ .../__tests__/channel-address-changed.test.ts | 50 +++ .../rules/__tests__/channel-removed.test.ts | 84 ++++ .../rules/__tests__/enum-values-added.test.ts | 93 ++++ .../__tests__/enum-values-removed.test.ts | 96 ++++ .../__tests__/media-type-removed.test.ts | 57 +++ .../message-content-type-changed.test.ts | 53 +++ .../rules/__tests__/message-removed.test.ts | 90 ++++ .../__tests__/numeric-range-changed.test.ts | 126 ++++++ .../operation-action-changed.test.ts | 54 +++ .../rules/__tests__/operation-removed.test.ts | 54 +++ .../parameter-added-required.test.ts | 145 ++++++ .../parameter-became-required.test.ts | 83 ++++ .../rules/__tests__/parameter-removed.test.ts | 156 +++++++ .../parameter-serialization-changed.test.ts | 68 +++ .../diff/rules/__tests__/path-removed.test.ts | 55 +++ .../property-removed-from-response.test.ts | 118 +++++ .../__tests__/ref-target-changed.test.ts | 63 +++ .../request-body-became-required.test.ts | 84 ++++ .../__tests__/request-body-removed.test.ts | 57 +++ .../required-properties-added.test.ts | 89 ++++ .../required-properties-removed.test.ts | 92 ++++ .../__tests__/response-header-removed.test.ts | 100 +++++ .../rules/__tests__/response-removed.test.ts | 54 +++ .../schema-combinator-changed.test.ts | 124 ++++++ .../__tests__/schema-format-changed.test.ts | 79 ++++ .../__tests__/schema-type-changed.test.ts | 286 +++++++++++- .../security-requirement-added.test.ts | 129 ++++++ .../__tests__/security-scheme-changed.test.ts | 141 ++++++ .../__tests__/security-scheme-removed.test.ts | 52 +++ .../__tests__/security-scopes-added.test.ts | 73 +++ .../rules/__tests__/server-removed.test.ts | 86 ++++ .../__tests__/string-length-changed.test.ts | 129 ++++++ .../src/diff/rules/__tests__/utils.test.ts | 20 - .../src/diff/rules/schema-type-changed.ts | 19 +- .../src/node-tree/__tests__/build.test.ts | 160 ++----- 49 files changed, 4287 insertions(+), 1291 deletions(-) delete mode 100644 packages/core/src/diff/__tests__/diff-config.test.ts delete mode 100644 packages/core/src/diff/__tests__/diff-documents.test.ts delete mode 100644 packages/core/src/diff/__tests__/documented-rules.test.ts create mode 100644 packages/core/src/diff/__tests__/index.test.ts delete mode 100644 packages/core/src/diff/__tests__/rules.test.ts delete mode 100644 packages/core/src/diff/__tests__/tree.ts create mode 100644 packages/core/src/diff/__tests__/utils.ts create mode 100644 packages/core/src/diff/rules/__tests__/additional-properties-changed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/channel-removed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/enum-values-added.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/media-type-removed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/message-removed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/numeric-range-changed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/operation-removed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/parameter-added-required.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/parameter-removed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/path-removed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/property-removed-from-response.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/request-body-removed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/required-properties-added.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/response-header-removed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/response-removed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/schema-format-changed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/security-requirement-added.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/security-scheme-removed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/security-scopes-added.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/server-removed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/string-length-changed.test.ts delete mode 100644 packages/core/src/diff/rules/__tests__/utils.test.ts diff --git a/packages/core/__tests__/utils.ts b/packages/core/__tests__/utils.ts index 18ad19e1a9..c20752f1c4 100644 --- a/packages/core/__tests__/utils.ts +++ b/packages/core/__tests__/utils.ts @@ -1,7 +1,9 @@ import * as path from 'path'; import { + type Change, type Document, + type JudgedChange, Source, type NormalizedProblem, parseYaml, @@ -15,6 +17,29 @@ export function parseYamlToDocument(body: string, absoluteRef: string = ''): Doc }; } +export function replaceSourceWithRefInChanges(changes: Array) { + return changes.map(({ node: _node, ...change }) => { + const mapped: Record = { ...change }; + + if ('base' in change) { + mapped.base = { ...change.base, location: change.base.location.absolutePointer }; + } + + if ('revision' in change) { + mapped.revision = { ...change.revision, location: change.revision.location.absolutePointer }; + } + + if ('verdicts' in change) { + mapped.verdicts = change.verdicts.map((verdict) => ({ + ...verdict, + location: verdict.location.absolutePointer, + })); + } + + return mapped; + }); +} + export function replaceSourceWithRef(results: NormalizedProblem[], cwd?: string) { const cwdRegexp = cwd ? new RegExp(cwd + path.sep, 'g') : /$^/; return results.map((r) => { diff --git a/packages/core/src/diff/__tests__/changes.test.ts b/packages/core/src/diff/__tests__/changes.test.ts index c83359bb73..d94271c5ef 100644 --- a/packages/core/src/diff/__tests__/changes.test.ts +++ b/packages/core/src/diff/__tests__/changes.test.ts @@ -1,106 +1,179 @@ -import type { NodeEntry, NodeValue } from '../../node-tree/types.js'; -import { Location } from '../../ref-utils.js'; -import { Source } from '../../resolve.js'; +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../__tests__/utils.js'; import { collectChanges } from '../changes.js'; import { buildDiffTree } from '../diff-tree.js'; +import { nodeTreeOf, treeOf } from './utils.js'; -const source = new Source('api.yaml', ''); - -/** A root with one level of children, each given as `key: { type, value }`. */ -function documentOf(children: Record) { - const root: NodeEntry = { - type: 'Root', - key: '', - location: new Location(source, '#/'), - value: {}, - parent: null, - children: [], - }; - const nodes = new Map([['#/', root]]); - for (const [key, { type = 'Schema', value = {} }] of Object.entries(children)) { - const node: NodeEntry = { - type, - key, - location: root.location.child([key]), - value, - parent: root, - children: [], - }; - root.children.push(node); - nodes.set(node.location.pointer, node); - } - return nodes; -} - -// No identity of its own: the structure alone decides. -const changesBetween = ( - base: ReturnType, - revision: ReturnType -) => collectChanges(buildDiffTree(base.get('#/')!, revision.get('#/')!, {}).root); - -/** `kind key · property base-pointer → revision-pointer` per change. */ -function summarize(changes: ReturnType): string[] { - return changes.map((change) => { - const property = change.kind === 'modified' ? ` · ${change.property}` : ''; - const base = change.kind === 'added' ? '-' : change.base.location.pointer; - const revision = change.kind === 'removed' ? '-' : change.revision.location.pointer; - return `${change.kind} ${change.key}${property} ${base} → ${revision}`; - }); -} +const cafe = (menuItem: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: {} + components: + schemas: + MenuItem: ${menuItem} +`; describe('collectChanges', () => { - it('emits one modified change per differing property, located at the escaped property pointer', () => { - const base = documentOf({ a: { value: { type: 'integer', 'x/y': 1 } } }); - const revision = documentOf({ a: { value: { type: 'number', 'x/y': 2 } } }); - - expect(summarize(changesBetween(base, revision))).toEqual([ - 'modified #/a · type #/a/type → #/a/type', - 'modified #/a · x/y #/a/x~1y → #/a/x~1y', - ]); + it('should report every property that differs, at its escaped pointer on each side', async () => { + const base = await nodeTreeOf( + cafe("{ type: integer, x-price/unit: cents, description: 'A drink' }") + ); + const revision = await nodeTreeOf( + cafe("{ type: number, x-price/unit: euros, description: 'A drink' }") + ); + + const changes = collectChanges(buildDiffTree(base.root, revision.root, {}).root); + + expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "api.yaml#/components/schemas/MenuItem/type", + "value": "integer", + }, + "key": "#/components/schemas/MenuItem", + "kind": "modified", + "property": "type", + "revision": { + "location": "api.yaml#/components/schemas/MenuItem/type", + "value": "number", + }, + }, + { + "base": { + "location": "api.yaml#/components/schemas/MenuItem/x-price~1unit", + "value": "cents", + }, + "key": "#/components/schemas/MenuItem", + "kind": "modified", + "property": "x-price/unit", + "revision": { + "location": "api.yaml#/components/schemas/MenuItem/x-price~1unit", + "value": "euros", + }, + }, + ] + `); }); - it('reports a removed node once, with the whole node as the value, and nothing below it', () => { - const base = documentOf({ a: { value: { b: { c: 1 } } } }); - const nested: NodeEntry = { - type: 'Schema', - key: 'b', - location: new Location(source, '#/a/b'), - value: { c: 1 }, - parent: base.get('#/a')!, - children: [], - }; - base.get('#/a')!.children.push(nested); - base.set('#/a/b', nested); - - const changes = changesBetween(base, documentOf({})); - - expect(summarize(changes)).toEqual(['removed #/a #/a → -']); - expect(changes[0].kind === 'removed' && changes[0].base.value).toEqual({ b: { c: 1 } }); + it('should locate a property one side does not have at the node itself', async () => { + const base = await nodeTreeOf(cafe('{ type: object }')); + const revision = await nodeTreeOf(cafe('{ type: object, minProperties: 1 }')); + + const changes = collectChanges(buildDiffTree(base.root, revision.root, {}).root); + + expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "api.yaml#/components/schemas/MenuItem", + "value": undefined, + }, + "key": "#/components/schemas/MenuItem", + "kind": "modified", + "property": "minProperties", + "revision": { + "location": "api.yaml#/components/schemas/MenuItem/minProperties", + "value": 1, + }, + }, + ] + `); }); - it('treats a node whose type changed as a removed+added pair at the same key', () => { - const base = documentOf({ a: { type: 'Schema' } }); - const revision = documentOf({ a: { type: 'Parameter' } }); - - expect(summarize(changesBetween(base, revision))).toEqual([ - 'removed #/a #/a → -', - 'added #/a - → #/a', - ]); + it('should report a removed node once, with its whole value, and nothing below it', async () => { + const base = await nodeTreeOf(cafe('{ properties: { price: { type: number, minimum: 0 } } }')); + const revision = await nodeTreeOf(cafe('{ properties: {} }')); + + const changes = collectChanges(buildDiffTree(base.root, revision.root, {}).root); + + expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "api.yaml#/components/schemas/MenuItem/properties/price", + "value": { + "minimum": 0, + "type": "number", + }, + }, + "key": "#/components/schemas/MenuItem/properties/price", + "kind": "removed", + }, + ] + `); }); - it('compares the target of a $ref node like any other value of it', () => { - const base = documentOf({ schema: { value: { $ref: '#/A' } } }); - const revision = documentOf({ schema: { value: { $ref: '#/B' } } }); - - const [change] = changesBetween(base, revision); + it('should report a node of another type in the same place as a removal and an addition', () => { + // The walker gives a place its type from the document, so the two sides can disagree. + const base = treeOf(` + #/ Root + #/components Components + #/components/examples NamedExamples + #/components/examples/latte Example + `); + const revision = treeOf(` + #/ Root + #/components Components + #/components/examples NamedExamples + #/components/examples/latte Schema + `); + + const changes = collectChanges(buildDiffTree(base.get('#/')!, revision.get('#/')!, {}).root); + + expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "tree.yaml#/components/examples/latte", + "value": {}, + }, + "key": "#/components/examples/latte", + "kind": "removed", + }, + { + "key": "#/components/examples/latte", + "kind": "added", + "revision": { + "location": "tree.yaml#/components/examples/latte", + "value": {}, + }, + }, + ] + `); + }); - expect(change.kind === 'modified' && change.property).toBe('$ref'); - expect(change.kind === 'modified' && change.revision.value).toBe('#/B'); + it('should compare a $ref by where it points', async () => { + const base = await nodeTreeOf(cafe("{ $ref: '#/components/schemas/Beverage' }")); + const revision = await nodeTreeOf(cafe("{ $ref: '#/components/schemas/Dessert' }")); + + const changes = collectChanges(buildDiffTree(base.root, revision.root, {}).root); + + expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "api.yaml#/components/schemas/MenuItem/$ref", + "value": "#/components/schemas/Beverage", + }, + "key": "#/components/schemas/MenuItem", + "kind": "modified", + "property": "$ref", + "revision": { + "location": "api.yaml#/components/schemas/MenuItem/$ref", + "value": "#/components/schemas/Dessert", + }, + }, + ] + `); }); - it('emits nothing when the two documents are identical', () => { - const shape = { a: { value: { enum: ['x', 'y'] } } }; + it('should report nothing when the documents are the same', async () => { + const { root } = await nodeTreeOf(cafe('{ type: string, enum: [coffee, tea] }')); + + const changes = collectChanges(buildDiffTree(root, root, {}).root); - expect(changesBetween(documentOf(shape), documentOf(shape))).toEqual([]); + expect(changes).toEqual([]); }); }); diff --git a/packages/core/src/diff/__tests__/diff-config.test.ts b/packages/core/src/diff/__tests__/diff-config.test.ts deleted file mode 100644 index f5a9adff06..0000000000 --- a/packages/core/src/diff/__tests__/diff-config.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { outdent } from 'outdent'; - -import { createConfig } from '../../config/index.js'; -import { makeDocumentFromString } from '../../resolve.js'; -import { diffDocuments } from '../index.js'; - -const BASE = outdent` - openapi: 3.1.0 - info: { title: T, version: '1.0.0' } - paths: - /pets: - get: - responses: { '200': { description: OK } } - delete: - responses: { '204': { description: Gone } } -`; - -const REVISION = outdent` - openapi: 3.1.0 - info: { title: T, version: '1.0.0' } - paths: - /pets: - get: - responses: { '200': { description: OK } } -`; - -function diffWith(config: Awaited>) { - return diffDocuments({ - base: makeDocumentFromString(BASE, 'base.yaml'), - revision: makeDocumentFromString(REVISION, 'revision.yaml'), - config, - }); -} - -describe('diff configuration', () => { - it('merges the diff-recommended preset with the user map, later wins', async () => { - const config = await createConfig({ - extends: ['diff-recommended'], - diff: { 'operation-removed': 'minor', 'enum-values-added': 'off' }, - }); - - expect(config.diff.oas3_1?.['operation-removed']).toBe('minor'); - expect(config.diff.oas3_1?.['enum-values-added']).toBe('off'); - expect(config.diff.oas3_1?.['path-removed']).toBe('major'); - }); - - it('uses the configured impact for a verdict', async () => { - const config = await createConfig({ - extends: ['diff-recommended'], - diff: { 'operation-removed': 'minor' }, - }); - - const result = diffWith(config); - - expect(result.changes.map((change) => [change.key, change.impact])).toEqual([ - ['#/paths/~1pets/delete', 'minor'], - ]); - expect(result.bump).toBe('minor'); - }); - - it('takes the impacts from the preset when the config says nothing about diff', async () => { - const config = await createConfig({ extends: ['diff-recommended'] }); - - expect(diffWith(config).changes[0].impact).toBe('major'); - }); - - it('lets a spec block override the impact a preset set', async () => { - const config = await createConfig({ - extends: ['diff-recommended'], - oas3_1Diff: { 'operation-removed': 'minor' }, - }); - - expect(diffWith(config).changes[0].impact).toBe('minor'); - }); - - it('runs only the listed rules when a diff map is given without a ruleset', async () => { - const config = await createConfig({ diff: { 'path-removed': 'major' } }); - - // operation-removed is not in the map, so the removal is an unjudged patch - expect(diffWith(config).changes[0].impact).toBe('patch'); - }); - - it('turns a rule off when the config says so', async () => { - const config = await createConfig({ - extends: ['diff-recommended'], - diff: { 'operation-removed': 'off' }, - }); - - // No rule speaks for the removal any more, so it falls back to an unjudged patch. - expect(diffWith(config).changes[0].impact).toBe('patch'); - }); -}); diff --git a/packages/core/src/diff/__tests__/diff-documents.test.ts b/packages/core/src/diff/__tests__/diff-documents.test.ts deleted file mode 100644 index adf32d348b..0000000000 --- a/packages/core/src/diff/__tests__/diff-documents.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { outdent } from 'outdent'; - -import { createConfig } from '../../config/index.js'; -import { makeDocumentFromString } from '../../resolve.js'; -import { HandledError } from '../../utils/error.js'; -import { diffDocuments } from '../index.js'; -import type { DiffResult } from '../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 sides = [ - change.kind !== 'added' ? change.base : undefined, - change.kind !== 'removed' ? change.revision : undefined, - ].filter((side) => side !== undefined); - const at = sides - .map((side) => `${side.location.source.absoluteRef} ${side.location.pointer}`) - .join(' → '); - const name = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key; - return [ - `${change.impact} ${change.kind} ${name}`, - ...change.verdicts.map((verdict) => ` ${verdict.ruleId}: ${verdict.message}`), - ` at ${at}`, - ].join('\n'); - }) - .join('\n'); -} - -describe('diffDocuments', () => { - it('takes the direction of a component from a site that exists only in the revision', async () => { - const shared = outdent` - components: - schemas: - Size: - type: string - `; - const base = outdent` - openapi: 3.1.0 - info: { title: T, version: '1.0.0' } - paths: {} - ${shared} - enum: [s, m, l] - `; - const revision = outdent` - openapi: 3.1.0 - info: { title: T, version: '1.0.0' } - paths: - /pets: - post: - requestBody: - content: - application/json: - schema: { $ref: '#/components/schemas/Size' } - responses: { '201': { description: Created } } - ${shared} - enum: [s, m] - `; - const config = await createConfig({ extends: ['diff-recommended'] }); - - const result = diffDocuments({ - base: makeDocumentFromString(base, 'base.yaml'), - revision: makeDocumentFromString(revision, 'rev.yaml'), - config, - }); - - const enumChange = result.changes.find( - (change) => change.kind === 'modified' && change.property === 'enum' - ); - expect(enumChange?.verdicts.map((verdict) => verdict.ruleId)).toEqual(['enum-values-removed']); - }); - - it('matches reordered parameters by identity and judges what actually changed', async () => { - const config = await createConfig({ extends: ['diff-recommended'] }); - 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(` - "major modified #/paths/~1pets/get/parameters/{query:limit} · required - parameter-became-required: Parameter became required. - at base.yaml #/paths/~1pets/get/parameters/0 → rev.yaml #/paths/~1pets/get/parameters/1/required - patch modified #/paths/~1pets/get/parameters/{query:limit}/schema · type - at base.yaml #/paths/~1pets/get/parameters/0/schema/type → rev.yaml #/paths/~1pets/get/parameters/1/schema/type - patch modified #/paths/~1pets/get/responses/200 · description - at base.yaml #/paths/~1pets/get/responses/200/description → rev.yaml #/paths/~1pets/get/responses/200/description" - `); - expect(result.summary).toEqual({ major: 1, minor: 0, patch: 2 }); - expect(result.bump).toBe('major'); - }); - - it('throws a handled error for different spec families', async () => { - const config = await createConfig({ extends: ['diff-recommended'] }); - const oas2 = makeDocumentFromString( - outdent` - swagger: '2.0' - info: { title: Test, version: '1.0' } - paths: {} - `, - '' - ); - expect(() => - diffDocuments({ base: oas2, revision: makeDocumentFromString(REVISION, ''), config }) - ).toThrow(HandledError); - }); - - it('compares a specification without diff rules by its structure and judges nothing', async () => { - const config = await createConfig({ extends: ['diff-recommended'] }); - const oas2 = (paths: string) => - makeDocumentFromString( - outdent` - swagger: '2.0' - info: { title: Test, version: '1.0' } - paths: ${paths} - `, - '' - ); - - const result = diffDocuments({ - base: oas2(`{ /pets: { get: { responses: { '200': { description: OK } } } } }`), - revision: oas2('{}'), - config, - }); - - expect(result.changes.map((change) => [change.kind, change.key, change.impact])).toEqual([ - ['removed', '#/paths/~1pets', 'patch'], - ]); - }); - - it('matches renamed path parameters instead of remove+add', async () => { - const config = await createConfig({ extends: ['diff-recommended'] }); - 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 by the path's shape. - expect(report(result)).toMatchInlineSnapshot(` - "patch modified #/paths/~1pet~1{0} · key - at base.yaml #/paths/~1pet~1{id} → rev.yaml #/paths/~1pet~1{petId} - patch modified #/paths/~1pet~1{0}/get/parameters/{path:0} · name - at base.yaml #/paths/~1pet~1{id}/get/parameters/0/name → rev.yaml #/paths/~1pet~1{petId}/get/parameters/0/name" - `); - expect(result.summary.major).toBe(0); - expect(result.bump).toBe('patch'); - }); - - it('keys two templates of the same shape in document order, suffixing the second', async () => { - const config = await createConfig({ extends: ['diff-recommended'] }); - 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' - ); - // The revision is spec-invalid: `/a/{y}/b` and `/a/{z}/b` are the same path. The first one - // matches the base by shape, the second is a new key like any duplicate identity (2.1.4). - const result = diffDocuments({ base, revision, config }); - - expect(report(result)).toMatchInlineSnapshot(` - "patch modified #/paths/~1a~1{0}~1b · key - at base.yaml #/paths/~1a~1{x}~1b → rev.yaml #/paths/~1a~1{y}~1b - minor added #/paths/~1a~1{0}~1b#2 - at rev.yaml #/paths/~1a~1{z}~1b" - `); - }); -}); diff --git a/packages/core/src/diff/__tests__/diff-tree.test.ts b/packages/core/src/diff/__tests__/diff-tree.test.ts index 66cbf15c4a..80002851af 100644 --- a/packages/core/src/diff/__tests__/diff-tree.test.ts +++ b/packages/core/src/diff/__tests__/diff-tree.test.ts @@ -1,132 +1,151 @@ import { outdent } from 'outdent'; -import { createConfig } from '../../config/index.js'; -import { detectSpec } from '../../detect-spec.js'; -import { buildNodeTree } from '../../node-tree/build.js'; -import { getTypes } from '../../oas-types.js'; -import { makeDocumentFromString } from '../../resolve.js'; -import { normalizeTypes } from '../../types/index.js'; import { buildDiffTree } from '../diff-tree.js'; import { oas3Identities } from '../specs/oas3.js'; -import type { DiffNode } from '../types.js'; - -async function nodesOf(yaml: string) { - const document = makeDocumentFromString(yaml, 'api.yaml'); - const config = await createConfig({}); - const specVersion = detectSpec(document.parsed); - const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - return buildNodeTree({ document, types, specVersion }).root; -} - -/** Every node's label, the document compared with itself. */ -async function labelsOf(yaml: string): Promise> { - const root = await nodesOf(yaml); - const labels = new Map(); - const visit = (node: DiffNode) => { - labels.set(node.label, node); - node.children.forEach(visit); - }; - visit(buildDiffTree(root, root, oas3Identities).root); - return labels; -} +import { nodeTreeOf } from './utils.js'; + +const cafe = (body: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + ${body} +`; describe('buildDiffTree', () => { - it('labels a path by its shape and its path parameters by position', async () => { - const labels = await labelsOf(outdent` - openapi: 3.1.0 - info: { title: T, version: '1' } - paths: - /pets/{petId}/toys/{toyId}: - get: - parameters: - - { name: toyId, in: path, required: true, schema: { type: string } } - - { name: petId, in: path, required: true, schema: { type: string } } - - { name: q, in: query, schema: { type: string } } - responses: {} + it('should label a path by its shape and a path parameter by its place in the template', async () => { + const { root } = await nodeTreeOf( + cafe(outdent` + paths: + /orders/{orderId}/items/{itemId}: + get: + parameters: + - { name: itemId, in: path, required: true } + - { name: orderId, in: path, required: true } + - { name: fields, in: query } + responses: {} + `) + ); + const { diffNodeOf } = buildDiffTree(root, root, oas3Identities); + const labels = [...new Set(diffNodeOf.values())].map((node) => node.label); + + const parameterLabels = labels.filter((label) => label.includes('/parameters/')); + + expect(parameterLabels).toMatchInlineSnapshot(` + [ + "#/paths/~1orders~1{0}~1items~1{1}/get/parameters/{path:1}", + "#/paths/~1orders~1{0}~1items~1{1}/get/parameters/{path:0}", + "#/paths/~1orders~1{0}~1items~1{1}/get/parameters/{query:fields}", + ] `); + }); - expect(labels.get('#/paths/~1pets~1{0}~1toys~1{1}')?.base?.key).toBe( - '/pets/{petId}/toys/{toyId}' + it('should keep a callback key as it is and label a path parameter there by its name', async () => { + const { root } = await nodeTreeOf( + cafe(outdent` + paths: + /orders: + post: + responses: {} + callbacks: + orderReady: + '{$request.body#/callbackUrl}': + post: + parameters: + - { name: orderId, in: path, required: true } + responses: {} + `) ); - expect( - [...labels.keys()].filter((key) => key.includes('parameters/') && !key.endsWith('/schema')) - ).toEqual([ - '#/paths/~1pets~1{0}~1toys~1{1}/get/parameters/{path:1}', - '#/paths/~1pets~1{0}~1toys~1{1}/get/parameters/{path:0}', - '#/paths/~1pets~1{0}~1toys~1{1}/get/parameters/{query:q}', - ]); - }); + const { diffNodeOf } = buildDiffTree(root, root, oas3Identities); + const labels = [...new Set(diffNodeOf.values())].map((node) => node.label); + + const parameterLabels = labels.filter((label) => label.includes('/parameters/')); - it('keeps a callback key as it is and falls back to the parameter name there', async () => { - const labels = await labelsOf(outdent` - openapi: 3.1.0 - info: { title: T, version: '1' } - paths: - /subscribe: - post: - responses: {} - callbacks: - onEvent: - '{$request.body#/url}': - post: - parameters: - - { name: id, in: path, required: true, schema: { type: string } } - responses: {} + expect(parameterLabels).toMatchInlineSnapshot(` + [ + "#/paths/~1orders/post/callbacks/orderReady/{$request.body#~1callbackUrl}/post/parameters/{path:orderId}", + ] `); + }); - expect(labels.has('#/paths/~1subscribe/post/callbacks/onEvent/{$request.body#~1url}')).toBe( - true + it('should label servers by url, tags by name and security requirements by scheme names', async () => { + const { root } = await nodeTreeOf( + cafe(outdent` + servers: + - url: https://api.cafe.example + tags: + - name: Orders + security: + - OAuth: [orders:read] + ApiKey: [] + paths: {} + `) ); - expect( - labels.has( - '#/paths/~1subscribe/post/callbacks/onEvent/{$request.body#~1url}/post/parameters/{path:id}' - ) - ).toBe(true); - }); + const { diffNodeOf } = buildDiffTree(root, root, oas3Identities); + const labels = [...new Set(diffNodeOf.values())].map((node) => node.label); - it('labels servers by url, tags by name, and security requirements by their scheme names', async () => { - const labels = await labelsOf(outdent` - openapi: 3.1.0 - info: { title: T, version: '1' } - servers: - - url: https://a.example/v1 - tags: - - name: pets - security: - - oauth: [read] - apiKey: [] - paths: {} + const identifiedLabels = labels.filter((label) => /\/(servers|tags|security)\//.test(label)); + + expect(identifiedLabels).toMatchInlineSnapshot(` + [ + "#/servers/{https:~1~1api.cafe.example}", + "#/security/{ApiKey+OAuth}", + "#/tags/{Orders}", + ] `); + }); + + it('should match a referenced list item by what it points at, wherever it is listed', async () => { + const menu = (parameters: string) => + cafe(outdent` + paths: + /menu: + get: + parameters: ${parameters} + responses: {} + components: + schemas: + Sort: { name: sort, in: query } + Filter: { name: filter, in: query } + `); - expect(labels.has('#/servers/{https:~1~1a.example~1v1}')).toBe(true); - expect(labels.has('#/tags/{pets}')).toBe(true); - expect(labels.has('#/security/{apiKey+oauth}')).toBe(true); + const base = await nodeTreeOf( + menu("[{ $ref: '#/components/schemas/Sort' }, { $ref: '#/components/schemas/Filter' }]") + ); + const revision = await nodeTreeOf( + menu("[{ $ref: '#/components/schemas/Filter' }, { $ref: '#/components/schemas/Sort' }]") + ); + const { diffNodeOf } = buildDiffTree(base.root, revision.root, oas3Identities); + const nodes = new Map([...diffNodeOf.values()].map((node) => [node.label, node])); + const sort = nodes.get('#/paths/~1menu/get/parameters/{query:sort}')!; + const filter = nodes.get('#/paths/~1menu/get/parameters/{query:filter}')!; + + expect(sort.base?.key).toBe(0); + expect(sort.revision?.key).toBe(1); + expect(filter.base?.key).toBe(1); + expect(filter.revision?.key).toBe(0); }); - it('matches siblings with the same identity in order and numbers the label from the second one', async () => { - const yaml = (second: string) => outdent` - openapi: 3.1.0 - info: { title: T, version: '1' } - paths: - /p: - get: - parameters: - - { name: a, in: query } - ${second} - responses: {} - `; - const base = await nodesOf(yaml('- { name: a, in: query, required: true }')); - const revision = await nodesOf(yaml('')); - - const list = buildDiffTree(base, revision, oas3Identities) - .root.children.flatMap((node) => node.children) - .flatMap((node) => node.children) - .flatMap((node) => node.children) - .find((node) => node.base?.type === 'ParameterList')!; - - expect(list.children.map((node) => [node.label, node.base?.key, node.revision?.key])).toEqual([ - ['#/paths/~1p/get/parameters/{query:a}', 0, 0], - ['#/paths/~1p/get/parameters/{query:a}#2', 1, undefined], - ]); + it('should match items that share an identity in document order and number the extra one', async () => { + const menu = (parameters: string) => + cafe(outdent` + paths: + /menu: + get: + parameters: ${parameters} + responses: {} + `); + + const base = await nodeTreeOf( + menu('[{ name: search, in: query }, { name: search, in: query, required: true }]') + ); + const revision = await nodeTreeOf(menu('[{ name: search, in: query }]')); + const { diffNodeOf } = buildDiffTree(base.root, revision.root, oas3Identities); + const nodes = new Map([...diffNodeOf.values()].map((node) => [node.label, node])); + const first = nodes.get('#/paths/~1menu/get/parameters/{query:search}')!; + const second = nodes.get('#/paths/~1menu/get/parameters/{query:search}#2')!; + + expect(first.base?.key).toBe(0); + expect(first.revision?.key).toBe(0); + expect(second.base?.key).toBe(1); + expect(second.revision).toBeUndefined(); }); }); diff --git a/packages/core/src/diff/__tests__/direction.test.ts b/packages/core/src/diff/__tests__/direction.test.ts index 66f0e6023b..3e34036abd 100644 --- a/packages/core/src/diff/__tests__/direction.test.ts +++ b/packages/core/src/diff/__tests__/direction.test.ts @@ -1,209 +1,187 @@ -import { mergeDirections } from '../direction.js'; import { async3Directions } from '../specs/async3.js'; import { oas3Directions } from '../specs/oas3.js'; -import { directionsOfTree, 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 node = (key: string) => entries.get(key)!; -const resolveWithReferences = (edges: Array<[string, string]>) => - directionsOfTree(entries, edges, oas3Directions); -const unreferenced = resolveWithReferences([]); - -describe('mergeDirections', () => { - it('merges directions', () => { - expect(mergeDirections([], ['request'])).toEqual(['request']); - expect(mergeDirections(['request'], ['request'])).toEqual(['request']); - expect(mergeDirections(['response'], ['request'])).toEqual(['request', 'response']); - }); -}); - -describe('getOas3Direction', () => { - it('reads the direction off the node types on the way down', () => { - expect(unreferenced(node('#/paths/~1p/get/responses/200'))).toEqual(['response']); - expect(unreferenced(node('#/paths/~1p/get/parameters/{query:limit}/schema'))).toEqual([ - 'request', - ]); - expect(unreferenced(node('#/paths/~1p/post/requestBody/content/application~1json'))).toEqual([ - 'request', - ]); - expect(unreferenced(node('#/info/title'))).toEqual([]); - expect(unreferenced(node('#/tags/{pets}'))).toEqual([]); - }); - - it('flips the direction under callbacks and webhooks', () => { - // The API sends these, so their request body reaches the consumer like a response. - expect(unreferenced(node('#/paths/~1p/post/callbacks/onEvent/~1cb/post/requestBody'))).toEqual([ - 'response', - ]); - expect(unreferenced(node('#/webhooks/newPet/post/requestBody'))).toEqual(['response']); - // ...and what the consumer answers with is a request. - expect(unreferenced(node('#/webhooks/newPet/post/responses'))).toEqual(['request']); +import { directionsOfTree, treeOf } from './utils.js'; + +describe('resolveDirections for OpenAPI 3', () => { + it('should read the direction off the place a node sits in', () => { + const cafe = treeOf(` + #/ Root + #/info Info + #/paths Paths + #/paths/~1orders PathItem + #/paths/~1orders/get Operation + #/paths/~1orders/get/parameters ParameterList + #/paths/~1orders/get/parameters/0 Parameter + #/paths/~1orders/get/responses Responses + #/paths/~1orders/get/responses/200 Response + #/paths/~1orders/post Operation + #/paths/~1orders/post/requestBody RequestBody + `); + const statusFilter = cafe.get('#/paths/~1orders/get/parameters/0')!; + const orderList = cafe.get('#/paths/~1orders/get/responses/200')!; + const newOrder = cafe.get('#/paths/~1orders/post/requestBody')!; + const info = cafe.get('#/info')!; + + const directionOf = directionsOfTree(cafe, [], oas3Directions); + + expect(directionOf(statusFilter)).toEqual(['request']); + expect(directionOf(orderList)).toEqual(['response']); + expect(directionOf(newOrder)).toEqual(['request']); + expect(directionOf(info)).toEqual([]); }); - 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( - unreferenced( - node('#/paths/~1p/post/requestBody/content/application~1json/schema/properties/responses') - ) - ).toEqual(['request']); - expect( - unreferenced( - node('#/paths/~1p/get/responses/200/content/application~1json/schema/properties/callbacks') - ) - ).toEqual(['response']); + it('should turn the direction round under a callback or a webhook, which the API sends', () => { + const cafe = treeOf(` + #/ Root + #/paths Paths + #/paths/~1orders PathItem + #/paths/~1orders/post Operation + #/paths/~1orders/post/callbacks CallbacksMap + #/paths/~1orders/post/callbacks/orderReady Callback + #/paths/~1orders/post/callbacks/orderReady/url PathItem + #/paths/~1orders/post/callbacks/orderReady/url/post Operation + #/paths/~1orders/post/callbacks/orderReady/url/post/requestBody RequestBody + #/webhooks WebhooksMap + #/webhooks/menuChanged PathItem + #/webhooks/menuChanged/post Operation + #/webhooks/menuChanged/post/responses Responses + `); + const orderReadySent = cafe.get( + '#/paths/~1orders/post/callbacks/orderReady/url/post/requestBody' + )!; + const menuChangedAnswer = cafe.get('#/webhooks/menuChanged/post/responses')!; + + const directionOf = directionsOfTree(cafe, [], oas3Directions); + + expect(directionOf(orderReadySent)).toEqual(['response']); + expect(directionOf(menuChangedAnswer)).toEqual(['request']); }); - it('derives component direction from usage sites', () => { - const directionOf = resolveWithReferences([ + it('should give a component the directions of the places that reference it, through other components', () => { + const cafe = treeOf(` + #/ Root + #/paths Paths + #/paths/~1orders PathItem + #/paths/~1orders/get Operation + #/paths/~1orders/get/responses Responses + #/paths/~1orders/get/responses/200 Response + #/paths/~1orders/post Operation + #/paths/~1orders/post/requestBody RequestBody + #/paths/~1menu PathItem + #/paths/~1menu/get Operation + #/paths/~1menu/get/responses Responses + #/paths/~1menu/get/responses/200 Response + #/components Components + #/components/schemas NamedSchemas + #/components/schemas/Order Schema + #/components/schemas/Order/items Schema + #/components/schemas/OrderItem Schema + #/components/schemas/OrderItem/order Schema + #/components/schemas/MenuItem Schema + #/components/schemas/Dessert Schema + `); + const order = cafe.get('#/components/schemas/Order')!; + const orderItem = cafe.get('#/components/schemas/OrderItem')!; + const menuItem = cafe.get('#/components/schemas/MenuItem')!; + const dessert = cafe.get('#/components/schemas/Dessert')!; + + const directionOf = directionsOfTree( + cafe, [ - '#/paths/~1p/get/responses/200/content/application~1json/schema', - '#/components/schemas/Pet', + ['#/paths/~1orders/get/responses/200', '#/components/schemas/Order'], + ['#/paths/~1orders/post/requestBody', '#/components/schemas/Order'], + ['#/components/schemas/Order/items', '#/components/schemas/OrderItem'], + ['#/components/schemas/OrderItem/order', '#/components/schemas/Order'], + ['#/paths/~1menu/get/responses/200', '#/components/schemas/MenuItem'], ], - ]); - expect(directionOf(node('#/components/schemas/Pet/properties/name'))).toEqual(['response']); - }); + oas3Directions + ); - it('derives both when a component is used on both sides', () => { - const directionOf = resolveWithReferences([ - [ - '#/paths/~1p/get/responses/200/content/application~1json/schema', - '#/components/schemas/Pet', - ], - ['#/paths/~1p/post/requestBody/content/application~1json/schema', '#/components/schemas/Pet'], - ]); - expect(directionOf(node('#/components/schemas/Pet'))).toEqual(['request', 'response']); + expect(directionOf(order)).toEqual(['request', 'response']); + expect(directionOf(orderItem)).toEqual(['request', 'response']); + expect(directionOf(menuItem)).toEqual(['response']); + expect(directionOf(dessert)).toEqual([]); }); +}); - it('resolves transitive usage through other components, cycle-safe', () => { - const directionOf = resolveWithReferences([ +describe('resolveDirections for AsyncAPI 3', () => { + it('should judge what an operation receives as a request and what it sends as a response', () => { + const kitchen = treeOf(` + #/ Root + #/channels NamedChannels + #/channels/orderPlaced Channel + #/channels/orderReady Channel + #/channels/menuChanged Channel + #/operations NamedOperations + #/operations/onOrderPlaced Operation action=receive + #/operations/onOrderPlaced/channel Channel + #/operations/sendOrderReady Operation action=send + #/operations/sendOrderReady/channel Channel + `); + const orderPlaced = kitchen.get('#/channels/orderPlaced')!; + const orderReady = kitchen.get('#/channels/orderReady')!; + const menuChanged = kitchen.get('#/channels/menuChanged')!; + + const directionOf = directionsOfTree( + kitchen, [ - '#/paths/~1p/get/responses/200/content/application~1json/schema', - '#/components/schemas/Pet', + ['#/operations/onOrderPlaced/channel', '#/channels/orderPlaced'], + ['#/operations/sendOrderReady/channel', '#/channels/orderReady'], ], - ['#/components/schemas/Pet/properties/name', '#/components/schemas/Address'], - // cycle back - ['#/components/schemas/Address', '#/components/schemas/Pet'], - ]); - expect(directionOf(node('#/components/schemas/Address'))).toEqual(['response']); - }); + async3Directions + ); - it('returns neutral for unused components', () => { - expect(unreferenced(node('#/components/schemas/Orphan'))).toEqual([]); - }); -}); - -// 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 asyncNode = (key: string) => asyncEntries.get(key)!; -const asyncDirectionsOf = (edges: Array<[string, string]>) => - directionsOfTree(asyncEntries, edges, async3Directions); - -describe('getAsync3Direction', () => { - const directionOf = asyncDirectionsOf([ - ['#/operations/onSignup', '#/channels/signups'], - ['#/operations/sendReceipt', '#/channels/receipts'], - ['#/operations/onOrder/reply', '#/channels/orders'], - ]); - - 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(directionOf(asyncNode('#/channels/signups/messages/signup/payload'))).toEqual([ - 'request', - ]); - expect(directionOf(asyncNode('#/channels/receipts/messages/receipt'))).toEqual(['response']); - }); - - it('flips the direction for a reply channel', () => { - expect(directionOf(asyncNode('#/channels/orders'))).toEqual(['response']); + expect(directionOf(orderPlaced)).toEqual(['request']); + expect(directionOf(orderReady)).toEqual(['response']); + expect(directionOf(menuChanged)).toEqual([]); }); - it('reads the direction off the operation the change sits in', () => { - expect(directionOf(asyncNode('#/operations/sendReceipt'))).toEqual(['response']); + it('should turn the direction round for the channel a reply travels on', () => { + const kitchen = treeOf(` + #/ Root + #/channels NamedChannels + #/channels/orderConfirmed Channel + #/operations NamedOperations + #/operations/onOrderPlaced Operation action=receive + #/operations/onOrderPlaced/reply OperationReply + #/operations/onOrderPlaced/reply/channel Channel + `); + const orderConfirmed = kitchen.get('#/channels/orderConfirmed')!; + + const directionOf = directionsOfTree( + kitchen, + [['#/operations/onOrderPlaced/reply/channel', '#/channels/orderConfirmed']], + async3Directions + ); + + expect(directionOf(orderConfirmed)).toEqual(['response']); }); - it('returns neutral for a channel no operation references', () => { - expect(asyncDirectionsOf([])(asyncNode('#/channels/signups'))).toEqual([]); - }); - - it('takes the direction of every referenced node it sits in', () => { - const directionOf = asyncDirectionsOf([ - ['#/operations/onSignup', '#/channels/signups'], - ['#/operations/sendReceipt', '#/channels/signups/messages/signup'], - ]); - expect(directionOf(asyncNode('#/channels/signups/messages/signup/payload'))).toEqual([ - 'request', - 'response', - ]); - }); + it('should give a message the directions of both its channel and itself', () => { + const kitchen = treeOf(` + #/ Root + #/channels NamedChannels + #/channels/orders Channel + #/channels/orders/messages NamedMessages + #/channels/orders/messages/orderPlaced Message + #/operations NamedOperations + #/operations/onOrder Operation action=receive + #/operations/onOrder/channel Channel + #/operations/sendOrderPlaced Operation action=send + #/operations/sendOrderPlaced/messages MessageList + #/operations/sendOrderPlaced/messages/0 Message + `); + const orderPlaced = kitchen.get('#/channels/orders/messages/orderPlaced')!; + + const directionOf = directionsOfTree( + kitchen, + [ + ['#/operations/onOrder/channel', '#/channels/orders'], + ['#/operations/sendOrderPlaced/messages/0', '#/channels/orders/messages/orderPlaced'], + ], + async3Directions + ); - it('does not hang on a payload that refers back into its own channel', () => { - const recursive = asyncDirectionsOf([ - ['#/channels/signups/messages/signup/payload', '#/channels/signups'], - ['#/channels/signups', '#/channels/signups/messages/signup/payload'], - ]); - expect(recursive(asyncNode('#/channels/signups/messages/signup'))).toEqual([]); + expect(directionOf(orderPlaced)).toEqual(['request', 'response']); }); }); diff --git a/packages/core/src/diff/__tests__/documented-rules.test.ts b/packages/core/src/diff/__tests__/documented-rules.test.ts deleted file mode 100644 index 21bad435c6..0000000000 --- a/packages/core/src/diff/__tests__/documented-rules.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { diffRuleIds } from '../rules/index.js'; - -const docs = readFileSync( - join(dirname(fileURLToPath(import.meta.url)), '../../../../../docs/@v2/commands/diff.md'), - 'utf8' -); - -/** 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 Set([...docs.matchAll(RULE_TABLE_ROW)].map(([, ruleId]) => ruleId)); - -describe('the documented rule catalog', () => { - it('lists every rule the command runs', () => { - expect(diffRuleIds.filter((ruleId) => !documented.has(ruleId)).sort()).toEqual([]); - }); - - it('lists no rule the command no longer has', () => { - expect([...documented].filter((ruleId) => !diffRuleIds.includes(ruleId)).sort()).toEqual([]); - }); -}); diff --git a/packages/core/src/diff/__tests__/impact.test.ts b/packages/core/src/diff/__tests__/impact.test.ts index 57222e3ab3..a69cfc8324 100644 --- a/packages/core/src/diff/__tests__/impact.test.ts +++ b/packages/core/src/diff/__tests__/impact.test.ts @@ -1,13 +1,19 @@ -import { highestImpact, impactRank } from '../impact.js'; +import { defaultImpact, highestImpact, impactRank } from '../impact.js'; -describe('impact ladder', () => { - it('orders patch below minor below major', () => { +describe('impact', () => { + it('should rank patch below minor below major', () => { expect(impactRank('patch')).toBeLessThan(impactRank('minor')); expect(impactRank('minor')).toBeLessThan(impactRank('major')); }); - it('picks the highest impact and none from an empty list', () => { + it('should pick the highest impact, and none from no impacts', () => { expect(highestImpact(['patch', 'major', 'minor'])).toBe('major'); expect(highestImpact([])).toBeUndefined(); }); + + it('should take an unjudged addition for a minor and anything else for a patch', () => { + expect(defaultImpact('added')).toBe('minor'); + expect(defaultImpact('removed')).toBe('patch'); + expect(defaultImpact('modified')).toBe('patch'); + }); }); diff --git a/packages/core/src/diff/__tests__/index.test.ts b/packages/core/src/diff/__tests__/index.test.ts new file mode 100644 index 0000000000..b1209cd89d --- /dev/null +++ b/packages/core/src/diff/__tests__/index.test.ts @@ -0,0 +1,417 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../__tests__/utils.js'; +import { createConfig } from '../../config/index.js'; +import { makeDocumentFromString } from '../../resolve.js'; +import { HandledError } from '../../utils/error.js'; +import { diffDocuments } from '../index.js'; + +const orderStatus = (paths: string, statuses: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: ${paths} + components: + schemas: + OrderStatus: { type: string, enum: ${statuses} } +`; + +const menu = (parameters: string, description = 'OK') => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + parameters: ${parameters} + responses: + '200': { description: ${description} } +`; + +const menuItem = (template: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu/{${template}}: + get: + parameters: + - { name: ${template}, in: path, required: true, schema: { type: string } } + responses: + '200': { description: OK } +`; + +describe('diffDocuments', () => { + it('should judge a component in the direction a site added in the revision uses it', async () => { + const placeOrder = outdent` + { /orders: { post: { + requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/OrderStatus' } } } }, + responses: { 201: { description: Created } } } } } + `; + + const result = diffDocuments({ + base: makeDocumentFromString(orderStatus('{}', '[placed, preparing, ready]'), 'base.yaml'), + revision: makeDocumentFromString(orderStatus(placeOrder, '[placed, ready]'), 'revision.yaml'), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "minor", + "key": "#/paths/~1orders", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders", + "value": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderStatus", + }, + }, + }, + }, + "responses": { + "201": { + "description": "Created", + }, + }, + }, + }, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/components/schemas/OrderStatus/enum", + "value": [ + "placed", + "preparing", + "ready", + ], + }, + "impact": "major", + "key": "#/components/schemas/OrderStatus", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/components/schemas/OrderStatus/enum", + "value": [ + "placed", + "ready", + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/components/schemas/OrderStatus/enum", + "message": "Enum values removed: preparing.", + "ruleId": "enum-values-removed", + }, + ], + }, + ] + `); + }); + + it('should match reordered parameters by name and judge only what changed', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + menu( + '[{ name: limit, in: query, schema: { type: integer } }, { name: search, in: query }]' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + menu( + '[{ name: search, in: query }, { name: limit, in: query, required: true, schema: { type: number } }]', + 'Menu items' + ), + 'revision.yaml' + ), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/{query:limit}", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/1/required", + "value": true, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/1/required", + "message": "Parameter became required.", + "ruleId": "parameter-became-required", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0/schema/type", + "value": "integer", + }, + "impact": "patch", + "key": "#/paths/~1menu/get/parameters/{query:limit}/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/1/schema/type", + "value": "number", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1menu/get/responses/200/description", + "value": "OK", + }, + "impact": "patch", + "key": "#/paths/~1menu/get/responses/200", + "kind": "modified", + "property": "description", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/responses/200/description", + "value": "Menu items", + }, + "verdicts": [], + }, + ] + `); + expect(result.summary).toEqual({ major: 1, minor: 0, patch: 2 }); + expect(result.bump).toBe('major'); + }); + + it('should report a renamed path parameter as a change of key and name, not a removal', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(menuItem('id'), 'base.yaml'), + revision: makeDocumentFromString(menuItem('menuItemId'), 'revision.yaml'), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu~1{id}", + "value": "/menu/{id}", + }, + "impact": "patch", + "key": "#/paths/~1menu~1{0}", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1menu~1{menuItemId}", + "value": "/menu/{menuItemId}", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1menu~1{id}/get/parameters/0/name", + "value": "id", + }, + "impact": "patch", + "key": "#/paths/~1menu~1{0}/get/parameters/{path:0}", + "kind": "modified", + "property": "name", + "revision": { + "location": "revision.yaml#/paths/~1menu~1{menuItemId}/get/parameters/0/name", + "value": "menuItemId", + }, + "verdicts": [], + }, + ] + `); + expect(result.bump).toBe('patch'); + }); + + it('should number a second path of the same shape, which the specification forbids', async () => { + const cafe = (paths: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: ${paths} + `; + const item = (template: string) => + `'/menu/{${template}}': { get: { responses: { 200: { description: OK } } } }`; + + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${item('menuItemId')} }`), 'base.yaml'), + revision: makeDocumentFromString( + cafe(`{ ${item('menuItemId')}, ${item('itemId')} }`), + 'revision.yaml' + ), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "minor", + "key": "#/paths/~1menu~1{0}#2", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1menu~1{itemId}", + "value": { + "get": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should leave the change of info.version out of the required bump', async () => { + const cafe = (version: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: ${version} } + paths: {} + `; + + const result = diffDocuments({ + base: makeDocumentFromString(cafe('1.0.0'), 'base.yaml'), + revision: makeDocumentFromString(cafe('2.0.0'), 'revision.yaml'), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(result.changes).toHaveLength(1); + expect(result.bump).toBeUndefined(); + }); + + it('should compare a specification without diff rules by its structure alone', async () => { + const swagger = (paths: string) => outdent` + swagger: '2.0' + info: { title: Cafe, version: 1.0.0 } + paths: ${paths} + `; + + const result = diffDocuments({ + base: makeDocumentFromString( + swagger('{ /menu: { get: { responses: { 200: { description: OK } } } } }'), + 'base.yaml' + ), + revision: makeDocumentFromString(swagger('{}'), 'revision.yaml'), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu", + "value": { + "get": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + "impact": "patch", + "key": "#/paths/~1menu", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); + + it('should refuse to compare documents of different specifications', async () => { + const swagger = outdent` + swagger: '2.0' + info: { title: Cafe, version: 1.0.0 } + paths: {} + `; + const config = await createConfig({ extends: ['diff-recommended'] }); + + const compare = () => + diffDocuments({ + base: makeDocumentFromString(swagger, 'base.yaml'), + revision: makeDocumentFromString(menu('[]'), 'revision.yaml'), + config, + }); + + expect(compare).toThrow(HandledError); + }); +}); + +describe('diffDocuments configuration', () => { + const orders = (operations: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders/{orderId}: ${operations} + `; + const get = 'get: { responses: { 200: { description: OK } } }'; + const cancel = 'delete: { responses: { 204: { description: Cancelled } } }'; + const base = makeDocumentFromString(orders(`{ ${get}, ${cancel} }`), 'base.yaml'); + const revision = makeDocumentFromString(orders(`{ ${get} }`), 'revision.yaml'); + + it('should take the impacts of diff-recommended', async () => { + const config = await createConfig({ extends: ['diff-recommended'] }); + + const [cancelRemoved] = diffDocuments({ base, revision, config }).changes; + + expect(cancelRemoved.impact).toBe('major'); + }); + + it('should let the diff map override a preset', async () => { + const config = await createConfig({ + extends: ['diff-recommended'], + diff: { 'operation-removed': 'minor' }, + }); + + const [cancelRemoved] = diffDocuments({ base, revision, config }).changes; + + expect(cancelRemoved.impact).toBe('minor'); + }); + + it('should let a specification block override a preset', async () => { + const config = await createConfig({ + extends: ['diff-recommended'], + oas3_1Diff: { 'operation-removed': 'minor' }, + }); + + const [cancelRemoved] = diffDocuments({ base, revision, config }).changes; + + expect(cancelRemoved.impact).toBe('minor'); + }); + + it('should leave a change unjudged when its rule is off', async () => { + const config = await createConfig({ + extends: ['diff-recommended'], + diff: { 'operation-removed': 'off' }, + }); + + const [cancelRemoved] = diffDocuments({ base, revision, config }).changes; + + expect(cancelRemoved.impact).toBe('patch'); + }); + + it('should run only the listed rules when no preset is extended', async () => { + const config = await createConfig({ diff: { 'path-removed': 'major' } }); + + const [cancelRemoved] = diffDocuments({ base, revision, config }).changes; + + expect(cancelRemoved.impact).toBe('patch'); + }); +}); diff --git a/packages/core/src/diff/__tests__/judge.test.ts b/packages/core/src/diff/__tests__/judge.test.ts index e6ee65a677..44580a64d4 100644 --- a/packages/core/src/diff/__tests__/judge.test.ts +++ b/packages/core/src/diff/__tests__/judge.test.ts @@ -1,308 +1,154 @@ -import { defaultDiffRules } from '../../config/diff-recommended.js'; -import { Location } from '../../ref-utils.js'; -import { Source } from '../../resolve.js'; import { appliesTo, judgeChanges } from '../judge.js'; -import { oas3Rules } from '../rules/index.js'; -import { oas3Directions } from '../specs/oas3.js'; -import type { Change, DiffNode, DiffRule, Impact } from '../types.js'; -import { directionsOfTree, diffNodesOfTree, treeOf } from './tree.js'; +import type { Change, DiffRule } from '../types.js'; +import { diffTreeOf } from './utils.js'; -const source = new Source('api.yaml', ''); -const defaults: Record = defaultDiffRules; -const defaultImpact = (ruleId: string) => defaults[ruleId] ?? 'off'; -const at = (pointer: string) => new Location(source, pointer); - -const loneNode = (type: string, location: Location): DiffNode => { - const key = location.pointer.slice(location.pointer.lastIndexOf('/') + 1); - const node = { type, key, location, value: {}, parent: null, children: [] }; - return { base: node, revision: node, parent: null, children: [], label: '#/' }; -}; - -// None of these lone nodes has a direction. -const unlinked = { directionOf: () => [] }; - -// What every finding passes through: which visitors run, what a report becomes, and how the -// same finding under two directions is kept once. The rules themselves are covered by tests/e2e/diff. describe('judgeChanges', () => { - it('leaves a change no rule reports on patch with no verdicts', () => { - const changes: Change[] = [ - { - key: '#/info', - kind: 'modified', - property: 'title', - node: loneNode('Info', at('#/info')), - base: { location: at('#/info/title'), value: 'a' }, - revision: { location: at('#/info/title'), value: 'b' }, - }, - ]; - - const [change] = judgeChanges({ - changes, - specVersion: 'oas3_1', - ruleSets: [oas3Rules], - impactOf: defaultImpact, - ...unlinked, - }); - - expect(change.impact).toBe('patch'); - expect(change.verdicts).toEqual([]); - }); - - it('stamps the rule id on a report and defaults its location to the display side', () => { - const changes: Change[] = [ - { - key: '#/paths/~1x/delete', - kind: 'removed', - node: loneNode('Operation', at('#/paths/~1x/delete')), - base: { location: at('#/paths/~1x/delete'), value: {} }, - }, - ]; - - const [change] = judgeChanges({ - changes, - specVersion: 'oas3_1', - ruleSets: [oas3Rules], - impactOf: defaultImpact, - ...unlinked, - }); - - expect(change.impact).toBe('major'); - expect(change.verdicts).toEqual([ - { - ruleId: 'operation-removed', - impact: 'major', - message: 'Operation was removed.', - location: at('#/paths/~1x/delete'), - }, - ]); - }); - - it('judges a component used in a request and a response for both directions', () => { - const entries = treeOf(` - #/ Root - #/paths Paths - #/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/Shared Schema + it('should run a nested handler for a member only and an any handler for every change', () => { + const cafe = diffTreeOf(` + #/Order Schema + #/Order/properties SchemaProperties + #/Order/properties/items Schema `); - const directionOf = directionsOfTree( - entries, - [ - ['#/paths/~1x/get/parameters/{query:q}/schema', '#/components/schemas/Shared'], - [ - '#/paths/~1x/get/responses/200/content/application~1json/schema', - '#/components/schemas/Shared', - ], - ], - oas3Directions - ); - const changes: Change[] = [ - { - key: '#/components/schemas/Shared', - kind: 'modified', - property: 'enum', - node: diffNodesOfTree(entries).get(entries.get('#/components/schemas/Shared')!)!, - base: { location: at('#/components/schemas/Shared/enum'), value: ['a', 'b'] }, - revision: { location: at('#/components/schemas/Shared/enum'), value: ['a', 'c'] }, - }, - ]; - const [change] = judgeChanges({ - changes, - specVersion: 'oas3_1', - directionOf, - ruleSets: [oas3Rules], - impactOf: defaultImpact, - }); - - expect(change.verdicts.map((verdict) => verdict.ruleId)).toEqual([ - 'enum-values-removed', - 'enum-values-added', - ]); - }); - - it('defaults an unjudged addition to minor and an unjudged modification to patch', () => { - const changes: Change[] = [ - { - key: '#/tags/{pets}', - kind: 'added', - node: loneNode('Tag', at('#/tags/0')), - revision: { location: at('#/tags/0'), value: {} }, - }, - { - key: '#/info', - kind: 'modified', - property: 'title', - node: loneNode('Info', at('#/info')), - base: { location: at('#/info/title'), value: 'a' }, - revision: { location: at('#/info/title'), value: 'b' }, - }, - ]; - - const [added, modified] = judgeChanges({ - changes, - specVersion: 'oas3_1', - ruleSets: [oas3Rules], - impactOf: defaultImpact, - ...unlinked, - }); - - expect(added.impact).toBe('minor'); - expect(modified.impact).toBe('patch'); - }); + const order = cafe.get('#/Order')!; + const items = cafe.get('#/Order/properties/items')!; - it('runs a nested handler for a member only and an any handler for every change', () => { - const entries = treeOf(` - #/ Root - #/components Components - #/components/schemas NamedSchemas - #/components/schemas/Pet Schema - #/components/schemas/Pet/properties SchemaProperties - #/components/schemas/Pet/properties/name Schema - `); - const nodes = diffNodesOfTree(entries); - const removedAt = (pointer: string): Change => ({ - key: pointer, + const orderRemoved: Change = { + key: order.label, kind: 'removed', - node: nodes.get(entries.get(pointer)!)!, - base: { location: at(pointer), value: {} }, - }); + node: order, + base: { location: order.base!.location, value: {} }, + }; + const itemsRemoved: Change = { + key: items.label, + kind: 'removed', + node: items, + base: { location: items.base!.location, value: {} }, + }; + const rule: DiffRule = () => ({ any(_change, { report }) { - report({ message: 'any' }); + report({ message: 'Something changed.' }); }, SchemaProperties: { Schema(_change, { report }) { - report({ message: 'property' }); + report({ message: 'A property changed.' }); }, }, }); - const judged = judgeChanges({ - changes: [ - removedAt('#/components/schemas/Pet'), - removedAt('#/components/schemas/Pet/properties/name'), - ], + const [orderJudged, itemsJudged] = judgeChanges({ + changes: [orderRemoved, itemsRemoved], specVersion: 'oas3_1', ruleSets: [{ 'my-rule': rule }], impactOf: () => 'major', - ...unlinked, + directionOf: () => [], }); - expect(judged.map((change) => change.verdicts.map((verdict) => verdict.message))).toEqual([ - ['any'], - ['any', 'property'], + expect(orderJudged.verdicts).toMatchObject([{ message: 'Something changed.' }]); + expect(itemsJudged.verdicts).toMatchObject([ + { message: 'Something changed.' }, + { message: 'A property changed.' }, ]); }); - it('rejects the lint hooks and a nested any, which a diff rule has no use for', () => { - const judgeWith = (rule: DiffRule) => () => + it('should reject a lint hook, which a diff rule has no use for', () => { + const withEnter: DiffRule = () => ({ Schema: { enter() {} } }); + + const judge = () => judgeChanges({ changes: [], specVersion: 'oas3_1', - ruleSets: [{ 'my-rule': rule }], + ruleSets: [{ 'my-rule': withEnter }], impactOf: () => 'major', - ...unlinked, + directionOf: () => [], }); - expect(judgeWith(() => ({ Schema: { enter() {} } }))).toThrow( - "Diff rule 'my-rule' uses 'enter'" - ); - expect(judgeWith(() => ({ Schema: { any() {} } }))).toThrow("Diff rule 'my-rule' uses 'any'"); + expect(judge).toThrow("Diff rule 'my-rule' uses 'enter' where diff visitors do not have it."); }); - it('stamps the configured impact on a verdict and skips a rule set to off', () => { - const changes: Change[] = [ - { - key: '#/paths/~1x/delete', - kind: 'removed', - node: loneNode('Operation', at('#/paths/~1x/delete')), - base: { location: at('#/paths/~1x/delete'), value: {} }, - }, - ]; + it('should reject an any handler nested under a type', () => { + const withNestedAny: DiffRule = () => ({ Schema: { any() {} } }); - const [minor] = judgeChanges({ - changes, - specVersion: 'oas3_1', - ruleSets: [oas3Rules], - impactOf: (ruleId: string) => (ruleId === 'operation-removed' ? 'minor' : 'off'), - ...unlinked, - }); - const [off] = judgeChanges({ - changes, - specVersion: 'oas3_1', - ruleSets: [oas3Rules], - impactOf: () => 'off' as const, - ...unlinked, - }); + const judge = () => + judgeChanges({ + changes: [], + specVersion: 'oas3_1', + ruleSets: [{ 'my-rule': withNestedAny }], + impactOf: () => 'major', + directionOf: () => [], + }); - expect(minor.impact).toBe('minor'); - expect(minor.verdicts[0].impact).toBe('minor'); - expect(off.impact).toBe('patch'); - expect(off.verdicts).toEqual([]); + expect(judge).toThrow("Diff rule 'my-rule' uses 'any' where diff visitors do not have it."); }); }); -// The tree the walker was probed with: a nested key must pick the members of a `properties` -// map and nothing that merely sits somewhere below one. -const schemaTree = treeOf(` - #/ Root - #/components Components - #/components/schemas NamedSchemas - #/components/schemas/Pet Schema - #/components/schemas/Pet/properties SchemaProperties - #/components/schemas/Pet/properties/name Schema - #/components/schemas/Pet/properties/owner Schema - #/components/schemas/Pet/properties/owner/oneOf OneOf - #/components/schemas/Pet/properties/owner/oneOf/0 Schema - #/components/schemas/Pet/properties/owner/oneOf/0/properties SchemaProperties - #/components/schemas/Pet/properties/owner/oneOf/0/properties/login Schema - #/components/schemas/Pet/properties/tags Schema - #/components/schemas/Pet/properties/tags/items Schema -`); -const schemaDiffNodes = diffNodesOfTree(schemaTree); -const diffNodeAt = (key: string) => schemaDiffNodes.get(schemaTree.get(key)!)!; - describe('appliesTo', () => { - it('matches the members of a properties map, at any depth, and nothing below a member', () => { - const matched = [...schemaTree.keys()].filter((key) => - appliesTo(['SchemaProperties', 'Schema'], diffNodeAt(key)) - ); + it('should apply a nested path to a member of the map, not to the schema that owns it', () => { + const cafe = diffTreeOf(` + #/Order Schema + #/Order/properties SchemaProperties + #/Order/properties/items Schema + `); + const order = cafe.get('#/Order')!; + const items = cafe.get('#/Order/properties/items')!; - expect(matched).toEqual([ - '#/components/schemas/Pet/properties/name', - '#/components/schemas/Pet/properties/owner', - '#/components/schemas/Pet/properties/owner/oneOf/0/properties/login', - '#/components/schemas/Pet/properties/tags', - ]); + expect(appliesTo(['SchemaProperties', 'Schema'], items)).toBe(true); + expect(appliesTo(['SchemaProperties', 'Schema'], order)).toBe(false); }); - it('matches the alternatives of a oneOf and a flat key matches every node of the type', () => { - expect( - appliesTo( - ['OneOf', 'Schema'], - diffNodeAt('#/components/schemas/Pet/properties/owner/oneOf/0') - ) - ).toBe(true); - expect( - appliesTo( - ['OneOf', 'Schema'], - diffNodeAt('#/components/schemas/Pet/properties/owner/oneOf/0/properties/login') - ) - ).toBe(false); - expect( - appliesTo(['Schema'], diffNodeAt('#/components/schemas/Pet/properties/tags/items')) - ).toBe(true); - expect(appliesTo([], diffNodeAt('#/'))).toBe(true); + it('should not apply a nested path to a schema further down inside a member', () => { + const cafe = diffTreeOf(` + #/Order Schema + #/Order/properties SchemaProperties + #/Order/properties/items Schema + #/Order/properties/items/items Schema + `); + const orderLine = cafe.get('#/Order/properties/items/items')!; + + expect(appliesTo(['SchemaProperties', 'Schema'], orderLine)).toBe(false); + }); + + it('should apply a nested path to a member of a map at any depth', () => { + const cafe = diffTreeOf(` + #/Order Schema + #/Order/properties SchemaProperties + #/Order/properties/payment Schema + #/Order/properties/payment/oneOf OneOf + #/Order/properties/payment/oneOf/0 Schema + #/Order/properties/payment/oneOf/0/properties SchemaProperties + #/Order/properties/payment/oneOf/0/properties/cardNumber Schema + `); + const cardNumber = cafe.get('#/Order/properties/payment/oneOf/0/properties/cardNumber')!; + + expect(appliesTo(['SchemaProperties', 'Schema'], cardNumber)).toBe(true); + }); + + it('should apply OneOf › Schema to an alternative, not to a property of it', () => { + const cafe = diffTreeOf(` + #/Payment Schema + #/Payment/oneOf OneOf + #/Payment/oneOf/0 Schema + #/Payment/oneOf/0/properties SchemaProperties + #/Payment/oneOf/0/properties/cardNumber Schema + `); + const card = cafe.get('#/Payment/oneOf/0')!; + const cardNumber = cafe.get('#/Payment/oneOf/0/properties/cardNumber')!; + + expect(appliesTo(['OneOf', 'Schema'], card)).toBe(true); + expect(appliesTo(['OneOf', 'Schema'], cardNumber)).toBe(false); + }); + + it('should apply a flat path to every node of its type and the empty path to any node', () => { + const cafe = diffTreeOf(` + #/ Root + #/Order Schema + #/Order/items Schema + `); + const root = cafe.get('#/')!; + const orderLine = cafe.get('#/Order/items')!; + + expect(appliesTo(['Schema'], orderLine)).toBe(true); + expect(appliesTo([], root)).toBe(true); }); }); diff --git a/packages/core/src/diff/__tests__/rules.test.ts b/packages/core/src/diff/__tests__/rules.test.ts deleted file mode 100644 index 7f63176f27..0000000000 --- a/packages/core/src/diff/__tests__/rules.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { getTypes } from '../../oas-types.js'; -import { async3Rules, oas3Rules } from '../rules/index.js'; -import type { DiffVisitor } from '../types.js'; - -// A visitor key is a node type name; a typo (`HeaderMap` for `HeadersMap`) would make the -// rule silently never run, which no e2e fixture can tell from "nothing to report". -function visitedTypes(visitor: DiffVisitor): string[] { - return Object.entries(visitor).flatMap(([key, value]) => - typeof value === 'function' - ? key === 'any' || key === 'enter' - ? [] - : [key] - : [key, ...visitedTypes(value)] - ); -} - -describe('diff rule registries', () => { - it.each([ - ['oas3_1', oas3Rules], - ['async3', async3Rules], - ] as const)( - '%s rules visit only node types that exist in that specification', - (specVersion, rules) => { - const typeNames = new Set(Object.keys(getTypes(specVersion))); - - const unknown = Object.values(rules) - .flatMap((rule) => visitedTypes(rule())) - .filter((typeName) => !typeNames.has(typeName)); - - expect(unknown).toEqual([]); - } - ); -}); diff --git a/packages/core/src/diff/__tests__/tree.ts b/packages/core/src/diff/__tests__/tree.ts deleted file mode 100644 index 7469a3247e..0000000000 --- a/packages/core/src/diff/__tests__/tree.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { NodeEntry, Reference } from '../../node-tree/types.js'; -import { Location } from '../../ref-utils.js'; -import { Source } from '../../resolve.js'; -import { resolveDirections } from '../direction.js'; -import type { DiffNode, Directions } from '../types.js'; - -const source = new Source('tree.yaml', ''); - -/** - * Builds nodes from a spelled-out tree for tests that need real node types rather than a - * document. Each line is `key Type`, optionally followed by `name=value` fields; a node's - * parent is the closest preceding line whose key is a prefix of it — which is what - * `buildNodeTree` records when it walks a document. - */ -export function treeOf(nodes: string): Map { - const entries = new Map(); - - for (const line of nodes.trim().split('\n')) { - const [key, type, ...assignments] = line.trim().split(/\s+/); - const parentKey = [...entries.keys()] - .reverse() - .find((candidate) => key.startsWith(`${candidate}/`)); - - const parent = (parentKey ? entries.get(parentKey) : null) ?? null; - const entry: NodeEntry = { - type, - key: key.slice(key.lastIndexOf('/') + 1), - location: new Location(source, key), - value: Object.fromEntries(assignments.map((pair) => pair.split('='))), - parent, - children: [], - }; - parent?.children.push(entry); - entries.set(key, entry); - } - - return entries; -} - -/** The tree compared with itself: every entry is both sides of its node. */ -export function diffNodesOfTree(entries: Map): Map { - const diffNodes = new Map(); - for (const entry of entries.values()) { - const parent = entry.parent ? diffNodes.get(entry.parent)! : null; - const node: DiffNode = { - base: entry, - revision: entry, - parent, - children: [], - label: entry.location.pointer, - }; - parent?.children.push(node); - diffNodes.set(entry, node); - } - return diffNodes; -} - -export function referencesOfTree( - entries: Map, - edges: Array<[from: string, to: string]> -): Reference[] { - return edges.map(([from, to]) => ({ from: entries.get(from)!, to: entries.get(to)! })); -} - -/** The directions of a tree compared with itself, for tests that only vary the references. */ -export function directionsOfTree( - entries: Map, - edges: Array<[from: string, to: string]>, - directions: Directions -) { - return resolveDirections(referencesOfTree(entries, edges), diffNodesOfTree(entries), directions); -} diff --git a/packages/core/src/diff/__tests__/utils.ts b/packages/core/src/diff/__tests__/utils.ts new file mode 100644 index 0000000000..c4107cf848 --- /dev/null +++ b/packages/core/src/diff/__tests__/utils.ts @@ -0,0 +1,72 @@ +import { createConfig } from '../../config/index.js'; +import { detectSpec } from '../../detect-spec.js'; +import { buildNodeTree } from '../../node-tree/build.js'; +import type { NodeEntry } from '../../node-tree/types.js'; +import { getTypes } from '../../oas-types.js'; +import { Location } from '../../ref-utils.js'; +import { makeDocumentFromString, Source } from '../../resolve.js'; +import { normalizeTypes } from '../../types/index.js'; +import { buildDiffTree } from '../diff-tree.js'; +import { resolveDirections } from '../direction.js'; +import type { DiffNode, Directions } from '../types.js'; + +const source = new Source('tree.yaml', ''); + +/** The node tree of a YAML document, typed the way the diff types it. */ +export async function nodeTreeOf(yaml: string, absoluteRef = 'api.yaml') { + const document = makeDocumentFromString(yaml, absoluteRef); + const config = await createConfig({}); + const specVersion = detectSpec(document.parsed); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + return buildNodeTree({ document, types, specVersion }); +} + +/** + * A node tree spelled out line by line as `pointer Type`, optionally followed by `name=value` + * fields, for tests that need node types rather than a document. The first line is the root; + * each other line is a child of the line whose pointer it extends by one segment. + */ +export function treeOf(lines: string): Map { + const entries = new Map(); + + for (const line of lines.trim().split('\n')) { + const [pointer, type, ...fields] = line.trim().split(/\s+/); + const parentPointer = pointer.slice(0, pointer.lastIndexOf('/')); + const parent = entries.get(parentPointer === '#' ? '#/' : parentPointer) ?? null; + const entry: NodeEntry = { + type, + key: pointer.slice(pointer.lastIndexOf('/') + 1), + location: new Location(source, pointer), + value: Object.fromEntries(fields.map((field) => field.split('='))), + parent, + children: [], + }; + parent?.children.push(entry); + entries.set(pointer, entry); + } + + return entries; +} + +/** A spelled-out tree compared with itself, its diff nodes keyed by pointer. */ +export function diffTreeOf(lines: string): Map { + const entries = treeOf(lines); + const [root] = entries.values(); + const { diffNodeOf } = buildDiffTree(root, root, {}); + return new Map([...entries].map(([pointer, entry]) => [pointer, diffNodeOf.get(entry)!])); +} + +/** The directions in a spelled-out tree compared with itself, given the references in it. */ +export function directionsOfTree( + entries: Map, + references: Array<[from: string, to: string]>, + directions: Directions +) { + const [root] = entries.values(); + const { diffNodeOf } = buildDiffTree(root, root, {}); + return resolveDirections( + references.map(([from, to]) => ({ from: entries.get(from)!, to: entries.get(to)! })), + diffNodeOf, + directions + ); +} diff --git a/packages/core/src/diff/rules/__tests__/additional-properties-changed.test.ts b/packages/core/src/diff/rules/__tests__/additional-properties-changed.test.ts new file mode 100644 index 0000000000..4f21dd1cb7 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/additional-properties-changed.test.ts @@ -0,0 +1,138 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (order: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${order} + responses: + '201': + description: Created + content: + application/json: + schema: ${order} +`; + +describe('additional-properties-changed', () => { + it('should report closing an open request object, not an open response object', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: object, additionalProperties: true }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, additionalProperties: false }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'additional-properties-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "value": true, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "additionalProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "value": false, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "message": "\`additionalProperties\` changed from 'true' to 'false'.", + "ruleId": "additional-properties-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "additionalProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", + "value": false, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report opening a closed response object, not a closed request object', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: object, additionalProperties: false }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, additionalProperties: true }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'additional-properties-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "value": false, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "additionalProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "value": true, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", + "value": false, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "additionalProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", + "value": true, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", + "message": "\`additionalProperties\` changed from 'false' to 'true'.", + "ruleId": "additional-properties-changed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts b/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts new file mode 100644 index 0000000000..a2a082e205 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts @@ -0,0 +1,50 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (address: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orderPlaced: { address: ${address} } +`; + +describe('channel-address-changed', () => { + it('should report a channel that moved to another address', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe('orders.placed'), 'base.yaml'), + revision: makeDocumentFromString(cafe('kitchen.orders.placed'), 'revision.yaml'), + config: await createConfig({ diff: { 'channel-address-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orderPlaced/address", + "value": "orders.placed", + }, + "impact": "major", + "key": "#/channels/orderPlaced", + "kind": "modified", + "property": "address", + "revision": { + "location": "revision.yaml#/channels/orderPlaced/address", + "value": "kitchen.orders.placed", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/channels/orderPlaced/address", + "message": "The channel address changed from 'orders.placed' to 'kitchen.orders.placed'.", + "ruleId": "channel-address-changed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/channel-removed.test.ts b/packages/core/src/diff/rules/__tests__/channel-removed.test.ts new file mode 100644 index 0000000000..11d7e75c69 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/channel-removed.test.ts @@ -0,0 +1,84 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (channels?: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + ${channels === undefined ? '' : `channels: ${channels}`} +`; + +const placed = 'orderPlaced: { address: orders.placed }'; +const ready = 'orderReady: { address: orders.ready }'; + +// A channel is where messages travel, so both sides of it break together. +describe('channel-removed', () => { + it('should report a channel that is gone', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${placed}, ${ready} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${placed} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'channel-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orderReady", + "value": { + "address": "orders.ready", + }, + }, + "impact": "major", + "key": "#/channels/orderReady", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/channels/orderReady", + "message": "The channel was removed.", + "ruleId": "channel-removed", + }, + ], + }, + ] + `); + }); + + it('should report every channel leaving with the whole map', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${placed} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(), 'revision.yaml'), + config: await createConfig({ diff: { 'channel-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels", + "value": { + "orderPlaced": { + "address": "orders.placed", + }, + }, + }, + "impact": "major", + "key": "#/channels", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/channels", + "message": "Every channel was removed.", + "ruleId": "channel-removed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts b/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts new file mode 100644 index 0000000000..6bc80fab26 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts @@ -0,0 +1,93 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (status: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${status} + responses: + '201': + description: Created + content: + application/json: + schema: ${status} +`; + +describe('enum-values-added', () => { + it('should report the values a response may now send, not the ones a request now accepts', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe('{ type: string, enum: [placed, ready] }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: string, enum: [placed, preparing, ready] }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'enum-values-added': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum", + "value": [ + "placed", + "ready", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum", + "value": [ + "placed", + "preparing", + "ready", + ], + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum", + "value": [ + "placed", + "ready", + ], + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum", + "value": [ + "placed", + "preparing", + "ready", + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum", + "message": "Enum values added: preparing.", + "ruleId": "enum-values-added", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts b/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts new file mode 100644 index 0000000000..0afb2119ca --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts @@ -0,0 +1,96 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (status: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${status} + responses: + '201': + description: Created + content: + application/json: + schema: ${status} +`; + +describe('enum-values-removed', () => { + it('should report the values a request no longer accepts, not the ones a response stops sending', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: string, enum: [placed, preparing, ready] }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: string, enum: [placed, ready] }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'enum-values-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum", + "value": [ + "placed", + "preparing", + "ready", + ], + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum", + "value": [ + "placed", + "ready", + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum", + "message": "Enum values removed: preparing.", + "ruleId": "enum-values-removed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum", + "value": [ + "placed", + "preparing", + "ready", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum", + "value": [ + "placed", + "ready", + ], + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts b/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts new file mode 100644 index 0000000000..3ce9b084f2 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts @@ -0,0 +1,57 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (content: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + responses: + '200': + description: OK + content: ${content} +`; + +const json = 'application/json: { schema: { type: array } }'; +const csv = 'text/csv: { schema: { type: string } }'; + +describe('media-type-removed', () => { + it('should report a media type that is gone', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${json}, ${csv} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${json} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'media-type-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/responses/200/content/text~1csv", + "value": { + "schema": { + "type": "string", + }, + }, + }, + "impact": "major", + "key": "#/paths/~1menu/get/responses/200/content/text~1csv", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1menu/get/responses/200/content/text~1csv", + "message": "Media type was removed.", + "ruleId": "media-type-removed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts b/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts new file mode 100644 index 0000000000..ffcc3628ca --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts @@ -0,0 +1,53 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (contentType: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: + address: orders + messages: + orderPlaced: { contentType: ${contentType}, payload: { type: object } } +`; + +describe('message-content-type-changed', () => { + it('should report a message encoded another way', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe('application/json'), 'base.yaml'), + revision: makeDocumentFromString(cafe('application/avro'), 'revision.yaml'), + config: await createConfig({ diff: { 'message-content-type-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orders/messages/orderPlaced/contentType", + "value": "application/json", + }, + "impact": "major", + "key": "#/channels/orders/messages/orderPlaced", + "kind": "modified", + "property": "contentType", + "revision": { + "location": "revision.yaml#/channels/orders/messages/orderPlaced/contentType", + "value": "application/avro", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/channels/orders/messages/orderPlaced/contentType", + "message": "The message content type changed from 'application/json' to 'application/avro'.", + "ruleId": "message-content-type-changed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/message-removed.test.ts b/packages/core/src/diff/rules/__tests__/message-removed.test.ts new file mode 100644 index 0000000000..3f79592ba7 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/message-removed.test.ts @@ -0,0 +1,90 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (messages?: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: + address: orders + ${messages === undefined ? '' : `messages: ${messages}`} +`; + +const placed = 'orderPlaced: { payload: { type: object } }'; +const cancelled = 'orderCancelled: { payload: { type: object } }'; + +describe('message-removed', () => { + it('should report a message that is gone from its channel', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${placed}, ${cancelled} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${placed} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'message-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orders/messages/orderCancelled", + "value": { + "payload": { + "type": "object", + }, + }, + }, + "impact": "major", + "key": "#/channels/orders/messages/orderCancelled", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/channels/orders/messages/orderCancelled", + "message": "The message was removed.", + "ruleId": "message-removed", + }, + ], + }, + ] + `); + }); + + it('should report every message of a channel leaving with the whole map', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${placed} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(), 'revision.yaml'), + config: await createConfig({ diff: { 'message-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orders/messages", + "value": { + "orderPlaced": { + "payload": { + "type": "object", + }, + }, + }, + }, + "impact": "major", + "key": "#/channels/orders/messages", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/channels/orders/messages", + "message": "Every message of the channel was removed.", + "ruleId": "message-removed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/numeric-range-changed.test.ts b/packages/core/src/diff/rules/__tests__/numeric-range-changed.test.ts new file mode 100644 index 0000000000..3c4be4bacc --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/numeric-range-changed.test.ts @@ -0,0 +1,126 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (quantity: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${quantity} + responses: + '201': + description: Created + content: + application/json: + schema: ${quantity} +`; + +describe('numeric-range-changed', () => { + it('should report a bound that makes a request accept less, not one that lets a response send less', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe('{ type: integer, maximum: 20 }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ type: integer, maximum: 10 }'), 'revision.yaml'), + config: await createConfig({ diff: { 'numeric-range-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum", + "value": 20, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "maximum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum", + "value": 10, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum", + "message": "\`maximum\` changed from '20' to '10'.", + "ruleId": "numeric-range-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maximum", + "value": 20, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "maximum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maximum", + "value": 10, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a bound that lets a response send more, not one that makes a request accept more', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe('{ type: integer, minimum: 1 }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ type: integer, minimum: 0 }'), 'revision.yaml'), + config: await createConfig({ diff: { 'numeric-range-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", + "value": 1, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", + "value": 0, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", + "value": 1, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", + "value": 0, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", + "message": "\`minimum\` changed from '1' to '0'.", + "ruleId": "numeric-range-changed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts b/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts new file mode 100644 index 0000000000..e6e5ccc578 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts @@ -0,0 +1,54 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (action: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: { address: orders } + operations: + onOrderPlaced: + action: ${action} + channel: { $ref: '#/channels/orders' } +`; + +describe('operation-action-changed', () => { + it('should report an operation that now sends what it received', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe('receive'), 'base.yaml'), + revision: makeDocumentFromString(cafe('send'), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-action-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/operations/onOrderPlaced/action", + "value": "receive", + }, + "impact": "major", + "key": "#/operations/onOrderPlaced", + "kind": "modified", + "property": "action", + "revision": { + "location": "revision.yaml#/operations/onOrderPlaced/action", + "value": "send", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/operations/onOrderPlaced/action", + "message": "The operation action changed from 'receive' to 'send'.", + "ruleId": "operation-action-changed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/operation-removed.test.ts b/packages/core/src/diff/rules/__tests__/operation-removed.test.ts new file mode 100644 index 0000000000..f17997a130 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/operation-removed.test.ts @@ -0,0 +1,54 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (operations: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders/{orderId}: ${operations} +`; + +const get = 'get: { responses: { 200: { description: OK } } }'; +const cancel = 'delete: { responses: { 204: { description: Cancelled } } }'; + +describe('operation-removed', () => { + it('should report an operation that is gone', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${get}, ${cancel} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${get} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders~1{orderId}/delete", + "value": { + "responses": { + "204": { + "description": "Cancelled", + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders~1{0}/delete", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders~1{orderId}/delete", + "message": "Operation was removed.", + "ruleId": "operation-removed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/parameter-added-required.test.ts b/packages/core/src/diff/rules/__tests__/parameter-added-required.test.ts new file mode 100644 index 0000000000..2d963de515 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/parameter-added-required.test.ts @@ -0,0 +1,145 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (parameters: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + parameters: ${parameters} + responses: + '200': { description: OK } + components: + parameters: + Sort: { name: sort, in: query, schema: { type: string } } + Category: { name: category, in: query, required: true, schema: { type: string } } +`; + +const search = '{ name: search, in: query, schema: { type: string } }'; + +describe('parameter-added-required', () => { + it('should report a new required parameter, not a new optional one', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`[${search}]`), 'base.yaml'), + revision: makeDocumentFromString( + cafe( + `[${search}, { name: limit, in: query, schema: { type: integer } }, { name: locale, in: header, required: true, schema: { type: string } }]` + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-added-required': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "minor", + "key": "#/paths/~1menu/get/parameters/{query:limit}", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/1", + "value": { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + }, + }, + }, + "verdicts": [], + }, + { + "impact": "major", + "key": "#/paths/~1menu/get/parameters/{header:locale}", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/2", + "value": { + "in": "header", + "name": "locale", + "required": true, + "schema": { + "type": "string", + }, + }, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/2", + "message": "A new required parameter was added.", + "ruleId": "parameter-added-required", + }, + ], + }, + ] + `); + }); + + it('should read whether a referenced parameter is required from what it points at', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe("[{ $ref: '#/components/parameters/Sort' }]"), 'base.yaml'), + revision: makeDocumentFromString( + cafe( + "[{ $ref: '#/components/parameters/Sort' }, { $ref: '#/components/parameters/Category' }]" + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-added-required': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1menu/get/parameters/{query:category}", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/1", + "value": { + "in": "query", + "name": "category", + "required": true, + "schema": { + "type": "string", + }, + }, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/1", + "message": "A new required parameter was added.", + "ruleId": "parameter-added-required", + }, + ], + }, + ] + `); + }); + + it('should not report referenced parameters listed in another order', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe( + "[{ $ref: '#/components/parameters/Sort' }, { $ref: '#/components/parameters/Category' }]" + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe( + "[{ $ref: '#/components/parameters/Category' }, { $ref: '#/components/parameters/Sort' }]" + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-added-required': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`[]`); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts b/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts new file mode 100644 index 0000000000..c989b407f7 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts @@ -0,0 +1,83 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (parameters: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + parameters: ${parameters} + responses: + '200': { description: OK } + components: + parameters: + Sort: { name: sort, in: query, schema: { type: string } } + Category: { name: category, in: query, required: true, schema: { type: string } } +`; + +describe('parameter-became-required', () => { + it('should report an optional parameter that became required, not one that became optional', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe( + '[{ name: limit, in: query, schema: { type: integer } }, { name: search, in: query, required: true }]' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe( + '[{ name: limit, in: query, required: true, schema: { type: integer } }, { name: search, in: query, required: false }]' + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-became-required': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/{query:limit}", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/0/required", + "value": true, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/0/required", + "message": "Parameter became required.", + "ruleId": "parameter-became-required", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/1/required", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1menu/get/parameters/{query:search}", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/1/required", + "value": false, + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts b/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts new file mode 100644 index 0000000000..01f71bfe35 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts @@ -0,0 +1,156 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (parameters?: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + ${parameters === undefined ? '' : `parameters: ${parameters}`} + responses: + '200': { description: OK } + components: + parameters: + Sort: { name: sort, in: query, schema: { type: string } } + Category: { name: category, in: query, required: true, schema: { type: string } } +`; + +const limit = '{ name: limit, in: query, schema: { type: integer } }'; +const search = '{ name: search, in: query, schema: { type: string } }'; + +describe('parameter-removed', () => { + it('should report a parameter that is gone', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`[${limit}, ${search}]`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`[${search}]`), 'revision.yaml'), + config: await createConfig({ diff: { 'parameter-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0", + "value": { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + }, + }, + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/{query:limit}", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1menu/get/parameters/0", + "message": "Parameter was removed.", + "ruleId": "parameter-removed", + }, + ], + }, + ] + `); + }); + + it('should report the last parameter leaving with the whole list', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`[${limit}]`), 'base.yaml'), + revision: makeDocumentFromString(cafe(), 'revision.yaml'), + config: await createConfig({ diff: { 'parameter-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters", + "value": [ + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + }, + }, + ], + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1menu/get/parameters", + "message": "Every parameter was removed.", + "ruleId": "parameter-removed", + }, + ], + }, + ] + `); + }); + + it('should report a referenced parameter swapped for another', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe("[{ $ref: '#/components/parameters/Sort' }]"), 'base.yaml'), + revision: makeDocumentFromString( + cafe("[{ $ref: '#/components/parameters/Category' }]"), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0", + "value": { + "in": "query", + "name": "sort", + "schema": { + "type": "string", + }, + }, + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/{query:sort}", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1menu/get/parameters/0", + "message": "Parameter was removed.", + "ruleId": "parameter-removed", + }, + ], + }, + { + "impact": "minor", + "key": "#/paths/~1menu/get/parameters/{query:category}", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/0", + "value": { + "in": "query", + "name": "category", + "required": true, + "schema": { + "type": "string", + }, + }, + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts b/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts new file mode 100644 index 0000000000..abcc797d75 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts @@ -0,0 +1,68 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (parameters: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + parameters: ${parameters} + responses: + '200': { description: OK } + components: + parameters: + Sort: { name: sort, in: query, schema: { type: string } } + Category: { name: category, in: query, required: true, schema: { type: string } } +`; + +describe('parameter-serialization-changed', () => { + it('should report a parameter put on the wire another way', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe( + '[{ name: category, in: query, style: form, explode: true, schema: { type: array } }]' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe( + '[{ name: category, in: query, style: form, explode: false, schema: { type: array } }]' + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-serialization-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0/explode", + "value": true, + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/{query:category}", + "kind": "modified", + "property": "explode", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/0/explode", + "value": false, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/0/explode", + "message": "Parameter \`explode\` changed from 'true' to 'false'.", + "ruleId": "parameter-serialization-changed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/path-removed.test.ts b/packages/core/src/diff/rules/__tests__/path-removed.test.ts new file mode 100644 index 0000000000..6fa1ddbd49 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/path-removed.test.ts @@ -0,0 +1,55 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (paths: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: ${paths} +`; + +const menu = '/menu: { get: { responses: { 200: { description: OK } } } }'; +const revenue = '/revenue: { get: { responses: { 200: { description: OK } } } }'; + +describe('path-removed', () => { + it('should report a path that is gone', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${menu}, ${revenue} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${menu} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1revenue", + "value": { + "get": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1revenue", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1revenue", + "message": "Path was removed.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/property-removed-from-response.test.ts b/packages/core/src/diff/rules/__tests__/property-removed-from-response.test.ts new file mode 100644 index 0000000000..f3c9f1808f --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/property-removed-from-response.test.ts @@ -0,0 +1,118 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (order: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${order} + responses: + '201': + description: Created + content: + application/json: + schema: ${order} +`; + +describe('property-removed-from-response', () => { + it('should report a property a response no longer sends, not one a request no longer takes', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe( + '{ type: object, properties: { menuItemId: { type: string }, note: { type: string } } }' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, properties: { menuItemId: { type: string } } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'property-removed-from-response': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "value": { + "type": "string", + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "kind": "removed", + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note", + "value": { + "type": "string", + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note", + "message": "Schema property was removed.", + "ruleId": "property-removed-from-response", + }, + ], + }, + ] + `); + }); + + it('should not report a oneOf alternative, which is not a property', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe('{ oneOf: [{ type: string }, { type: integer }] }'), + 'base.yaml' + ), + revision: makeDocumentFromString(cafe('{ oneOf: [{ type: string }] }'), 'revision.yaml'), + config: await createConfig({ diff: { 'property-removed-from-response': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1", + "value": { + "type": "integer", + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1", + "kind": "removed", + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf/1", + "value": { + "type": "integer", + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf/1", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts b/packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts new file mode 100644 index 0000000000..2efb9b5488 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts @@ -0,0 +1,63 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (itemSchema: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu/{menuItemId}: + get: + parameters: + - { name: menuItemId, in: path, required: true, schema: { type: string } } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/${itemSchema}' } + components: + schemas: + Beverage: { type: object } + Dessert: { type: object } +`; + +describe('ref-target-changed', () => { + it('should report a $ref that points at another schema', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe('Beverage'), 'base.yaml'), + revision: makeDocumentFromString(cafe('Dessert'), 'revision.yaml'), + config: await createConfig({ diff: { 'ref-target-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu~1{menuItemId}/get/responses/200/content/application~1json/schema/$ref", + "value": "#/components/schemas/Beverage", + }, + "impact": "major", + "key": "#/paths/~1menu~1{0}/get/responses/200/content/application~1json/schema", + "kind": "modified", + "property": "$ref", + "revision": { + "location": "revision.yaml#/paths/~1menu~1{menuItemId}/get/responses/200/content/application~1json/schema/$ref", + "value": "#/components/schemas/Dessert", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu~1{menuItemId}/get/responses/200/content/application~1json/schema/$ref", + "message": "The reference target changed from '#/components/schemas/Beverage' to '#/components/schemas/Dessert'. The diff cannot check that the two targets are equivalent.", + "ruleId": "ref-target-changed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts b/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts new file mode 100644 index 0000000000..239b9800c3 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts @@ -0,0 +1,84 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (required: boolean) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + required: ${required} + content: { application/json: { schema: { type: object } } } + responses: + '201': { description: Created } +`; + +describe('request-body-became-required', () => { + it('should report an optional request body that became required', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(false), 'base.yaml'), + revision: makeDocumentFromString(cafe(true), 'revision.yaml'), + config: await createConfig({ diff: { 'request-body-became-required': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/required", + "value": false, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/required", + "value": true, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/required", + "message": "The request body became required.", + "ruleId": "request-body-became-required", + }, + ], + }, + ] + `); + }); + + it('should not report a required request body that became optional', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(true), 'base.yaml'), + revision: makeDocumentFromString(cafe(false), 'revision.yaml'), + config: await createConfig({ diff: { 'request-body-became-required': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/required", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/required", + "value": false, + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts b/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts new file mode 100644 index 0000000000..53adab5eaf --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts @@ -0,0 +1,57 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (operation: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: ${operation} +`; + +const created = "responses: { '201': { description: Created } }"; +const order = 'requestBody: { content: { application/json: { schema: { type: object } } } }'; + +describe('request-body-removed', () => { + it('should report a request body that is gone', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${order}, ${created} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${created} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'request-body-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody", + "value": { + "content": { + "application/json": { + "schema": { + "type": "object", + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/requestBody", + "message": "The request body was removed.", + "ruleId": "request-body-removed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts b/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts new file mode 100644 index 0000000000..593477878c --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts @@ -0,0 +1,89 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (order: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${order} + responses: + '201': + description: Created + content: + application/json: + schema: ${order} +`; + +describe('required-properties-added', () => { + it('should report the properties a request must now send, not the ones a response now promises', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe('{ type: object, required: [menuItemId] }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: object, required: [menuItemId, quantity] }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'required-properties-added': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "value": [ + "menuItemId", + ], + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "value": [ + "menuItemId", + "quantity", + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "message": "Properties became required: quantity.", + "ruleId": "required-properties-added", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required", + "value": [ + "menuItemId", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required", + "value": [ + "menuItemId", + "quantity", + ], + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts b/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts new file mode 100644 index 0000000000..60a124c399 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts @@ -0,0 +1,92 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (order: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${order} + responses: + '201': + description: Created + content: + application/json: + schema: ${order} +`; + +describe('required-properties-removed', () => { + it('should report the properties a response no longer promises, not the ones a request may now omit', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: object, required: [menuItemId, quantity] }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, required: [menuItemId] }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'required-properties-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "value": [ + "menuItemId", + "quantity", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "value": [ + "menuItemId", + ], + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required", + "value": [ + "menuItemId", + "quantity", + ], + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required", + "value": [ + "menuItemId", + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required", + "message": "Properties are no longer required: quantity.", + "ruleId": "required-properties-removed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts b/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts new file mode 100644 index 0000000000..09f308e93b --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts @@ -0,0 +1,100 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (response: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + responses: + '201': ${response} +`; + +const location = 'Location: { schema: { type: string } }'; +const rateLimit = 'X-Rate-Limit: { schema: { type: integer } }'; + +describe('response-header-removed', () => { + it('should report a header that is gone', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe(`{ description: Created, headers: { ${location}, ${rateLimit} } }`), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe(`{ description: Created, headers: { ${location} } }`), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'response-header-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/headers/X-Rate-Limit", + "value": { + "schema": { + "type": "integer", + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/headers/X-Rate-Limit", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/responses/201/headers/X-Rate-Limit", + "message": "A response header was removed.", + "ruleId": "response-header-removed", + }, + ], + }, + ] + `); + }); + + it('should report every header leaving with the whole map', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe(`{ description: Created, headers: { ${location} } }`), + 'base.yaml' + ), + revision: makeDocumentFromString(cafe('{ description: Created }'), 'revision.yaml'), + config: await createConfig({ diff: { 'response-header-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/headers", + "value": { + "Location": { + "schema": { + "type": "string", + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/headers", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/responses/201/headers", + "message": "The response headers were removed.", + "ruleId": "response-header-removed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/response-removed.test.ts b/packages/core/src/diff/rules/__tests__/response-removed.test.ts new file mode 100644 index 0000000000..4b90454557 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/response-removed.test.ts @@ -0,0 +1,54 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (responses: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders/{orderId}: + get: + parameters: + - { name: orderId, in: path, required: true, schema: { type: string } } + responses: ${responses} +`; + +describe('response-removed', () => { + it('should report a response that is gone', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe("{ '200': { description: OK }, '404': { description: Not found } }"), + 'base.yaml' + ), + revision: makeDocumentFromString(cafe("{ '200': { description: OK } }"), 'revision.yaml'), + config: await createConfig({ diff: { 'response-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders~1{orderId}/get/responses/404", + "value": { + "description": "Not found", + }, + }, + "impact": "major", + "key": "#/paths/~1orders~1{0}/get/responses/404", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders~1{orderId}/get/responses/404", + "message": "Response was removed.", + "ruleId": "response-removed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts new file mode 100644 index 0000000000..06da954f8f --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts @@ -0,0 +1,124 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (payment: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${payment} + responses: + '201': + description: Created + content: + application/json: + schema: ${payment} +`; + +describe('schema-combinator-changed', () => { + it('should report a oneOf alternative a request no longer accepts, not one a response stops sending', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe('{ oneOf: [{ const: card }, { const: cash }] }'), + 'base.yaml' + ), + revision: makeDocumentFromString(cafe('{ oneOf: [{ const: card }] }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-combinator-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1", + "value": { + "const": "cash", + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1", + "message": "A \`oneOf\` subschema was removed.", + "ruleId": "schema-combinator-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf/1", + "value": { + "const": "cash", + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf/1", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); + + it('should report an allOf member a request must now satisfy, not one a response now satisfies', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe('{ allOf: [{ required: [amount] }] }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ allOf: [{ required: [amount] }, { required: [currency] }] }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-combinator-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf/1", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf/1", + "value": { + "required": [ + "currency", + ], + }, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf/1", + "message": "A \`allOf\` subschema was added.", + "ruleId": "schema-combinator-changed", + }, + ], + }, + { + "impact": "minor", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/allOf/1", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/allOf/1", + "value": { + "required": [ + "currency", + ], + }, + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/schema-format-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-format-changed.test.ts new file mode 100644 index 0000000000..5806e990c5 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/schema-format-changed.test.ts @@ -0,0 +1,79 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (pickupAt: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${pickupAt} + responses: + '201': + description: Created + content: + application/json: + schema: ${pickupAt} +`; + +describe('schema-format-changed', () => { + it('should report a format added to a request, not to a response', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe('{ type: string }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: string, format: date-time }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-format-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "format", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/format", + "value": "date-time", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/format", + "message": "\`format\` was added with value 'date-time'.", + "ruleId": "schema-format-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "format", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/format", + "value": "date-time", + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts index 468fc7531c..5b4b8abd5c 100644 --- a/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts @@ -1,29 +1,273 @@ -import { acceptedTypes, typesNotIn } from '../schema-type-changed.js'; +import { outdent } from 'outdent'; -const lost = (before: unknown, after: unknown) => - typesNotIn(acceptedTypes(before), acceptedTypes(after)); +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (openapi: string, price: string) => outdent` + openapi: ${openapi} + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${price} + responses: + '201': + description: Created + content: + application/json: + schema: ${price} +`; describe('schema-type-changed', () => { - it('finds the types one side accepts and the other does not', () => { - // integer → number accepts more and loses nothing. - expect(lost('integer', 'number')).toEqual([]); - expect(lost('number', 'integer')).toEqual(['number']); - // string → number is incompatible both ways. - expect(lost('string', 'number')).toEqual(['string']); - expect(lost('number', 'string')).toEqual(['number']); - // Adding a type loses nothing; dropping one loses it. - expect(lost('string', ['string', 'number'])).toEqual([]); - expect(lost(['string', 'number'], 'string')).toEqual(['number']); + it('should report a request that accepts fewer types, not a response that sends fewer', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe('3.1.0', '{ type: number }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('3.1.0', '{ type: integer }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-type-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "value": "number", + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "value": "integer", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "message": "Schema type narrowed from 'number' to 'integer'.", + "ruleId": "schema-type-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "value": "number", + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "value": "integer", + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a response that sends more types, not a request that accepts more', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe('3.1.0', '{ type: integer }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('3.1.0', '{ type: number }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-type-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "value": "integer", + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "value": "number", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "value": "integer", + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "value": "number", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "message": "Schema type widened from 'integer' to 'number'.", + "ruleId": "schema-type-changed", + }, + ], + }, + ] + `); + }); + + it('should report a request that no longer accepts null in OpenAPI 3.0', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe('3.0.3', '{ type: number, nullable: true }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('3.0.3', '{ type: number }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-type-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/nullable", + "value": true, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "nullable", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "message": "Schema type narrowed from 'number | null' to 'number'.", + "ruleId": "schema-type-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/nullable", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "nullable", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + ] + `); }); - it('reads 3.0 `nullable` as the 3.1 null type, so the two spellings match', () => { - const from30 = acceptedTypes('string', true); - const from31 = acceptedTypes(['string', 'null']); + it("should read OpenAPI 3.0 `nullable: true` as the 3.1 `type: [..., 'null']`", async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe('3.0.3', '{ type: number, nullable: true }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('3.1.0', '{ type: [number, "null"] }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-type-changed': 'major' } }), + }); - expect(from30).toEqual(['string', 'null']); - expect(typesNotIn(from30, from31)).toEqual([]); - expect(typesNotIn(from31, from30)).toEqual([]); - // Dropping nullability loses the null type. - expect(typesNotIn(from30, acceptedTypes('string'))).toEqual(['null']); + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/openapi", + "value": "3.0.3", + }, + "impact": "patch", + "key": "#/", + "kind": "modified", + "property": "openapi", + "revision": { + "location": "revision.yaml#/openapi", + "value": "3.1.0", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "value": "number", + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "value": [ + "number", + "null", + ], + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/nullable", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "nullable", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "value": "number", + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "value": [ + "number", + "null", + ], + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/nullable", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "nullable", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + ] + `); }); }); diff --git a/packages/core/src/diff/rules/__tests__/security-requirement-added.test.ts b/packages/core/src/diff/rules/__tests__/security-requirement-added.test.ts new file mode 100644 index 0000000000..a03e1813f4 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/security-requirement-added.test.ts @@ -0,0 +1,129 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (operation: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + get: ${operation} + components: + securitySchemes: + ApiKey: { type: apiKey, in: header, name: X-Api-Key } + OAuth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://api.cafe.example/token + scopes: { orders:read: Read orders, orders:write: Write orders } +`; + +const ok = 'responses: { 200: { description: OK } }'; + +describe('security-requirement-added', () => { + it('should report a security list that appears where there was none', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${ok} }`), 'base.yaml'), + revision: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-requirement-added': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/get/security", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/get/security", + "value": [ + { + "ApiKey": [], + }, + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/get/security", + "message": "The API now requires authentication.", + "ruleId": "security-requirement-added", + }, + ], + }, + ] + `); + }); + + it('should report the first requirement filling a list that asked for none', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${ok}, security: [] }`), 'base.yaml'), + revision: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-requirement-added': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/get/security/{ApiKey}", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/get/security/0", + "value": { + "ApiKey": [], + }, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/get/security/0", + "message": "The API now requires authentication.", + "ruleId": "security-requirement-added", + }, + ], + }, + ] + `); + }); + + it('should not report another way to authenticate', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'), + revision: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ ApiKey: [] }, { OAuth: [orders:read] }] }`), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-requirement-added': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "minor", + "key": "#/paths/~1orders/get/security/{OAuth}", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/get/security/1", + "value": { + "OAuth": [ + "orders:read", + ], + }, + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts b/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts new file mode 100644 index 0000000000..b69611f900 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts @@ -0,0 +1,141 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (apiKey: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: {} + components: + securitySchemes: + ApiKey: ${apiKey} +`; + +describe('security-scheme-changed', () => { + it('should report a field clients authenticate with', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: apiKey, in: header, name: X-Api-Key }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: apiKey, in: header, name: X-Cafe-Key }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/securitySchemes/ApiKey/name", + "value": "X-Api-Key", + }, + "impact": "major", + "key": "#/components/securitySchemes/ApiKey", + "kind": "modified", + "property": "name", + "revision": { + "location": "revision.yaml#/components/securitySchemes/ApiKey/name", + "value": "X-Cafe-Key", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/components/securitySchemes/ApiKey/name", + "message": "Security scheme \`name\` changed from 'X-Api-Key' to 'X-Cafe-Key'.", + "ruleId": "security-scheme-changed", + }, + ], + }, + ] + `); + }); + + it('should report a scheme of another type once, not for every field it drags along', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: apiKey, in: header, name: X-Api-Key }'), + 'base.yaml' + ), + revision: makeDocumentFromString(cafe('{ type: http, scheme: bearer }'), 'revision.yaml'), + config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/securitySchemes/ApiKey/type", + "value": "apiKey", + }, + "impact": "major", + "key": "#/components/securitySchemes/ApiKey", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/components/securitySchemes/ApiKey/type", + "value": "http", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/components/securitySchemes/ApiKey/type", + "message": "Security scheme \`type\` changed from 'apiKey' to 'http'.", + "ruleId": "security-scheme-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/components/securitySchemes/ApiKey/in", + "value": "header", + }, + "impact": "patch", + "key": "#/components/securitySchemes/ApiKey", + "kind": "modified", + "property": "in", + "revision": { + "location": "revision.yaml#/components/securitySchemes/ApiKey", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/components/securitySchemes/ApiKey/name", + "value": "X-Api-Key", + }, + "impact": "patch", + "key": "#/components/securitySchemes/ApiKey", + "kind": "modified", + "property": "name", + "revision": { + "location": "revision.yaml#/components/securitySchemes/ApiKey", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/components/securitySchemes/ApiKey", + "value": undefined, + }, + "impact": "patch", + "key": "#/components/securitySchemes/ApiKey", + "kind": "modified", + "property": "scheme", + "revision": { + "location": "revision.yaml#/components/securitySchemes/ApiKey/scheme", + "value": "bearer", + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/security-scheme-removed.test.ts b/packages/core/src/diff/rules/__tests__/security-scheme-removed.test.ts new file mode 100644 index 0000000000..c0040878a3 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/security-scheme-removed.test.ts @@ -0,0 +1,52 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (schemes: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: {} + components: + securitySchemes: ${schemes} +`; + +const apiKey = 'ApiKey: { type: apiKey, in: header, name: X-Api-Key }'; +const bearer = 'Bearer: { type: http, scheme: bearer }'; + +describe('security-scheme-removed', () => { + it('should report a security scheme that is gone', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${apiKey}, ${bearer} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${apiKey} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'security-scheme-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/securitySchemes/Bearer", + "value": { + "scheme": "bearer", + "type": "http", + }, + }, + "impact": "major", + "key": "#/components/securitySchemes/Bearer", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/components/securitySchemes/Bearer", + "message": "A security scheme was removed.", + "ruleId": "security-scheme-removed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/security-scopes-added.test.ts b/packages/core/src/diff/rules/__tests__/security-scopes-added.test.ts new file mode 100644 index 0000000000..94ceef12c7 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/security-scopes-added.test.ts @@ -0,0 +1,73 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (operation: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + get: ${operation} + components: + securitySchemes: + ApiKey: { type: apiKey, in: header, name: X-Api-Key } + OAuth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://api.cafe.example/token + scopes: { orders:read: Read orders, orders:write: Write orders } +`; + +const ok = 'responses: { 200: { description: OK } }'; + +describe('security-scopes-added', () => { + it('should report the scopes a requirement now asks for', async () => { + const result = diffDocuments({ + base: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ OAuth: [orders:read] }] }`), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ OAuth: [orders:read, orders:write] }] }`), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-scopes-added': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/get/security/0/OAuth", + "value": [ + "orders:read", + ], + }, + "impact": "major", + "key": "#/paths/~1orders/get/security/{OAuth}", + "kind": "modified", + "property": "OAuth", + "revision": { + "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth", + "value": [ + "orders:read", + "orders:write", + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth", + "message": "Scheme \`OAuth\` requires new scopes: orders:write.", + "ruleId": "security-scopes-added", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/server-removed.test.ts b/packages/core/src/diff/rules/__tests__/server-removed.test.ts new file mode 100644 index 0000000000..012c981395 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/server-removed.test.ts @@ -0,0 +1,86 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (servers?: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + ${servers === undefined ? '' : `servers: ${servers}`} + channels: {} +`; + +const production = 'production: { host: broker.cafe.example, protocol: amqp }'; +const sandbox = 'sandbox: { host: sandbox.cafe.example, protocol: amqp }'; + +describe('server-removed', () => { + it('should report a server that is gone', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${production}, ${sandbox} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${production} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers/sandbox", + "value": { + "host": "sandbox.cafe.example", + "protocol": "amqp", + }, + }, + "impact": "major", + "key": "#/servers/sandbox", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/servers/sandbox", + "message": "The server was removed.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + }); + + it('should report every server leaving with the whole map', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${production} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(), 'revision.yaml'), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers", + "value": { + "production": { + "host": "broker.cafe.example", + "protocol": "amqp", + }, + }, + }, + "impact": "major", + "key": "#/servers", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/servers", + "message": "Every server was removed.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/string-length-changed.test.ts b/packages/core/src/diff/rules/__tests__/string-length-changed.test.ts new file mode 100644 index 0000000000..02c3095bea --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/string-length-changed.test.ts @@ -0,0 +1,129 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (note: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${note} + responses: + '201': + description: Created + content: + application/json: + schema: ${note} +`; + +describe('string-length-changed', () => { + it('should report a length limit that makes a request accept less', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe('{ type: string, maxLength: 200 }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ type: string, maxLength: 100 }'), 'revision.yaml'), + config: await createConfig({ diff: { 'string-length-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength", + "value": 200, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "maxLength", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength", + "value": 100, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength", + "message": "\`maxLength\` changed from '200' to '100'.", + "ruleId": "string-length-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxLength", + "value": 200, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "maxLength", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxLength", + "value": 100, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should treat any pattern change as accepting less, since patterns cannot be compared', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe("{ type: string, pattern: '^[a-z]+$' }"), 'base.yaml'), + revision: makeDocumentFromString( + cafe("{ type: string, pattern: '^[a-z ]+$' }"), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'string-length-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern", + "value": "^[a-z]+$", + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "pattern", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern", + "value": "^[a-z ]+$", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern", + "message": "\`pattern\` changed from '^[a-z]+$' to '^[a-z ]+$'.", + "ruleId": "string-length-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/pattern", + "value": "^[a-z]+$", + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "pattern", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/pattern", + "value": "^[a-z ]+$", + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/utils.test.ts b/packages/core/src/diff/rules/__tests__/utils.test.ts deleted file mode 100644 index 6ad9a07ebb..0000000000 --- a/packages/core/src/diff/rules/__tests__/utils.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { acceptsLess } from '../utils.js'; - -describe('acceptsLess', () => { - it('tells a constraint that accepts less from one that accepts more', () => { - // A bound that leaves less room accepts less. - expect(acceptsLess('maxLength', 100, 10)).toBe(true); - expect(acceptsLess('maxLength', 10, 100)).toBe(false); - expect(acceptsLess('minimum', 0, 10)).toBe(true); - expect(acceptsLess('minimum', 10, 0)).toBe(false); - // Presence alone decides when one side has no constraint. - expect(acceptsLess('maxLength', undefined, 10)).toBe(true); - expect(acceptsLess('maxLength', 10, undefined)).toBe(false); - // Equivalence of a pattern or format cannot be computed, so assume the worst. - expect(acceptsLess('pattern', '^a', '^b')).toBe(true); - expect(acceptsLess('format', undefined, 'uuid')).toBe(true); - // Closing an open object accepts less; opening it accepts more. - expect(acceptsLess('additionalProperties', true, false)).toBe(true); - expect(acceptsLess('additionalProperties', false, true)).toBe(false); - }); -}); diff --git a/packages/core/src/diff/rules/schema-type-changed.ts b/packages/core/src/diff/rules/schema-type-changed.ts index 2cead37231..a08b6b2627 100644 --- a/packages/core/src/diff/rules/schema-type-changed.ts +++ b/packages/core/src/diff/rules/schema-type-changed.ts @@ -1,4 +1,5 @@ import { fieldOf } from '../../node-tree/access.js'; +import { dequal } from '../../utils/dequal.js'; import type { DiffRule } from '../types.js'; // `integer` accepts a subset of what `number` does, so it is the one implicit widening among @@ -9,14 +10,14 @@ const WIDER_TYPE: Partial> = { integer: 'number' }; * The types a schema accepts, folding OpenAPI 3.0's `nullable: true` into the 3.1 spelling * (`type: [..., 'null']`) so the two compare as equal. */ -export function acceptedTypes(type: unknown, nullable?: unknown): string[] { +function acceptedTypes(type: unknown, nullable?: unknown): string[] { const declared = Array.isArray(type) ? type : type === undefined ? [] : [type]; const types = declared.filter((value): value is string => typeof value === 'string'); return nullable === true && !types.includes('null') ? [...types, 'null'] : types; } /** The types of `types` that `accepted` does not take. */ -export function typesNotIn(types: string[], accepted: string[]): string[] { +function typesNotIn(types: string[], accepted: string[]): string[] { return types.filter((type) => { const wider = WIDER_TYPE[type]; return !accepted.includes(type) && (wider === undefined || !accepted.includes(wider)); @@ -25,11 +26,15 @@ export function typesNotIn(types: string[], accepted: string[]): string[] { export const SchemaTypeChanged: DiffRule = () => ({ Schema(change, { report, directions }) { - if (change.kind !== 'modified' || 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 = acceptedTypes(change.base.value, fieldOf(change.node.base, 'nullable')); - const after = acceptedTypes(change.revision.value, fieldOf(change.node.revision, 'nullable')); + if (change.kind !== 'modified') return; + const { base, revision } = change.node; + // `nullable: true` is OpenAPI 3.0's spelling of `type: [..., 'null']`, so a change to either + // is judged on what the node accepts as a whole, and once: by `type` when both changed. + const typeChanged = !dequal(fieldOf(base, 'type'), fieldOf(revision, 'type')); + if (change.property !== 'type' && (change.property !== 'nullable' || typeChanged)) return; + + const before = acceptedTypes(fieldOf(base, 'type'), fieldOf(base, 'nullable')); + const after = acceptedTypes(fieldOf(revision, 'type'), fieldOf(revision, 'nullable')); // An absent `type` accepts anything, so there is nothing to narrow or widen. if (!before.length || !after.length) return; const described = `from '${before.join(' | ')}' to '${after.join(' | ')}'`; diff --git a/packages/core/src/node-tree/__tests__/build.test.ts b/packages/core/src/node-tree/__tests__/build.test.ts index a45d6bfac2..dbde6c7293 100644 --- a/packages/core/src/node-tree/__tests__/build.test.ts +++ b/packages/core/src/node-tree/__tests__/build.test.ts @@ -6,149 +6,77 @@ import { getTypes } from '../../oas-types.js'; import { makeDocumentFromString } from '../../resolve.js'; import { normalizeTypes } from '../../types/index.js'; import { buildNodeTree } from '../build.js'; -import type { NodeEntry } from '../types.js'; -async function build(yaml: string) { - const document = makeDocumentFromString(yaml, 'api.yaml'); +async function buildTree(yaml: string) { + const document = makeDocumentFromString(yaml, 'cafe.yaml'); const config = await createConfig({}); const specVersion = detectSpec(document.parsed); const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); return buildNodeTree({ document, types, specVersion }); } -/** Every node under the root by its pointer, in the order the walker reached them. */ -function byPointer(root: NodeEntry): Map { - const nodes = new Map(); - const visit = (node: NodeEntry) => { - nodes.set(node.location.pointer, node); - node.children.forEach(visit); - }; - visit(root); - return nodes; -} - -/** `pointer key Type` per node. */ -function outline(nodes: Map): string { - return [...nodes].map(([pointer, node]) => `${pointer} ${node.key} ${node.type}`).join('\n'); -} - -const PETSTORE = outdent` +const cafe = (menuItemRef: string) => outdent` openapi: 3.1.0 - info: { title: Pets, version: '1.0' } + info: { title: Cafe, version: 1.0.0 } paths: - /pets: + /menu: get: parameters: - - { name: limit, in: query, schema: { type: integer, maximum: 100 } } + - { name: limit, in: query } responses: '200': + description: OK content: application/json: - schema: { $ref: '#/components/schemas/Pet' } + schema: { $ref: '${menuItemRef}' } components: schemas: - Pet: - type: object - required: [id] - properties: - id: { type: string } + Menu Item: { type: object } `; describe('buildNodeTree', () => { - it('builds every object and array node into the tree, with the walker location and key', async () => { - const nodes = byPointer((await build(PETSTORE)).root); - - expect(outline(nodes)).toMatchInlineSnapshot(` - "#/ Root - #/info info Info - #/paths paths Paths - #/paths/~1pets /pets PathItem - #/paths/~1pets/get get Operation - #/paths/~1pets/get/parameters parameters ParameterList - #/paths/~1pets/get/parameters/0 0 Parameter - #/paths/~1pets/get/parameters/0/schema schema Schema - #/paths/~1pets/get/responses responses Responses - #/paths/~1pets/get/responses/200 200 Response - #/paths/~1pets/get/responses/200/content content MediaTypesMap - #/paths/~1pets/get/responses/200/content/application~1json application/json MediaType - #/paths/~1pets/get/responses/200/content/application~1json/schema schema Schema - #/components components Components - #/components/schemas schemas NamedSchemas - #/components/schemas/Pet Pet Schema - #/components/schemas/Pet/properties properties SchemaProperties - #/components/schemas/Pet/properties/id id Schema" - `); - expect(nodes.get('#/info')?.location.source.absoluteRef).toBe('api.yaml'); - expect(nodes.get('#/info')?.parent).toBe(nodes.get('#/')); - expect(nodes.get('#/paths/~1pets/get/parameters/0')?.parent).toBe( - nodes.get('#/paths/~1pets/get/parameters') - ); - expect(nodes.get('#/paths')?.children).toEqual([nodes.get('#/paths/~1pets')]); - }); - - it('keeps the node itself as the value, children included', async () => { - const nodes = byPointer((await build(PETSTORE)).root); + it('should build the document as a tree of typed nodes with their keys, values and locations', async () => { + const { root } = await buildTree(cafe('#/components/schemas/Menu%20Item')); + const [info, paths, components] = root.children; + const [menu] = paths.children; + const [listMenu] = menu.children; + const [parameters] = listMenu.children; + const [limit] = parameters.children; - expect(nodes.get('#/paths/~1pets/get/parameters/0')?.value).toEqual({ - name: 'limit', - in: 'query', - schema: { type: 'integer', maximum: 100 }, + expect(root.type).toBe('Root'); + expect(info.type).toBe('Info'); + expect(components.type).toBe('Components'); + expect(menu).toMatchObject({ type: 'PathItem', key: '/menu', parent: paths }); + expect(parameters).toMatchObject({ type: 'ParameterList', key: 'parameters' }); + expect(limit).toMatchObject({ + type: 'Parameter', + key: 0, + value: { name: 'limit', in: 'query' }, + parent: parameters, }); - expect(nodes.get('#/paths/~1pets/get/parameters')?.value).toHaveLength(1); + expect(limit.location.pointer).toBe('#/paths/~1menu/get/parameters/0'); }); -}); -describe('buildNodeTree references', () => { - it('resolves a $ref to the node it points at, wherever the $ref is written', async () => { - const { references } = await build(outdent` - openapi: 3.1.0 - info: { title: T, version: '1' } - paths: - /p: - get: - parameters: - - $ref: '#/components/parameters/Limit' - responses: - '200': - content: - application/json: - schema: { $ref: '#/components/schemas/Pet' } - components: - parameters: - Limit: { name: limit, in: query } - schemas: - Pet: { type: object } - `); + it('should make a $ref a node of the type its place expects, pointing at its target', async () => { + const { root, references } = await buildTree(cafe('#/components/schemas/Menu%20Item')); + const [, , components] = root.children; + const [schemas] = components.children; + const [menuItem] = schemas.children; + const [reference] = references; - expect(references.map(({ from, to }) => [from.location.pointer, to.location.pointer])).toEqual([ - ['#/paths/~1p/get/parameters/0', '#/components/parameters/Limit'], - [ - '#/paths/~1p/get/responses/200/content/application~1json/schema', - '#/components/schemas/Pet', - ], - ]); - expect(references.map(({ from }) => from.type)).toEqual(['Parameter', 'Schema']); + expect(references).toHaveLength(1); + expect(reference.from).toMatchObject({ + type: 'Schema', + key: 'schema', + value: { $ref: '#/components/schemas/Menu%20Item' }, + target: menuItem, + }); + expect(reference.to).toBe(menuItem); }); - it('resolves a $ref whose pointer is percent-encoded', async () => { - const { references } = await build(outdent` - openapi: 3.1.0 - info: { title: T, version: '1' } - paths: - /p: - get: - responses: - '200': - content: - application/json: - schema: { $ref: '#/components/schemas/Pet%20Name' } - components: - schemas: - Pet Name: { type: object } - `); + it('should leave a $ref that does not resolve without a target', async () => { + const { references } = await buildTree(cafe('#/components/schemas/Dessert')); - expect(references.map(({ to }) => to.location.pointer)).toEqual([ - '#/components/schemas/Pet Name', - ]); + expect(references).toEqual([]); }); }); From 39cb25ba8869d7a6bcc0e7887fff5019ce76b382 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:38:48 +0300 Subject: [PATCH 48/57] refactor(diff): name what changed in rule messages and stylish headings, add the next-version format, and align the report formats with the rest of the CLI --- .../diff/__tests__/check-version.test.ts | 64 +-- .../cli/src/commands/diff/check-version.ts | 91 ++-- packages/cli/src/commands/diff/fail-on.ts | 2 + .../__snapshots__/format.test.ts.snap | 449 ++++++++++++++++++ .../__tests__/__snapshots__/html-report.html | 146 ------ .../diff/format/__tests__/diff-node.ts | 8 - .../format/__tests__/fixtures/cafe/base.yaml | 27 ++ .../__tests__/fixtures/cafe/revision.yaml | 27 ++ .../__tests__/fixtures/kitchen/base.yaml | 9 + .../__tests__/fixtures/kitchen/revision.yaml | 8 + .../diff/format/__tests__/format.test.ts | 289 +++-------- .../diff/format/__tests__/problems.test.ts | 111 ----- .../commands/diff/format/github-actions.ts | 42 ++ packages/cli/src/commands/diff/format/html.ts | 36 +- .../cli/src/commands/diff/format/index.ts | 2 + packages/cli/src/commands/diff/format/json.ts | 96 +--- .../cli/src/commands/diff/format/location.ts | 10 + .../cli/src/commands/diff/format/markdown.ts | 18 +- .../src/commands/diff/format/next-version.ts | 13 + .../cli/src/commands/diff/format/order.ts | 15 - .../cli/src/commands/diff/format/problems.ts | 51 -- .../cli/src/commands/diff/format/stylish.ts | 127 ++--- packages/cli/src/commands/diff/index.ts | 87 ++-- packages/cli/src/index.ts | 2 +- packages/core/src/config/diff-recommended.ts | 2 +- .../core/src/diff/__tests__/index.test.ts | 4 +- packages/core/src/diff/index.ts | 13 +- .../__tests__/channel-address-changed.test.ts | 2 +- .../rules/__tests__/channel-removed.test.ts | 4 +- .../rules/__tests__/enum-values-added.test.ts | 2 +- .../__tests__/enum-values-removed.test.ts | 2 +- .../__tests__/media-type-removed.test.ts | 2 +- .../message-content-type-changed.test.ts | 2 +- .../rules/__tests__/message-removed.test.ts | 4 +- .../operation-action-changed.test.ts | 2 +- .../rules/__tests__/operation-removed.test.ts | 2 +- .../parameter-added-required.test.ts | 4 +- .../parameter-became-required.test.ts | 2 +- .../rules/__tests__/parameter-removed.test.ts | 6 +- .../parameter-serialization-changed.test.ts | 2 +- .../diff/rules/__tests__/path-removed.test.ts | 2 +- ...ponse.test.ts => property-removed.test.ts} | 10 +- .../__tests__/ref-target-changed.test.ts | 2 +- .../request-body-became-required.test.ts | 2 +- .../__tests__/request-body-removed.test.ts | 2 +- .../required-properties-added.test.ts | 2 +- .../required-properties-removed.test.ts | 2 +- .../__tests__/response-header-removed.test.ts | 4 +- .../rules/__tests__/response-removed.test.ts | 2 +- .../schema-combinator-changed.test.ts | 4 +- .../__tests__/schema-type-changed.test.ts | 6 +- .../security-requirement-added.test.ts | 4 +- .../__tests__/security-scheme-changed.test.ts | 4 +- .../__tests__/security-scheme-removed.test.ts | 2 +- .../__tests__/security-scopes-added.test.ts | 2 +- .../rules/__tests__/server-removed.test.ts | 112 ++++- .../src/diff/rules/channel-address-changed.ts | 7 +- .../core/src/diff/rules/channel-removed.ts | 6 +- .../core/src/diff/rules/enum-values-added.ts | 5 +- .../src/diff/rules/enum-values-removed.ts | 5 +- packages/core/src/diff/rules/index.ts | 5 +- .../core/src/diff/rules/media-type-removed.ts | 4 +- .../rules/message-content-type-changed.ts | 7 +- .../core/src/diff/rules/message-removed.ts | 9 +- .../diff/rules/operation-action-changed.ts | 7 +- .../core/src/diff/rules/operation-removed.ts | 17 +- .../diff/rules/parameter-added-required.ts | 5 +- .../diff/rules/parameter-became-required.ts | 3 +- .../core/src/diff/rules/parameter-removed.ts | 5 +- .../rules/parameter-serialization-changed.ts | 7 +- packages/core/src/diff/rules/path-removed.ts | 4 +- ...d-from-response.ts => property-removed.ts} | 7 +- .../core/src/diff/rules/ref-target-changed.ts | 2 +- .../rules/request-body-became-required.ts | 2 +- .../src/diff/rules/request-body-removed.ts | 2 +- .../diff/rules/required-properties-added.ts | 5 +- .../diff/rules/required-properties-removed.ts | 6 +- .../src/diff/rules/response-header-removed.ts | 7 +- .../core/src/diff/rules/response-removed.ts | 4 +- .../diff/rules/schema-combinator-changed.ts | 7 +- .../src/diff/rules/schema-type-changed.ts | 5 +- .../diff/rules/security-requirement-added.ts | 4 +- .../src/diff/rules/security-scheme-changed.ts | 7 +- .../src/diff/rules/security-scheme-removed.ts | 4 +- .../src/diff/rules/security-scopes-added.ts | 6 +- .../core/src/diff/rules/server-removed.ts | 28 +- packages/core/src/diff/rules/utils.ts | 52 +- packages/core/src/diff/types.ts | 4 +- .../snapshot.txt | 6 +- .../diff/async3-channel-removed/snapshot.txt | 12 +- .../snapshot.txt | 6 +- .../diff/async3-message-removed/snapshot.txt | 6 +- .../snapshot.txt | 6 +- .../snapshot.txt | 6 +- .../snapshot.txt | 6 +- .../snapshot.txt | 5 +- .../diff/async3-server-removed/snapshot.txt | 6 +- tests/e2e/diff/cli.test.ts | 7 +- .../diff/oas3-allof-ref-replaced/snapshot.txt | 2 +- .../snapshot.txt | 1 + .../diff/oas3-enum-values-added/snapshot.txt | 2 +- .../oas3-enum-values-removed/snapshot.txt | 2 +- .../diff/oas3-media-type-removed/snapshot.txt | 2 +- .../oas3-nullability-changed/snapshot.txt | 2 +- .../snapshot.txt | 3 +- .../oas3-numeric-range-changed/snapshot.txt | 2 +- .../diff/oas3-operation-removed/snapshot.txt | 4 +- .../snapshot.txt | 1 + .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../oas3-parameter-ref-replaced/snapshot.txt | 4 +- .../snapshot.txt | 1 + .../oas3-parameter-removed-last/snapshot.txt | 2 +- .../diff/oas3-parameter-removed/snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../oas3-path-parameter-renamed/snapshot.txt | 1 + tests/e2e/diff/oas3-path-removed/snapshot.txt | 4 +- .../base.yaml | 2 +- .../revision.yaml | 2 +- .../snapshot.txt | 2 +- .../diff/oas3-ref-target-changed/snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../oas3-request-body-removed/snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../oas3-response-header-removed/snapshot.txt | 2 +- .../diff/oas3-response-removed/snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../oas3-schema-format-changed/snapshot.txt | 2 +- .../snapshot.txt | 1 + .../snapshot.txt | 2 +- .../snapshot.txt | 2 +- .../oas3-security-scheme-changed/snapshot.txt | 2 +- .../oas3-security-scheme-removed/snapshot.txt | 2 +- .../oas3-security-scopes-added/snapshot.txt | 2 +- .../oas3-string-length-changed/snapshot.txt | 2 +- .../snapshot.txt | 6 +- tests/e2e/diff/rules.test.ts | 8 +- 138 files changed, 1332 insertions(+), 1021 deletions(-) create mode 100644 packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap delete mode 100644 packages/cli/src/commands/diff/format/__tests__/__snapshots__/html-report.html delete mode 100644 packages/cli/src/commands/diff/format/__tests__/diff-node.ts create mode 100644 packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/base.yaml create mode 100644 packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/revision.yaml create mode 100644 packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/base.yaml create mode 100644 packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/revision.yaml delete mode 100644 packages/cli/src/commands/diff/format/__tests__/problems.test.ts create mode 100644 packages/cli/src/commands/diff/format/github-actions.ts create mode 100644 packages/cli/src/commands/diff/format/location.ts create mode 100644 packages/cli/src/commands/diff/format/next-version.ts delete mode 100644 packages/cli/src/commands/diff/format/order.ts delete mode 100644 packages/cli/src/commands/diff/format/problems.ts rename packages/core/src/diff/rules/__tests__/{property-removed-from-response.test.ts => property-removed.test.ts} (91%) rename packages/core/src/diff/rules/{property-removed-from-response.ts => property-removed.ts} (56%) rename tests/e2e/diff/{oas3-property-removed-from-response => oas3-property-removed}/base.yaml (90%) rename tests/e2e/diff/{oas3-property-removed-from-response => oas3-property-removed}/revision.yaml (88%) rename tests/e2e/diff/{oas3-property-removed-from-response => oas3-property-removed}/snapshot.txt (78%) diff --git a/packages/cli/src/commands/diff/__tests__/check-version.test.ts b/packages/cli/src/commands/diff/__tests__/check-version.test.ts index 2f55a192b7..8395de9a12 100644 --- a/packages/cli/src/commands/diff/__tests__/check-version.test.ts +++ b/packages/cli/src/commands/diff/__tests__/check-version.test.ts @@ -1,50 +1,52 @@ -import { checkVersion } from '../check-version.js'; +import { checkVersion, nextVersion } from '../check-version.js'; describe('checkVersion', () => { it('passes when the declared bump matches or exceeds the required one', () => { - expect(checkVersion({ base: '1.2.0', revision: '1.3.0', required: 'minor' })).toEqual({ - status: 'passed', - }); - expect(checkVersion({ base: '1.2.0', revision: '2.0.0', required: 'patch' })).toEqual({ - status: 'passed', - }); + expect(checkVersion({ base: '1.2.0', revision: '1.3.0', required: 'minor' })).toBeUndefined(); + expect(checkVersion({ base: '1.2.0', revision: '2.0.0', required: 'patch' })).toBeUndefined(); }); it('fails when the version moved less than the changes require', () => { - expect(checkVersion({ base: '1.2.0', revision: '1.3.0', required: 'major' })).toEqual({ - status: 'failed', - message: '✖ info.version went 1.2.0 → 1.3.0, but these changes require a major bump (2.0.0).', - }); + expect(checkVersion({ base: '1.2.0', revision: '1.3.0', required: 'major' })).toBe( + '❌ info.version went 1.2.0 → 1.3.0, but these changes require a major bump (2.0.0).' + ); }); it('fails when the version did not move although something changed', () => { - expect(checkVersion({ base: '1.2.0', revision: '1.2.0', required: 'patch' })).toEqual({ - status: 'failed', - message: '✖ info.version stayed 1.2.0, but these changes require a patch bump (1.2.1).', - }); + expect(checkVersion({ base: '1.2.0', revision: '1.2.0', required: 'patch' })).toBe( + '❌ info.version stayed 1.2.0, but these changes require a patch bump (1.2.1).' + ); }); it('lets a minor stand in for a major while below 1.0', () => { - expect(checkVersion({ base: '0.3.1', revision: '0.4.0', required: 'major' })).toEqual({ - status: 'passed', - }); - expect(checkVersion({ base: '0.3.1', revision: '0.3.2', required: 'major' })).toEqual({ - status: 'failed', - message: - '✖ info.version went 0.3.1 → 0.3.2, but these changes require a minor bump (0.4.0) while below 1.0.', - }); + expect(checkVersion({ base: '0.3.1', revision: '0.4.0', required: 'major' })).toBeUndefined(); + expect(checkVersion({ base: '0.3.1', revision: '0.3.2', required: 'major' })).toBe( + '❌ info.version went 0.3.1 → 0.3.2, but these changes require a minor bump (0.4.0) while below 1.0.' + ); }); - it('skips with a warning when either version is not semver', () => { - expect(checkVersion({ base: 'v1', revision: '1.0.0', required: 'patch' })).toEqual({ - status: 'skipped', - message: '⚠️ info.version is not a semver string; --check-version was skipped.', - }); + it('fails when either version is not semver', () => { + expect(checkVersion({ base: 'v1', revision: '1.0.0', required: 'patch' })).toBe( + '❌ info.version is not a semver string, so --check-version cannot compare it.' + ); }); it('passes when nothing changed', () => { - expect(checkVersion({ base: 'v1', revision: 'v1', required: undefined })).toEqual({ - status: 'passed', - }); + expect(checkVersion({ base: 'v1', revision: 'v1', required: undefined })).toBeUndefined(); + }); +}); + +describe('nextVersion', () => { + it('should bump the base version as the changes require, a minor for a major below 1.0', () => { + expect(nextVersion('1.4.2', 'major')).toBe('2.0.0'); + expect(nextVersion('0.4.2', 'major')).toBe('0.5.0'); + }); + + it('should keep the base version when nothing requires a bump', () => { + expect(nextVersion('1.4.2', undefined)).toBe('1.4.2'); + }); + + it('should give nothing for a base version that is not semver', () => { + expect(nextVersion('v1', 'patch')).toBeUndefined(); }); }); diff --git a/packages/cli/src/commands/diff/check-version.ts b/packages/cli/src/commands/diff/check-version.ts index c6ba8364d4..447a58ae96 100644 --- a/packages/cli/src/commands/diff/check-version.ts +++ b/packages/cli/src/commands/diff/check-version.ts @@ -1,81 +1,54 @@ -import { impactRank, isPlainObject, type Document, type Impact } from '@redocly/openapi-core'; +import type { Impact } from '@redocly/openapi-core'; import semver from 'semver'; -export type VersionCheck = - | { status: 'passed' } - | { status: 'skipped'; message: string } - | { status: 'failed'; message: string }; - -interface Version { - major: number; - minor: number; - patch: number; -} - // Pre-release and build metadata are accepted and ignored: only the three numbers are compared. -function parseVersion(version: unknown): Version | undefined { +function releaseOf(version: unknown): string | undefined { const parsed = typeof version === 'string' ? semver.parse(version) : null; - return parsed ? { major: parsed.major, minor: parsed.minor, patch: parsed.patch } : undefined; -} -function format({ major, minor, patch }: Version): string { - return `${major}.${minor}.${patch}`; + return parsed ? `${parsed.major}.${parsed.minor}.${parsed.patch}` : undefined; } -/** The first part that grew from `from` to `to`; `undefined` when nothing grew. */ -function bumpBetween(from: Version, to: Version): Impact | undefined { - if (to.major > from.major) return 'major'; - if (to.major === from.major && to.minor > from.minor) return 'minor'; - if (to.major === from.major && to.minor === from.minor && to.patch > from.patch) return 'patch'; - return undefined; +// Semver lets a 0.x line break in a minor release, so below 1.0 a minor stands in for a major. +function bumpFor(base: string, required: Impact): Impact { + return semver.major(base) === 0 && required === 'major' ? 'minor' : required; } -function bump(version: Version, part: Impact): Version { - if (part === 'major') return { major: version.major + 1, minor: 0, patch: 0 }; - if (part === 'minor') return { ...version, minor: version.minor + 1, patch: 0 }; - return { ...version, patch: version.patch + 1 }; +/** + * The version a release of these changes takes: the base version bumped as the changes require, + * or unchanged when they require nothing; `undefined` when the base version is not semver. + */ +export function nextVersion( + baseVersion: unknown, + required: Impact | undefined +): string | undefined { + const base = releaseOf(baseVersion); + if (!base || required === undefined) return base; + // `base` is a valid release, so incrementing it cannot fail. + return semver.inc(base, bumpFor(base, required))!; } -export function getDeclaredVersion(document: Document): unknown { - return isPlainObject(document.parsed) && isPlainObject(document.parsed.info) - ? document.parsed.info.version - : undefined; -} - -// Semver lets a 0.x line break in a minor release, so below 1.0 a declared minor -// satisfies a required major. export function checkVersion(opts: { base: unknown; revision: unknown; required: Impact | undefined; -}): VersionCheck { +}): string | undefined { const { required } = opts; - if (required === undefined) return { status: 'passed' }; + if (required === undefined) return undefined; + + const base = releaseOf(opts.base); + const revision = releaseOf(opts.revision); - const base = parseVersion(opts.base); - const revision = parseVersion(opts.revision); if (!base || !revision) { - return { - status: 'skipped', - message: '⚠️ info.version is not a semver string; --check-version was skipped.', - }; + return '❌ info.version is not a semver string, so --check-version cannot compare it.'; } - const isRelaxed = base.major === 0 && required === 'major'; - const effectiveRequired: Impact = isRelaxed ? 'minor' : required; - const declared = bumpBetween(base, revision); - if (declared && impactRank(declared) >= impactRank(effectiveRequired)) - return { status: 'passed' }; + const bump = bumpFor(base, required); + const next = nextVersion(base, required)!; + + if (semver.gte(revision, next)) return undefined; + + const moved = base === revision ? `stayed ${base}` : `went ${base} → ${revision}`; - const moved = - format(base) === format(revision) - ? `stayed ${format(base)}` - : `went ${format(base)} → ${format(revision)}`; - const next = format(bump(base, effectiveRequired)); - return { - status: 'failed', - message: `✖ info.version ${moved}, but these changes require a ${effectiveRequired} bump (${next})${ - isRelaxed ? ' while below 1.0' : '' - }.`, - }; + const belowOne = bump === required ? '' : ' while below 1.0'; + return `❌ info.version ${moved}, but these changes require a ${bump} bump (${next})${belowOne}.`; } diff --git a/packages/cli/src/commands/diff/fail-on.ts b/packages/cli/src/commands/diff/fail-on.ts index e13d6e1526..b20283769a 100644 --- a/packages/cli/src/commands/diff/fail-on.ts +++ b/packages/cli/src/commands/diff/fail-on.ts @@ -8,9 +8,11 @@ export function getDiffFailure(summary: DiffSummary, failOn: DiffFailOn): string const failing = [...impacts] .reverse() .filter((impact) => impactRank(impact) >= impactRank(failOn) && summary[impact] > 0); + if (!failing.length) return undefined; const total = failing.reduce((sum, impact) => sum + summary[impact], 0); const counted = failing.map((impact) => `${summary[impact]} ${impact}`).join(' and '); + return `❌ Diff failed with ${counted} ${pluralize('change', total)}.`; } diff --git a/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap b/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap new file mode 100644 index 0000000000..d34a9e426d --- /dev/null +++ b/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap @@ -0,0 +1,449 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`htmlDiff > should render a self-contained page 1`] = ` +" + + + +API diff + + + +

API diff: base.yaml → revision.yaml

+

+ 4 major + 1 minor + 1 patch + requires a major bump + OpenAPI 3.1 +

+ +
+ + patch + modified + #/paths/~1menu/get · summary + + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1menu/get/summary",
+    "line": 6,
+    "col": 16,
+    "value": "List the menu"
+  },
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/paths/~1menu/get/summary",
+    "line": 6,
+    "col": 16,
+    "value": "List the whole menu"
+  }
+}
+
+ +
+ + major + modified + #/paths/~1menu/get/parameters/{query:limit} · required + \`limit\` query parameter became required. parameter-became-required + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1menu/get/parameters/0",
+    "line": 8,
+    "col": 11
+  },
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/paths/~1menu/get/parameters/0/required",
+    "line": 8,
+    "col": 47,
+    "value": true
+  }
+}
+
+ +
+ + major + modified + #/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note · pattern + \`pattern\` of \`note\` changed from 'a' to 'a|b'. string-length-changed + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/pattern",
+    "line": 15,
+    "col": 68,
+    "value": "a"
+  },
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/pattern",
+    "line": 15,
+    "col": 68,
+    "value": "a|b"
+  }
+}
+
+ +
+ + major + removed + #/paths/~1orders/delete + Operation \`DELETE /orders\` was removed. operation-removed + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1orders/delete",
+    "line": 18,
+    "col": 7,
+    "value": {
+      "summary": "<script>alert(1)</script>",
+      "responses": {
+        "204": {
+          "description": "Cancelled"
+        }
+      }
+    }
+  }
+}
+
+ +
+ + major + removed + #/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta + Property \`eta\` was removed. property-removed + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta",
+    "line": 26,
+    "col": 42,
+    "value": {
+      "type": "string"
+    }
+  }
+}
+
+ +
+ + minor + added + #/components + + +
{
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/components",
+    "line": 26,
+    "col": 3,
+    "value": {
+      "schemas": {
+        "Dessert": {
+          "type": "object"
+        }
+      }
+    }
+  }
+}
+
+ +" +`; + +exports[`jsonDiff > should render the result with each change located in its file 1`] = ` +"{ + "reportVersion": "1", + "files": { + "base": "base.yaml", + "revision": "revision.yaml" + }, + "specVersions": { + "base": "oas3_1", + "revision": "oas3_1" + }, + "infoVersions": { + "base": "1.0.0", + "revision": "1.0.0" + }, + "summary": { + "major": 4, + "minor": 1, + "patch": 1 + }, + "bump": "major", + "changes": [ + { + "key": "#/paths/~1menu/get", + "typeName": "Operation", + "kind": "modified", + "impact": "patch", + "verdicts": [], + "property": "summary", + "base": { + "file": "base.yaml", + "pointer": "#/paths/~1menu/get/summary", + "line": 6, + "col": 16, + "value": "List the menu" + }, + "revision": { + "file": "revision.yaml", + "pointer": "#/paths/~1menu/get/summary", + "line": 6, + "col": 16, + "value": "List the whole menu" + } + }, + { + "key": "#/paths/~1menu/get/parameters/{query:limit}", + "typeName": "Parameter", + "kind": "modified", + "impact": "major", + "verdicts": [ + { + "ruleId": "parameter-became-required", + "impact": "major", + "message": "\`limit\` query parameter became required.", + "location": { + "file": "revision.yaml", + "pointer": "#/paths/~1menu/get/parameters/0/required", + "line": 8, + "col": 47 + } + } + ], + "property": "required", + "base": { + "file": "base.yaml", + "pointer": "#/paths/~1menu/get/parameters/0", + "line": 8, + "col": 11 + }, + "revision": { + "file": "revision.yaml", + "pointer": "#/paths/~1menu/get/parameters/0/required", + "line": 8, + "col": 47, + "value": true + } + }, + { + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "typeName": "Schema", + "kind": "modified", + "impact": "major", + "verdicts": [ + { + "ruleId": "string-length-changed", + "impact": "major", + "message": "\`pattern\` of \`note\` changed from 'a' to 'a|b'.", + "location": { + "file": "revision.yaml", + "pointer": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/pattern", + "line": 15, + "col": 68 + } + } + ], + "property": "pattern", + "base": { + "file": "base.yaml", + "pointer": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/pattern", + "line": 15, + "col": 68, + "value": "a" + }, + "revision": { + "file": "revision.yaml", + "pointer": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/pattern", + "line": 15, + "col": 68, + "value": "a|b" + } + }, + { + "key": "#/paths/~1orders/delete", + "typeName": "Operation", + "kind": "removed", + "impact": "major", + "verdicts": [ + { + "ruleId": "operation-removed", + "impact": "major", + "message": "Operation \`DELETE /orders\` was removed.", + "location": { + "file": "base.yaml", + "pointer": "#/paths/~1orders/delete", + "line": 18, + "col": 7 + } + } + ], + "base": { + "file": "base.yaml", + "pointer": "#/paths/~1orders/delete", + "line": 18, + "col": 7, + "value": { + "summary": "", + "responses": { + "204": { + "description": "Cancelled" + } + } + } + } + }, + { + "key": "#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta", + "typeName": "Schema", + "kind": "removed", + "impact": "major", + "verdicts": [ + { + "ruleId": "property-removed", + "impact": "major", + "message": "Property \`eta\` was removed.", + "location": { + "file": "base.yaml", + "pointer": "#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta", + "line": 26, + "col": 42 + } + } + ], + "base": { + "file": "base.yaml", + "pointer": "#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta", + "line": 26, + "col": 42, + "value": { + "type": "string" + } + } + }, + { + "key": "#/components", + "typeName": "Components", + "kind": "added", + "impact": "minor", + "verdicts": [], + "revision": { + "file": "revision.yaml", + "pointer": "#/components", + "line": 26, + "col": 3, + "value": { + "schemas": { + "Dessert": { + "type": "object" + } + } + } + } + } + ] +}" +`; + +exports[`markdownDiff > should render one table row per change 1`] = ` +"## API diff: base.yaml → revision.yaml + +**4** major · **1** minor · **1** patch · requires a **major** bump + +| Impact | Change | Pointer | Location | Details | +| --- | --- | --- | --- | --- | +| ⚪ patch | modified | \`#/paths/~1menu/get · summary\` | revision.yaml:6:16 | | +| 🔴 major | modified | \`#/paths/~1menu/get/parameters/{query:limit} · required\` | revision.yaml:8:47 | \\\`limit\\\` query parameter became required. \`parameter-became-required\` | +| 🔴 major | modified | \`#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note · pattern\` | revision.yaml:15:68 | \\\`pattern\\\` of \\\`note\\\` changed from 'a' to 'a\\|b'. \`string-length-changed\` | +| 🔴 major | removed | \`#/paths/~1orders/delete\` | base.yaml:18:7 | Operation \\\`DELETE /orders\\\` was removed. \`operation-removed\` | +| 🔴 major | removed | \`#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta\` | base.yaml:26:42 | Property \\\`eta\\\` was removed. \`property-removed\` | +| 🟢 minor | added | \`#/components\` | revision.yaml:26:3 | |" +`; + +exports[`nextVersionDiff > should bump the base version as far as the changes require 1`] = `"2.0.0"`; + +exports[`printGithubActions > should annotate each verdict of a breaking change as an error and skip the rest 1`] = ` +"::error title=parameter-became-required,file=revision.yaml,line=8,col=47,endLine=8,endColumn=51::\`limit\` query parameter became required. +::error title=string-length-changed,file=revision.yaml,line=15,col=68,endLine=15,endColumn=73::\`pattern\` of \`note\` changed from 'a' to 'a|b'. +::error title=operation-removed,file=base.yaml,line=18,col=7,endLine=19,endColumn=55::Operation \`DELETE /orders\` was removed. +::error title=property-removed,file=base.yaml,line=26,col=42,endLine=26,endColumn=58::Property \`eta\` was removed. +" +`; + +exports[`stylishDiff > should group AsyncAPI changes per channel, operation and server 1`] = ` +"server staging + ✖ major removed + Server \`staging\` was removed. (server-removed) + at base.yaml:5:12 + +channel orderPlaced + ✖ major modified address + Channel \`orderPlaced\` address changed from 'orders/placed' to 'orders/new'. (channel-address-changed) + at revision.yaml:6:27 + +operation sendOrderReady + ✖ major modified action + Operation \`sendOrderReady\` action changed from 'send' to 'receive'. (operation-action-changed) + at revision.yaml:8:29 + +3 major, 0 minor, 0 patch." +`; + +exports[`stylishDiff > should group the changes per endpoint and webhook, worst first, with their verdicts and location 1`] = ` +"GET /menu + ✖ major modified parameters/{query:limit} · required + \`limit\` query parameter became required. (parameter-became-required) + at revision.yaml:8:47 + · patch modified summary + at revision.yaml:6:16 + +POST /orders + ✖ major modified requestBody/content/application/json/schema/properties/note · pattern + \`pattern\` of \`note\` changed from 'a' to 'a|b'. (string-length-changed) + at revision.yaml:15:68 + +DELETE /orders + ✖ major removed + Operation \`DELETE /orders\` was removed. (operation-removed) + at base.yaml:18:7 + +POST orderReady (webhook) + ✖ major removed requestBody/content/application/json/schema/properties/eta + Property \`eta\` was removed. (property-removed) + at base.yaml:26:42 + +components + ✔ minor added + at revision.yaml:26:3 + +4 major, 1 minor, 1 patch." +`; diff --git a/packages/cli/src/commands/diff/format/__tests__/__snapshots__/html-report.html b/packages/cli/src/commands/diff/format/__tests__/__snapshots__/html-report.html deleted file mode 100644 index 430069f665..0000000000 --- a/packages/cli/src/commands/diff/format/__tests__/__snapshots__/html-report.html +++ /dev/null @@ -1,146 +0,0 @@ - - - - -API diff - - - -

API diff

-

- 3 major - 1 minor - 1 patch - requires a major bump - oas3_1 → oas3_1 -

- -
- - minor - added - #/components/schemas/Pet - - -
{
-  "revision": {
-    "file": "revision.yaml",
-    "pointer": "#/components/schemas/Pet",
-    "line": 20,
-    "col": 7,
-    "value": {
-      "type": "object"
-    }
-  }
-}
-
- -
- - major - removed - #/paths/~1pets/delete - Operation was removed. operation-removed - -
{
-  "base": {
-    "file": "base.yaml",
-    "pointer": "#/paths/~1pets/delete",
-    "line": 17,
-    "col": 7,
-    "value": {
-      "summary": "<script>alert(1)</script>"
-    }
-  }
-}
-
- -
- - patch - modified - #/paths/~1pets/get · summary - - -
{
-  "base": {
-    "file": "base.yaml",
-    "pointer": "#/paths/~1pets/get/summary",
-    "line": 5,
-    "col": 16,
-    "value": "List pets"
-  },
-  "revision": {
-    "file": "revision.yaml",
-    "pointer": "#/paths/~1pets/get/summary",
-    "line": 5,
-    "col": 16,
-    "value": "List all pets"
-  }
-}
-
- -
- - major - modified - #/paths/~1pets/get/parameters/{query:limit} · required - Parameter became required. parameter-became-required - -
{
-  "base": {
-    "file": "base.yaml",
-    "pointer": "#/paths/~1pets/get/parameters/0/required",
-    "line": 7,
-    "col": 11
-  },
-  "revision": {
-    "file": "revision.yaml",
-    "pointer": "#/paths/~1pets/get/parameters/0/required",
-    "line": 9,
-    "col": 21,
-    "value": true
-  }
-}
-
- -
- - major - modified - #/paths/~1pets/post/requestBody/content/application~1json/schema · pattern - `pattern` changed from 'a' to 'a|b'. string-length-changed - -
{
-  "base": {
-    "file": "base.yaml",
-    "pointer": "#/paths/~1pets/post/requestBody/content/application~1json/schema/pattern",
-    "line": 15,
-    "col": 24,
-    "value": "a"
-  },
-  "revision": {
-    "file": "revision.yaml",
-    "pointer": "#/paths/~1pets/post/requestBody/content/application~1json/schema/pattern",
-    "line": 16,
-    "col": 24,
-    "value": "a|b"
-  }
-}
-
- - \ No newline at end of file diff --git a/packages/cli/src/commands/diff/format/__tests__/diff-node.ts b/packages/cli/src/commands/diff/format/__tests__/diff-node.ts deleted file mode 100644 index 9ac34bf91a..0000000000 --- a/packages/cli/src/commands/diff/format/__tests__/diff-node.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { DiffNode, Location } from '@redocly/openapi-core'; - -/** A diff node that stands alone, for tests that only need its type and location. */ -export function loneNode(type: string, location: Location): DiffNode { - const key = location.pointer.slice(location.pointer.lastIndexOf('/') + 1); - const node = { type, key, location, value: {}, parent: null, children: [] }; - return { base: node, revision: node, parent: null, children: [], label: '#/' }; -} diff --git a/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/base.yaml b/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/base.yaml new file mode 100644 index 0000000000..b9cbd41248 --- /dev/null +++ b/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/base.yaml @@ -0,0 +1,27 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.0.0 } +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + responses: { '200': { description: OK } } + /orders: + post: + requestBody: + content: + application/json: + schema: { properties: { note: { type: string, pattern: 'a' } } } + responses: { '201': { description: Created } } + delete: + summary: + responses: { '204': { description: Cancelled } } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: { properties: { eta: { type: string } } } + responses: { '200': { description: OK } } diff --git a/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/revision.yaml b/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/revision.yaml new file mode 100644 index 0000000000..25c1690c30 --- /dev/null +++ b/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/revision.yaml @@ -0,0 +1,27 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.0.0 } +paths: + /menu: + get: + summary: List the whole menu + parameters: + - { name: limit, in: query, required: true, schema: { type: integer } } + responses: { '200': { description: OK } } + /orders: + post: + requestBody: + content: + application/json: + schema: { properties: { note: { type: string, pattern: 'a|b' } } } + responses: { '201': { description: Created } } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: { properties: {} } + responses: { '200': { description: OK } } +components: + schemas: + Dessert: { type: object } diff --git a/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/base.yaml b/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/base.yaml new file mode 100644 index 0000000000..83be825107 --- /dev/null +++ b/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/base.yaml @@ -0,0 +1,9 @@ +asyncapi: 3.0.0 +info: { title: Cafe kitchen, version: 1.0.0 } +servers: + production: { host: kitchen.cafe.example, protocol: mqtt } + staging: { host: staging.cafe.example, protocol: mqtt } +channels: + orderPlaced: { address: orders/placed } +operations: + sendOrderReady: { action: send, channel: { $ref: '#/channels/orderPlaced' } } diff --git a/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/revision.yaml b/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/revision.yaml new file mode 100644 index 0000000000..5977a6a47c --- /dev/null +++ b/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/revision.yaml @@ -0,0 +1,8 @@ +asyncapi: 3.0.0 +info: { title: Cafe kitchen, version: 1.0.0 } +servers: + production: { host: kitchen.cafe.example, protocol: mqtt } +channels: + orderPlaced: { address: orders/new } +operations: + sendOrderReady: { action: receive, channel: { $ref: '#/channels/orderPlaced' } } diff --git a/packages/cli/src/commands/diff/format/__tests__/format.test.ts b/packages/cli/src/commands/diff/format/__tests__/format.test.ts index d2e0be0042..13cc7aa360 100644 --- a/packages/cli/src/commands/diff/format/__tests__/format.test.ts +++ b/packages/cli/src/commands/diff/format/__tests__/format.test.ts @@ -1,246 +1,87 @@ -import { Location, Source, type DiffResult } from '@redocly/openapi-core'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { outdent } from 'outdent'; +import { createConfig, diffDocuments, makeDocumentFromString } from '@redocly/openapi-core'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { stripVTControlCharacters } from 'node:util'; +import { printGithubActions } from '../github-actions.js'; import { htmlDiff } from '../html.js'; +import { jsonDiff } from '../json.js'; import { markdownDiff } from '../markdown.js'; +import { nextVersionDiff } from '../next-version.js'; import { stylishDiff } from '../stylish.js'; -import { loneNode } from './diff-node.js'; -// format.test.ts — vitest runs with FORCE_COLOR=1, so the codes are stripped before snapshotting -function stripColors(output: string): string { - // oxlint-disable-next-line no-control-regex - return output.replace(/\x1b\[\d+m/g, ''); -} - -const base = new Source( - 'base.yaml', - outdent` - openapi: 3.1.0 - paths: - /pets: - get: - summary: List pets - parameters: - - name: limit - in: query - schema: { type: integer } - post: - requestBody: - content: - application/json: - schema: - pattern: 'a' - delete: - summary: - ` -); - -const revision = new Source( - 'revision.yaml', - outdent` - openapi: 3.1.0 - paths: - /pets: - get: - summary: List all pets - parameters: - - name: limit - in: query - required: true - schema: { type: integer } - post: - requestBody: - content: - application/json: - schema: - pattern: 'a|b' - components: - schemas: - Pet: - type: object - ` -); - -const at = (source: Source, pointer: string) => new Location(source, pointer); +const documentOf = (fixture: string, side: 'base' | 'revision') => + makeDocumentFromString( + readFileSync(join(import.meta.dirname, 'fixtures', fixture, `${side}.yaml`), 'utf8'), + `${side}.yaml` + ); + +const config = await createConfig({ extends: ['diff-recommended'] }); + +// Every shape a report has to survive: a removal found in the base document, changes found in +// the revision, an addition, a change no rule judges, a webhook — and content that fights the +// output format, so the escaping shows up in the snapshots. +const cafe = diffDocuments({ + base: documentOf('cafe', 'base'), + revision: documentOf('cafe', 'revision'), + config, +}); -/** - * 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, 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: { major: 3, minor: 1, patch: 1 }, - bump: 'major', - changes: [ - { - key: '#/paths/~1pets/delete', - kind: 'removed', - node: loneNode('Operation', at(base, '#/paths/~1pets/delete')), - base: { - location: at(base, '#/paths/~1pets/delete'), - value: { summary: '' }, - }, - impact: 'major', - verdicts: [ - { - ruleId: 'operation-removed', - impact: 'major', - message: 'Operation was removed.', - location: at(base, '#/paths/~1pets/delete'), - }, - ], - }, - { - key: '#/paths/~1pets/get/parameters/{query:limit}', - property: 'required', - kind: 'modified', - node: loneNode('Parameter', at(base, '#/paths/~1pets/get/parameters/{query:limit}')), - base: { - location: at(base, '#/paths/~1pets/get/parameters/0/required'), - value: undefined, - }, - revision: { - location: at(revision, '#/paths/~1pets/get/parameters/0/required'), - value: true, - }, - impact: 'major', - verdicts: [ - { - ruleId: 'parameter-became-required', - impact: 'major', - message: 'Parameter became required.', - location: at(revision, '#/paths/~1pets/get/parameters/0/required'), - }, - ], - }, - { - key: '#/paths/~1pets/post/requestBody/content/application~1json/schema', - property: 'pattern', - kind: 'modified', - node: loneNode( - 'Schema', - at(base, '#/paths/~1pets/post/requestBody/content/application~1json/schema') - ), - base: { - location: at( - base, - '#/paths/~1pets/post/requestBody/content/application~1json/schema/pattern' - ), - value: 'a', - }, - revision: { - location: at( - revision, - '#/paths/~1pets/post/requestBody/content/application~1json/schema/pattern' - ), - value: 'a|b', - }, - impact: 'major', - verdicts: [ - { - ruleId: 'string-length-changed', - impact: 'major', - // 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'.", - location: at( - revision, - '#/paths/~1pets/post/requestBody/content/application~1json/schema/pattern' - ), - }, - ], - }, - { - key: '#/paths/~1pets/get', - property: 'summary', - kind: 'modified', - node: loneNode('Operation', at(base, '#/paths/~1pets/get')), - base: { - location: at(base, '#/paths/~1pets/get/summary'), - value: 'List pets', - }, - revision: { - location: at(revision, '#/paths/~1pets/get/summary'), - value: 'List all pets', - }, - impact: 'patch', - verdicts: [], - }, - { - key: '#/components/schemas/Pet', - kind: 'added', - node: loneNode('Schema', at(base, '#/components/schemas/Pet')), - revision: { - location: at(revision, '#/components/schemas/Pet'), - value: { type: 'object' }, - }, - impact: 'minor', - verdicts: [], - }, - ], -}; +const kitchen = diffDocuments({ + base: documentOf('kitchen', 'base'), + revision: documentOf('kitchen', 'revision'), + config, +}); +// vitest runs with FORCE_COLOR=1, so the stylish report is snapshotted without its colours. describe('stylishDiff', () => { - it('groups changes per operation, worst first, each with its verdicts and location', () => { - expect(stripColors(stylishDiff(RESULT))).toMatchInlineSnapshot(` - "components - ✔ minor added schemas/Pet - at revision.yaml:20:7 - - DELETE /pets - ✖ major removed paths · /pets · delete - Operation was removed. (operation-removed) - at base.yaml:17:7 - - GET /pets - ✖ major modified parameters/{query:limit} · required - Parameter became required. (parameter-became-required) - at revision.yaml:9:21 - · patch modified summary - at revision.yaml:5:16 - - POST /pets - ✖ major modified requestBody/content/application/json/schema · pattern - \`pattern\` changed from 'a' to 'a|b'. (string-length-changed) - at revision.yaml:16:24 + it('should group the changes per endpoint and webhook, worst first, with their verdicts and location', () => { + expect(stripVTControlCharacters(stylishDiff(cafe))).toMatchSnapshot(); + }); - 3 major, 1 minor, 1 patch." - `); + it('should group AsyncAPI changes per channel, operation and server', () => { + expect(stripVTControlCharacters(stylishDiff(kitchen))).toMatchSnapshot(); }); }); describe('markdownDiff', () => { - it('renders one table row per change', () => { - expect(markdownDiff(RESULT)).toMatchInlineSnapshot(` - "## API diff - - **3** major · **1** minor · **1** patch · requires a **major** bump - - | Impact | Change | Location | Details | - | --- | --- | --- | --- | - | 🟢 minor | added | \`#/components/schemas/Pet\` | | - | 🔴 major | removed | \`#/paths/~1pets/delete\` | Operation was removed. \`operation-removed\` | - | ⚪ patch | modified | \`#/paths/~1pets/get · summary\` | | - | 🔴 major | modified | \`#/paths/~1pets/get/parameters/{query:limit} · required\` | Parameter became required. \`parameter-became-required\` | - | 🔴 major | modified | \`#/paths/~1pets/post/requestBody/content/application~1json/schema · pattern\` | \\\`pattern\\\` changed from 'a' to 'a\\|b'. \`string-length-changed\` |" - `); + it('should render one table row per change', () => { + expect(markdownDiff(cafe)).toMatchSnapshot(); }); }); describe('htmlDiff', () => { - it('renders a self-contained page', async () => { - const output = htmlDiff(RESULT); + it('should render a self-contained page', () => { + const output = htmlDiff(cafe); - // 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') - ); + expect(output).toMatchSnapshot(); // The report is opened straight from disk, so it must pull in nothing. expect(output).not.toMatch(/src="http|href="http/); }); }); + +describe('jsonDiff', () => { + it('should render the result with each change located in its file', () => { + expect(jsonDiff(cafe)).toMatchSnapshot(); + }); +}); + +describe('printGithubActions', () => { + it('should annotate each verdict of a breaking change as an error and skip the rest', () => { + let output = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((text: string | Uint8Array) => { + output += text; + return true; + }); + + printGithubActions(cafe); + + expect(output).toMatchSnapshot(); + }); +}); + +describe('nextVersionDiff', () => { + it('should bump the base version as far as the changes require', () => { + expect(nextVersionDiff(cafe)).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/commands/diff/format/__tests__/problems.test.ts b/packages/cli/src/commands/diff/format/__tests__/problems.test.ts deleted file mode 100644 index e6eee850de..0000000000 --- a/packages/cli/src/commands/diff/format/__tests__/problems.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { Location, Source, type DiffResult } from '@redocly/openapi-core'; - -import { diffToProblems } from '../problems.js'; -import { loneNode } from './diff-node.js'; - -const baseSource = new Source('base.yaml', 'openapi: 3.1.0\n'); -const revisionSource = new Source('revision.yaml', 'openapi: 3.1.0\n'); -const at = (source: Source, pointer: string) => new Location(source, pointer); - -const result: DiffResult = { - version: '1', - specVersions: { base: 'oas3_1', revision: 'oas3_1' }, - summary: { major: 1, minor: 1, patch: 1 }, - bump: 'major', - changes: [ - { - key: '#/paths/~1pets/get/parameters/{query:limit}', - property: 'required', - kind: 'modified', - node: loneNode('Parameter', at(baseSource, '#/paths/~1pets/get/parameters/{query:limit}')), - base: { - location: at(baseSource, '#/paths/~1pets/get/parameters/0/required'), - value: undefined, - }, - revision: { - location: at(revisionSource, '#/paths/~1pets/get/parameters/0/required'), - value: true, - }, - impact: 'major', - verdicts: [ - { - ruleId: 'parameter-became-required', - impact: 'major', - message: 'Parameter became required.', - location: at(revisionSource, '#/paths/~1pets/get/parameters/0/required'), - }, - { - ruleId: 'parameter-serialization-changed', - impact: 'major', - message: 'Parameter serialization changed.', - location: at(revisionSource, '#/paths/~1pets/get/parameters/0/style'), - }, - ], - }, - { - key: '#/components/schemas/Pet', - kind: 'added', - node: loneNode('Schema', at(baseSource, '#/components/schemas/Pet')), - revision: { - location: at(revisionSource, '#/components/schemas/Pet'), - value: { type: 'object' }, - }, - impact: 'minor', - verdicts: [], - }, - { - key: '#/info', - property: 'version', - kind: 'modified', - node: loneNode('Info', at(baseSource, '#/info')), - base: { location: at(baseSource, '#/info/version'), value: '1.0.0' }, - revision: { location: at(revisionSource, '#/info/version'), value: '1.0.1' }, - impact: 'patch', - verdicts: [], - }, - ], -}; - -describe('diffToProblems', () => { - it('maps a major impact to error and a minor impact to warn, and drops the patch', () => { - const problems = diffToProblems(result); - - // Both verdicts on the major change map to their own problem, at the verdict's own - // location, with the base side attached as `from`. The unjudged minor addition - // falls back to one problem for the whole change. The patch change carries no - // severity a lint problem can use, so nothing is reported for it. - 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": "revision.yaml#/components/schemas/Pet", - "from": undefined, - "message": "added #/components/schemas/Pet", - "ruleId": "diff", - "severity": "warn", - }, - { - "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", - }, - { - "at": "revision.yaml#/paths/~1pets/get/parameters/0/style", - "from": "base.yaml#/paths/~1pets/get/parameters/0/required", - "message": "Parameter serialization changed.", - "ruleId": "parameter-serialization-changed", - "severity": "error", - }, - ] - `); - }); -}); diff --git a/packages/cli/src/commands/diff/format/github-actions.ts b/packages/cli/src/commands/diff/format/github-actions.ts new file mode 100644 index 0000000000..1eee4a22e1 --- /dev/null +++ b/packages/cli/src/commands/diff/format/github-actions.ts @@ -0,0 +1,42 @@ +import { + displaySide, + formatProblems, + getTotals, + type DiffResult, + type Impact, + type JudgedChange, + type NormalizedProblem, + type ProblemSeverity, +} from '@redocly/openapi-core'; + +// Only a breaking change is annotated, as an error: a GitHub warning would flag a compatible +// change as something to fix. This is what lets the format come from core's formatProblems. +const SEVERITIES: Partial> = { major: 'error' }; + +export function printGithubActions(result: DiffResult): void { + const problems: NormalizedProblem[] = result.changes.flatMap((change) => { + // A change no rule judged is reported on its own, under the command's name. + const verdicts: JudgedChange['verdicts'] = change.verdicts.length + ? change.verdicts + : [ + { + ruleId: 'diff', + impact: change.impact, + message: `${change.kind} ${change.key}`, + location: displaySide(change).location, + }, + ]; + + return verdicts.flatMap(({ ruleId, impact, message, location }) => { + const severity = SEVERITIES[impact]; + if (!severity) return []; + return [{ message, ruleId, severity, location: [location], suggest: [] }]; + }); + }); + + formatProblems(problems, { + format: 'github-actions', + totals: getTotals(problems), + maxProblems: problems.length, + }); +} diff --git a/packages/cli/src/commands/diff/format/html.ts b/packages/cli/src/commands/diff/format/html.ts index dd2781525d..fa581d2648 100644 --- a/packages/cli/src/commands/diff/format/html.ts +++ b/packages/cli/src/commands/diff/format/html.ts @@ -1,7 +1,21 @@ -import type { DiffResult, JudgedChange } from '@redocly/openapi-core'; +import type { DiffResult, JudgedChange, SpecVersion } from '@redocly/openapi-core'; +import { formatPath } from '../../../utils/miscellaneous.js'; import { toJsonChange } from './json.js'; -import { byKeyAndProperty } from './order.js'; + +const SPEC_NAMES: Record = { + oas2: 'OpenAPI 2.0', + oas3_0: 'OpenAPI 3.0', + oas3_1: 'OpenAPI 3.1', + oas3_2: 'OpenAPI 3.2', + async2: 'AsyncAPI 2', + async3: 'AsyncAPI 3', + arazzo1: 'Arazzo 1.0', + arazzo1_1: 'Arazzo 1.1', + overlay1: 'Overlay 1', + openrpc1: 'OpenRPC 1', + graphql: 'GraphQL', +}; function escapeHtml(value: unknown): string { return String(value) @@ -12,15 +26,14 @@ function escapeHtml(value: unknown): string { } function renderChange(change: JudgedChange): string { - const location = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key; + const address = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key; const { base, revision } = toJsonChange(change); - const payload = { ...(base ? { base } : {}), ...(revision ? { revision } : {}) }; return `
${escapeHtml(change.impact)} ${escapeHtml(change.kind)} - ${escapeHtml(location)} + ${escapeHtml(address)} ${change.verdicts .map( (verdict) => @@ -30,12 +43,15 @@ function renderChange(change: JudgedChange): string { ) .join(' ')} -
${escapeHtml(JSON.stringify(payload, null, 2))}
+
${escapeHtml(JSON.stringify({ base, revision }, null, 2))}
`; } export function htmlDiff(result: DiffResult): string { const { major, minor, patch } = result.summary; + const { base, revision } = result.specVersions; + const specs = + base === revision ? SPEC_NAMES[base] : `${SPEC_NAMES[base]} → ${SPEC_NAMES[revision]}`; const bumpSpan = result.bump ? `requires a ${escapeHtml(result.bump)} bump` : ''; @@ -61,17 +77,15 @@ export function htmlDiff(result: DiffResult): string { -

API diff

+

API diff: ${escapeHtml(formatPath(result.files.base))} → ${escapeHtml(formatPath(result.files.revision))}

${major} major ${minor} minor ${patch} patch ${bumpSpan} - ${escapeHtml(result.specVersions.base)} → ${escapeHtml( - result.specVersions.revision - )} + ${escapeHtml(specs)}

-${result.changes.toSorted(byKeyAndProperty).map(renderChange).join('\n')} +${result.changes.map(renderChange).join('\n')} `; } diff --git a/packages/cli/src/commands/diff/format/index.ts b/packages/cli/src/commands/diff/format/index.ts index c88ed2e58f..155f94c65f 100644 --- a/packages/cli/src/commands/diff/format/index.ts +++ b/packages/cli/src/commands/diff/format/index.ts @@ -3,6 +3,7 @@ import type { DiffResult } from '@redocly/openapi-core'; import { htmlDiff } from './html.js'; import { jsonDiff } from './json.js'; import { markdownDiff } from './markdown.js'; +import { nextVersionDiff } from './next-version.js'; import { stylishDiff } from './stylish.js'; export const diffReportFormats = { @@ -10,6 +11,7 @@ export const diffReportFormats = { json: jsonDiff, markdown: markdownDiff, html: htmlDiff, + 'next-version': nextVersionDiff, } satisfies Record string>; export type DiffReportFormat = keyof typeof diffReportFormats; diff --git a/packages/cli/src/commands/diff/format/json.ts b/packages/cli/src/commands/diff/format/json.ts index 3bc6effc70..2114b433b0 100644 --- a/packages/cli/src/commands/diff/format/json.ts +++ b/packages/cli/src/commands/diff/format/json.ts @@ -1,96 +1,46 @@ import { - getLineColLocation, typeOf, - type Change, type DiffResult, - type Impact, type JudgedChange, type Location, type LocatedNode, } from '@redocly/openapi-core'; -import { byKeyAndProperty } from './order.js'; +import { formatPath } from '../../../utils/miscellaneous.js'; +import { lineColOf } from './location.js'; -export interface JsonLocatedNode { - file: string; - pointer: string; - line: number; - col: number; - value: unknown; +function toJsonLocation(location: Location) { + const { file, line, col } = lineColOf(location); + return { file, pointer: location.pointer, line, col }; } -export interface JsonVerdict { - ruleId: string; - impact: Impact; - message: string; - location: Omit; -} - -/** The wire shape: optional sides are the JSON reader's contract, the union stays internal. */ -export interface JsonChange { - key: string; - typeName: string; - kind: Change['kind']; - property?: string; - impact: Impact; - verdicts: JsonVerdict[]; - base?: JsonLocatedNode; - revision?: JsonLocatedNode; -} - -export interface JsonDiffResult extends Omit { - changes: JsonChange[]; -} - -// Nodes inlined by bundling do not exist in the root source AST; -// getLineColLocation falls back to 1:1 for such pointers. -function toJsonLocation(location: Location): Omit { - const { start } = getLineColLocation(location); - return { - file: location.source.absoluteRef, - pointer: location.pointer, - line: start.line, - col: start.col, - }; -} - -function toJsonNode({ location, value }: LocatedNode): JsonLocatedNode { +function toJsonNode({ location, value }: LocatedNode) { return { ...toJsonLocation(location), value }; } -export function toJsonChange(change: JudgedChange): JsonChange { - const { key, node, kind, impact, verdicts } = change; - const common = { - key, - typeName: typeOf(node), - kind, - impact, - verdicts: verdicts.map((verdict) => ({ - ruleId: verdict.ruleId, - impact: verdict.impact, - message: verdict.message, - location: toJsonLocation(verdict.location), +// Fields a change does not have stay undefined, and JSON.stringify leaves them out. +export function toJsonChange(change: JudgedChange) { + return { + key: change.key, + typeName: typeOf(change.node), + kind: change.kind, + impact: change.impact, + verdicts: change.verdicts.map(({ location, ...verdict }) => ({ + ...verdict, + location: toJsonLocation(location), })), + property: change.kind === 'modified' ? change.property : undefined, + base: 'base' in change ? toJsonNode(change.base) : undefined, + revision: 'revision' in change ? toJsonNode(change.revision) : undefined, }; - switch (change.kind) { - case 'added': - return { ...common, revision: toJsonNode(change.revision) }; - case 'removed': - return { ...common, base: toJsonNode(change.base) }; - case 'modified': - return { - ...common, - property: change.property, - base: toJsonNode(change.base), - revision: toJsonNode(change.revision), - }; - } } export function jsonDiff(result: DiffResult): string { - const report: JsonDiffResult = { + const report = { + reportVersion: '1', ...result, - changes: result.changes.toSorted(byKeyAndProperty).map(toJsonChange), + files: { base: formatPath(result.files.base), revision: formatPath(result.files.revision) }, + changes: result.changes.map(toJsonChange), }; return JSON.stringify(report, null, 2); } diff --git a/packages/cli/src/commands/diff/format/location.ts b/packages/cli/src/commands/diff/format/location.ts new file mode 100644 index 0000000000..b4cae27f1a --- /dev/null +++ b/packages/cli/src/commands/diff/format/location.ts @@ -0,0 +1,10 @@ +import { getLineColLocation, type Location } from '@redocly/openapi-core'; + +import { formatPath } from '../../../utils/miscellaneous.js'; + +// Nodes inlined by bundling do not exist in the root source AST; +// getLineColLocation falls back to 1:1 for such pointers. +export function lineColOf(location: Location): { file: string; line: number; col: number } { + const { start } = getLineColLocation(location); + return { file: formatPath(location.source.absoluteRef), line: start.line, col: start.col }; +} diff --git a/packages/cli/src/commands/diff/format/markdown.ts b/packages/cli/src/commands/diff/format/markdown.ts index f5af6a5b77..3309e26e48 100644 --- a/packages/cli/src/commands/diff/format/markdown.ts +++ b/packages/cli/src/commands/diff/format/markdown.ts @@ -1,6 +1,7 @@ -import type { DiffResult, Impact } from '@redocly/openapi-core'; +import { displaySide, type DiffResult, type Impact } from '@redocly/openapi-core'; -import { byKeyAndProperty } from './order.js'; +import { formatPath } from '../../../utils/miscellaneous.js'; +import { lineColOf } from './location.js'; const IMPACT_LABELS: Record = { major: '🔴 major', @@ -24,23 +25,24 @@ export function markdownDiff(result: DiffResult): string { const { major, minor, patch } = result.summary; const required = result.bump ? ` · requires a **${result.bump}** bump` : ''; const lines = [ - '## API diff', + `## API diff: ${formatPath(result.files.base)} → ${formatPath(result.files.revision)}`, '', `**${major}** major · **${minor}** minor · **${patch}** patch${required}`, '', - '| Impact | Change | Location | Details |', - '| --- | --- | --- | --- |', + '| Impact | Change | Pointer | Location | Details |', + '| --- | --- | --- | --- | --- |', ]; - for (const change of result.changes.toSorted(byKeyAndProperty)) { - const location = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key; + for (const change of result.changes) { + const address = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key; + const { file, line, col } = lineColOf(displaySide(change).location); // Only the message comes from the compared document and needs escaping. A rule id is // lowercase letters and hyphens, and the backticks around it are ours to keep. const details = change.verdicts .map((verdict) => `${escapeCell(verdict.message)} \`${verdict.ruleId}\``) .join('
'); lines.push( - `| ${IMPACT_LABELS[change.impact]} | ${change.kind} | \`${escapeCell(location)}\` | ${details} |` + `| ${IMPACT_LABELS[change.impact]} | ${change.kind} | \`${escapeCell(address)}\` | ${escapeCell(`${file}:${line}:${col}`)} | ${details} |` ); } diff --git a/packages/cli/src/commands/diff/format/next-version.ts b/packages/cli/src/commands/diff/format/next-version.ts new file mode 100644 index 0000000000..2f4c01e420 --- /dev/null +++ b/packages/cli/src/commands/diff/format/next-version.ts @@ -0,0 +1,13 @@ +import { HandledError, type DiffResult } from '@redocly/openapi-core'; + +import { nextVersion } from '../check-version.js'; + +export function nextVersionDiff(result: DiffResult): string { + const next = nextVersion(result.infoVersions.base, result.bump); + if (next === undefined) { + throw new HandledError( + 'info.version of the base description is not a semver string, so the next version cannot be computed.' + ); + } + return next; +} diff --git a/packages/cli/src/commands/diff/format/order.ts b/packages/cli/src/commands/diff/format/order.ts deleted file mode 100644 index 38e74c6792..0000000000 --- a/packages/cli/src/commands/diff/format/order.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { JudgedChange } from '@redocly/openapi-core'; - -// Compared by code unit, the way `Array.prototype.sort` compares strings, so a report lists its -// changes in the same order on every machine; `localeCompare` would follow the locale. -function compare(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0; -} - -function propertyOf(change: JudgedChange): string { - return change.kind === 'modified' ? change.property : ''; -} - -export function byKeyAndProperty(left: JudgedChange, right: JudgedChange): number { - return compare(left.key, right.key) || compare(propertyOf(left), propertyOf(right)); -} diff --git a/packages/cli/src/commands/diff/format/problems.ts b/packages/cli/src/commands/diff/format/problems.ts deleted file mode 100644 index f740b7e8e5..0000000000 --- a/packages/cli/src/commands/diff/format/problems.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - displaySide, - type DiffResult, - type Impact, - type NormalizedProblem, - type ProblemSeverity, -} from '@redocly/openapi-core'; - -import { byKeyAndProperty } from './order.js'; - -// A lint problem carries one of two severities, so a patch has nowhere to go and is left -// out; this is what lets the github-actions format come from core's formatProblems. -const SEVERITIES: Partial> = { major: 'error', minor: 'warn' }; - -export function diffToProblems(result: DiffResult): NormalizedProblem[] { - return result.changes.toSorted(byKeyAndProperty).flatMap((change) => { - const severity = SEVERITIES[change.impact]; - if (!severity) return []; - // Point at the counterpart in the base document, so formats that render - // a `from` location show both sides of the change. - const from = change.kind === 'modified' ? { from: change.base.location } : {}; - - if (!change.verdicts.length) { - return [ - { - message: `${change.kind} ${change.key}`, - ruleId: 'diff', - severity, - location: [displaySide(change).location], - ...from, - suggest: [], - }, - ]; - } - - return change.verdicts.flatMap((verdict) => { - const verdictSeverity = SEVERITIES[verdict.impact]; - if (!verdictSeverity) return []; - return [ - { - message: verdict.message, - ruleId: verdict.ruleId, - severity: verdictSeverity, - location: [verdict.location], - ...from, - suggest: [], - }, - ]; - }); - }); -} diff --git a/packages/cli/src/commands/diff/format/stylish.ts b/packages/cli/src/commands/diff/format/stylish.ts index a61e7fd62e..0d63752dbe 100644 --- a/packages/cli/src/commands/diff/format/stylish.ts +++ b/packages/cli/src/commands/diff/format/stylish.ts @@ -1,19 +1,24 @@ import { displaySide, - getLineColLocation, impactRank, - isAbsoluteUrl, - isBrowser, + impacts, parsePointer, + typeOf, type Change, + type DiffNode, type DiffResult, type Impact, type JudgedChange, } from '@redocly/openapi-core'; import { blue, bold, gray, green, red } from 'colorette'; -import * as path from 'node:path'; -import { byKeyAndProperty } from './order.js'; +import { lineColOf } from './location.js'; + +const IMPACT_COLORS: Record string> = { + major: red, + minor: green, + patch: gray, +}; const IMPACT_GLYPHS: Record = { major: red('✖ major'), @@ -27,86 +32,82 @@ function segmentsOf(pointer: string): string[] { return segments; } -const HTTP_METHODS = new Set([ - 'get', - 'put', - 'post', - 'delete', - 'options', - 'head', - 'patch', - 'trace', - 'query', -]); - -function groupOf(change: Change): string { - const segments = segmentsOf(displaySide(change).location.pointer); - if (segments[0] === 'paths' && segments.length > 1) { - const pathKey = segments[1]; - const method = segments[2]; - return method && HTTP_METHODS.has(method) ? `${method.toUpperCase()} ${pathKey}` : pathKey; - } - return segments[0] || 'document'; +function nameOf(node: DiffNode): string { + return String((node.revision ?? node.base)!.key); } -// The group heading already names what the label would repeat: the endpoint under -// `paths`, the section anywhere else. -function labelSegments(segments: string[]): string[] { - if (segments[0] !== 'paths') return segments.slice(1); - const underOperation = segments.length > 2 && HTTP_METHODS.has(segments[2]); - return segments.slice(underOperation ? 3 : 2); -} +// The items that get a heading of their own, by the type of the map that holds them. +const ITEM_HEADINGS: Partial string>> = { + Paths: (path) => path, + WebhooksMap: (webhook) => `${webhook} (webhook)`, + NamedChannels: (channel) => `channel ${channel}`, + NamedOperations: (operation) => `operation ${operation}`, + ServerMap: (server) => `server ${server}`, +}; -// A change on the group node itself is labelled by its property; the node itself by its -// real path, each segment unescaped so a `/` inside a name reads as one. -function labelOf(change: JudgedChange): string { - const named = labelSegments(segmentsOf(change.key)); - if (named.length) { - return change.kind === 'modified' ? `${named.join('/')} · ${change.property}` : named.join('/'); - } - if (change.kind === 'modified') return change.property; - return segmentsOf(displaySide(change).location.pointer).join(' · '); +/** + * The heading a change is listed under — its endpoint, webhook, channel, operation or server, + * or else the section it sits in — and how many levels below the root that heading names. The + * names come from the matched nodes, so a renamed path keeps one heading for all its changes. + */ +function headingOf(change: Change): { heading: string; depth: number } { + const ancestors: DiffNode[] = []; + for (let node: DiffNode | null = change.node; node; node = node.parent) ancestors.unshift(node); + const [, section, item, operation] = ancestors; + + if (!section) return { heading: 'document', depth: 0 }; + const itemHeading = ITEM_HEADINGS[typeOf(section)]; + if (!item || !itemHeading) return { heading: nameOf(section), depth: 1 }; + + const heading = itemHeading(nameOf(item)); + const isOperation = operation && typeOf(operation) === 'Operation' && typeOf(item) === 'PathItem'; + if (isOperation) return { heading: `${nameOf(operation).toUpperCase()} ${heading}`, depth: 3 }; + return { heading, depth: 2 }; } -function locationOf(change: Change, cwd: string): string { - const { location } = displaySide(change); - const { start } = getLineColLocation(location); - const file = isAbsoluteUrl(location.source.absoluteRef) - ? location.source.absoluteRef - : path.relative(cwd, location.source.absoluteRef); - return `${file}:${start.line}:${start.col}`; +// The label leaves out what the heading names, so a change on the heading's own node has only +// its property, if any. Each segment is unescaped, so a `/` inside a name reads as one. +function labelOf(change: JudgedChange): string { + const below = segmentsOf(change.key).slice(headingOf(change).depth).join('/'); + if (change.kind !== 'modified') return below; + return below ? `${below} · ${change.property}` : change.property; } export function stylishDiff(result: DiffResult): string { - const cwd = isBrowser ? '' : process.cwd(); const groups = new Map(); for (const change of result.changes) { - const key = groupOf(change); - const group = groups.get(key) ?? []; + const { heading } = headingOf(change); + const group = groups.get(heading) ?? []; group.push(change); - groups.set(key, group); + groups.set(heading, 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( - (left, right) => - impactRank(right.impact) - impactRank(left.impact) || byKeyAndProperty(left, right) + // The changes come sorted by key, so the headings follow the document's structure. + for (const [heading, changes] of groups) { + lines.push(bold(blue(heading))); + // Sorting is stable, so changes of one impact keep the order the report lists them in. + const sorted = changes.toSorted( + (left, right) => impactRank(right.impact) - impactRank(left.impact) ); for (const change of sorted) { - lines.push( - ` ${IMPACT_GLYPHS[change.impact]} ${bold(change.kind.padEnd(8))} ${labelOf(change)}` - ); + const label = labelOf(change); + const kind = bold(label ? change.kind.padEnd(8) : change.kind); + lines.push(` ${IMPACT_GLYPHS[change.impact]} ${kind}${label ? ` ${label}` : ''}`); for (const verdict of change.verdicts) { lines.push(gray(` ${verdict.message} (${verdict.ruleId})`)); } - lines.push(gray(` at ${locationOf(change, cwd)}`)); + const { file, line, col } = lineColOf(displaySide(change).location); + lines.push(gray(` at ${file}:${line}:${col}`)); } lines.push(''); } - const { major, minor, patch } = result.summary; - lines.push(`${red(`${major} major`)}, ${green(`${minor} minor`)}, ${gray(`${patch} patch`)}.`); + // A zero is not a finding, so it is not coloured like one. + const counts = impacts.toReversed().map((impact) => { + const count = result.summary[impact]; + return (count ? IMPACT_COLORS[impact] : gray)(`${count} ${impact}`); + }); + lines.push(`${counts.join(', ')}.`); return lines.join('\n'); } diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts index f76bef87a5..dd609b358c 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -1,13 +1,21 @@ -import { bundle, diffDocuments, formatProblems, getTotals, logger } from '@redocly/openapi-core'; +import { + bundle, + diffDocuments, + formatProblems, + getTotals, + logger, + type JudgedChange, +} from '@redocly/openapi-core'; +import { green } from 'colorette'; import { writeFileSync } from 'node:fs'; import { AbortFlowError, exitWithError } from '../../utils/error.js'; import { getFallbackApisOrExit, printExecutionTime } from '../../utils/miscellaneous.js'; import type { CommandArgs } from '../../wrapper.js'; -import { checkVersion, getDeclaredVersion } from './check-version.js'; +import { checkVersion } from './check-version.js'; import { getDiffFailure } from './fail-on.js'; +import { printGithubActions } from './format/github-actions.js'; import { diffReportFormats } from './format/index.js'; -import { diffToProblems } from './format/problems.js'; import type { DiffArgv } from './types.js'; export async function handleDiff({ argv, config, collectSpecData }: CommandArgs) { @@ -19,18 +27,18 @@ export async function handleDiff({ argv, config, collectSpecData }: CommandArgs< const startedAt = performance.now(); + config.skipDiffRules(argv['skip-rule']); + const [{ path: basePath }] = await getFallbackApisOrExit([argv.base], config); const [{ path: revisionPath }] = await getFallbackApisOrExit([argv.revision], config); - config.skipDiffRules(argv['skip-rule']); + const [base, revision] = await Promise.all([ + bundle({ config, ref: basePath }), + bundle({ config, ref: revisionPath }), + ]); - const { bundle: baseDocument, problems: baseProblems } = await bundle({ config, ref: basePath }); - const { bundle: revisionDocument, problems: revisionProblems } = await bundle({ - config, - ref: revisionPath, - }); + const bundleProblems = [...base.problems, ...revision.problems]; - const bundleProblems = [...baseProblems, ...revisionProblems]; if (bundleProblems.length) { formatProblems(bundleProblems, { format: 'codeframe', @@ -42,45 +50,48 @@ export async function handleDiff({ argv, config, collectSpecData }: CommandArgs< ); } - collectSpecData?.(revisionDocument); + collectSpecData?.(revision.bundle); + + const result = diffDocuments({ base: base.bundle, revision: revision.bundle, config }); - const result = diffDocuments({ base: baseDocument, revision: revisionDocument, config }); + result.changes.sort(byKeyAndProperty); if (argv.format === 'github-actions') { - const problems = diffToProblems(result); - formatProblems(problems, { - format: 'github-actions', - totals: getTotals(problems), - maxProblems: problems.length, - }); + printGithubActions(result); + } else if (argv.output) { + writeFileSync(argv.output, diffReportFormats[argv.format](result)); + logger.info(`Diff report written to ${argv.output}.\n`); } else { - const output = diffReportFormats[argv.format](result); - if (argv.output) { - writeFileSync(argv.output, output); - logger.info(`Diff report written to ${argv.output}.\n`); - } else { - logger.output(output + '\n'); - } + logger.output(diffReportFormats[argv.format](result) + '\n'); } printExecutionTime('diff', startedAt, `${basePath} vs ${revisionPath}`); - const failures: string[] = []; - const thresholdFailure = getDiffFailure(result.summary, argv['fail-on']); - if (thresholdFailure) failures.push(thresholdFailure); - - if (argv['check-version']) { - const versionCheck = checkVersion({ - base: getDeclaredVersion(baseDocument), - revision: getDeclaredVersion(revisionDocument), - required: result.bump, - }); - if (versionCheck.status === 'skipped') logger.warn(`${versionCheck.message}\n`); - if (versionCheck.status === 'failed') failures.push(versionCheck.message); - } + const failures = [ + getDiffFailure(result.summary, argv['fail-on']), + argv['check-version'] && + checkVersion({ + base: result.infoVersions.base, + revision: result.infoVersions.revision, + required: result.bump, + }), + ].filter((failure) => typeof failure === 'string'); if (failures.length) { for (const failure of failures) logger.error(`${failure}\n`); + throw new AbortFlowError('Diff failed.'); } + + logger.info(green('✅ Diff passed.\n')); +} + +function byKeyAndProperty(left: JudgedChange, right: JudgedChange): number { + if (left.key !== right.key) return left.key < right.key ? -1 : 1; + + const leftProperty = left.kind === 'modified' ? left.property : ''; + const rightProperty = right.kind === 'modified' ? right.property : ''; + if (leftProperty !== rightProperty) return leftProperty < rightProperty ? -1 : 1; + + return 0; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c10ffa9563..0daf15add7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -158,7 +158,7 @@ yargs(hideBin(process.argv)) 'markdown', 'html', 'github-actions', - // TODO: 'next-version' + 'next-version', ] as ReadonlyArray, default: 'stylish' as const, }, diff --git a/packages/core/src/config/diff-recommended.ts b/packages/core/src/config/diff-recommended.ts index cf0adde6dd..74f3b75c14 100644 --- a/packages/core/src/config/diff-recommended.ts +++ b/packages/core/src/config/diff-recommended.ts @@ -19,7 +19,7 @@ export const defaultDiffRules: Record = { 'parameter-removed': 'major', 'parameter-serialization-changed': 'major', 'path-removed': 'major', - 'property-removed-from-response': 'major', + 'property-removed': 'major', 'ref-target-changed': 'major', 'request-body-became-required': 'major', 'request-body-removed': 'major', diff --git a/packages/core/src/diff/__tests__/index.test.ts b/packages/core/src/diff/__tests__/index.test.ts index b1209cd89d..766f069125 100644 --- a/packages/core/src/diff/__tests__/index.test.ts +++ b/packages/core/src/diff/__tests__/index.test.ts @@ -105,7 +105,7 @@ describe('diffDocuments', () => { { "impact": "major", "location": "revision.yaml#/components/schemas/OrderStatus/enum", - "message": "Enum values removed: preparing.", + "message": "Enum of \`OrderStatus\` lost values: 'preparing'.", "ruleId": "enum-values-removed", }, ], @@ -151,7 +151,7 @@ describe('diffDocuments', () => { { "impact": "major", "location": "revision.yaml#/paths/~1menu/get/parameters/1/required", - "message": "Parameter became required.", + "message": "\`limit\` query parameter became required.", "ruleId": "parameter-became-required", }, ], diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index 8f0532a56d..fda50de18d 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -5,6 +5,7 @@ import { getTypes, type SpecVersion } from '../oas-types.js'; import type { Document } from '../resolve.js'; import { normalizeTypes } from '../types/index.js'; import { HandledError } from '../utils/error.js'; +import { isPlainObject } from '../utils/is-plain-object.js'; import { collectChanges } from './changes.js'; import { buildDiffTree, typeOf } from './diff-tree.js'; import { resolveDirections } from './direction.js'; @@ -64,17 +65,27 @@ export function diffDocuments(opts: { }); return { - version: '1', + files: { + base: base.source.absoluteRef, + revision: revision.source.absoluteRef, + }, specVersions: { base: baseVersion, revision: revisionVersion, }, + infoVersions: { base: infoVersionOf(base), revision: infoVersionOf(revision) }, summary: countByImpact(changes), bump: requiredBump(changes), changes, }; } +function infoVersionOf({ parsed }: Document): string | undefined { + const version = + isPlainObject(parsed) && isPlainObject(parsed.info) ? parsed.info.version : undefined; + return typeof version === 'string' ? version : undefined; +} + function countByImpact(changes: JudgedChange[]): DiffSummary { const summary: DiffSummary = { major: 0, minor: 0, patch: 0 }; for (const change of changes) summary[change.impact]++; diff --git a/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts b/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts index a2a082e205..ce9a905bb8 100644 --- a/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts @@ -39,7 +39,7 @@ describe('channel-address-changed', () => { { "impact": "major", "location": "revision.yaml#/channels/orderPlaced/address", - "message": "The channel address changed from 'orders.placed' to 'kitchen.orders.placed'.", + "message": "Channel \`orderPlaced\` address changed from 'orders.placed' to 'kitchen.orders.placed'.", "ruleId": "channel-address-changed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/channel-removed.test.ts b/packages/core/src/diff/rules/__tests__/channel-removed.test.ts index 11d7e75c69..0936bd9665 100644 --- a/packages/core/src/diff/rules/__tests__/channel-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/channel-removed.test.ts @@ -39,7 +39,7 @@ describe('channel-removed', () => { { "impact": "major", "location": "base.yaml#/channels/orderReady", - "message": "The channel was removed.", + "message": "Channel \`orderReady\` was removed.", "ruleId": "channel-removed", }, ], @@ -73,7 +73,7 @@ describe('channel-removed', () => { { "impact": "major", "location": "base.yaml#/channels", - "message": "Every channel was removed.", + "message": "All channels were removed.", "ruleId": "channel-removed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts b/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts index 6bc80fab26..5532169143 100644 --- a/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts +++ b/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts @@ -82,7 +82,7 @@ describe('enum-values-added', () => { { "impact": "major", "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum", - "message": "Enum values added: preparing.", + "message": "Enum gained values: 'preparing'.", "ruleId": "enum-values-added", }, ], diff --git a/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts b/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts index 0afb2119ca..eade59a9d5 100644 --- a/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts @@ -63,7 +63,7 @@ describe('enum-values-removed', () => { { "impact": "major", "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum", - "message": "Enum values removed: preparing.", + "message": "Enum lost values: 'preparing'.", "ruleId": "enum-values-removed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts b/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts index 3ce9b084f2..2e1736ac56 100644 --- a/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts @@ -46,7 +46,7 @@ describe('media-type-removed', () => { { "impact": "major", "location": "base.yaml#/paths/~1menu/get/responses/200/content/text~1csv", - "message": "Media type was removed.", + "message": "Media type \`text/csv\` was removed.", "ruleId": "media-type-removed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts b/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts index ffcc3628ca..1244bc6c76 100644 --- a/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts @@ -42,7 +42,7 @@ describe('message-content-type-changed', () => { { "impact": "major", "location": "revision.yaml#/channels/orders/messages/orderPlaced/contentType", - "message": "The message content type changed from 'application/json' to 'application/avro'.", + "message": "Message \`orderPlaced\` content type changed from 'application/json' to 'application/avro'.", "ruleId": "message-content-type-changed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/message-removed.test.ts b/packages/core/src/diff/rules/__tests__/message-removed.test.ts index 3f79592ba7..20c3d7dac1 100644 --- a/packages/core/src/diff/rules/__tests__/message-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/message-removed.test.ts @@ -43,7 +43,7 @@ describe('message-removed', () => { { "impact": "major", "location": "base.yaml#/channels/orders/messages/orderCancelled", - "message": "The message was removed.", + "message": "Message \`orderCancelled\` was removed.", "ruleId": "message-removed", }, ], @@ -79,7 +79,7 @@ describe('message-removed', () => { { "impact": "major", "location": "base.yaml#/channels/orders/messages", - "message": "Every message of the channel was removed.", + "message": "All messages of channel \`orders\` were removed.", "ruleId": "message-removed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts b/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts index e6e5ccc578..c6cba12ebe 100644 --- a/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts @@ -43,7 +43,7 @@ describe('operation-action-changed', () => { { "impact": "major", "location": "revision.yaml#/operations/onOrderPlaced/action", - "message": "The operation action changed from 'receive' to 'send'.", + "message": "Operation \`onOrderPlaced\` action changed from 'receive' to 'send'.", "ruleId": "operation-action-changed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/operation-removed.test.ts b/packages/core/src/diff/rules/__tests__/operation-removed.test.ts index f17997a130..86be25aa19 100644 --- a/packages/core/src/diff/rules/__tests__/operation-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/operation-removed.test.ts @@ -43,7 +43,7 @@ describe('operation-removed', () => { { "impact": "major", "location": "base.yaml#/paths/~1orders~1{orderId}/delete", - "message": "Operation was removed.", + "message": "Operation \`DELETE /orders/{orderId}\` was removed.", "ruleId": "operation-removed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/parameter-added-required.test.ts b/packages/core/src/diff/rules/__tests__/parameter-added-required.test.ts index 2d963de515..984b4caddd 100644 --- a/packages/core/src/diff/rules/__tests__/parameter-added-required.test.ts +++ b/packages/core/src/diff/rules/__tests__/parameter-added-required.test.ts @@ -72,7 +72,7 @@ describe('parameter-added-required', () => { { "impact": "major", "location": "revision.yaml#/paths/~1menu/get/parameters/2", - "message": "A new required parameter was added.", + "message": "Required \`locale\` header parameter was added.", "ruleId": "parameter-added-required", }, ], @@ -114,7 +114,7 @@ describe('parameter-added-required', () => { { "impact": "major", "location": "revision.yaml#/paths/~1menu/get/parameters/1", - "message": "A new required parameter was added.", + "message": "Required \`category\` query parameter was added.", "ruleId": "parameter-added-required", }, ], diff --git a/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts b/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts index c989b407f7..c2cfb6f8d9 100644 --- a/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts +++ b/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts @@ -57,7 +57,7 @@ describe('parameter-became-required', () => { { "impact": "major", "location": "revision.yaml#/paths/~1menu/get/parameters/0/required", - "message": "Parameter became required.", + "message": "\`limit\` query parameter became required.", "ruleId": "parameter-became-required", }, ], diff --git a/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts b/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts index 01f71bfe35..d1c13bd7fb 100644 --- a/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts @@ -51,7 +51,7 @@ describe('parameter-removed', () => { { "impact": "major", "location": "base.yaml#/paths/~1menu/get/parameters/0", - "message": "Parameter was removed.", + "message": "\`limit\` query parameter was removed.", "ruleId": "parameter-removed", }, ], @@ -89,7 +89,7 @@ describe('parameter-removed', () => { { "impact": "major", "location": "base.yaml#/paths/~1menu/get/parameters", - "message": "Every parameter was removed.", + "message": "All parameters were removed.", "ruleId": "parameter-removed", }, ], @@ -128,7 +128,7 @@ describe('parameter-removed', () => { { "impact": "major", "location": "base.yaml#/paths/~1menu/get/parameters/0", - "message": "Parameter was removed.", + "message": "\`sort\` query parameter was removed.", "ruleId": "parameter-removed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts b/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts index abcc797d75..1e2d2c5656 100644 --- a/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts @@ -57,7 +57,7 @@ describe('parameter-serialization-changed', () => { { "impact": "major", "location": "revision.yaml#/paths/~1menu/get/parameters/0/explode", - "message": "Parameter \`explode\` changed from 'true' to 'false'.", + "message": "\`explode\` of \`category\` query parameter changed from 'true' to 'false'.", "ruleId": "parameter-serialization-changed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/path-removed.test.ts b/packages/core/src/diff/rules/__tests__/path-removed.test.ts index 6fa1ddbd49..66ac49c506 100644 --- a/packages/core/src/diff/rules/__tests__/path-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/path-removed.test.ts @@ -44,7 +44,7 @@ describe('path-removed', () => { { "impact": "major", "location": "base.yaml#/paths/~1revenue", - "message": "Path was removed.", + "message": "Path \`/revenue\` was removed.", "ruleId": "path-removed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/property-removed-from-response.test.ts b/packages/core/src/diff/rules/__tests__/property-removed.test.ts similarity index 91% rename from packages/core/src/diff/rules/__tests__/property-removed-from-response.test.ts rename to packages/core/src/diff/rules/__tests__/property-removed.test.ts index f3c9f1808f..b11edbe2eb 100644 --- a/packages/core/src/diff/rules/__tests__/property-removed-from-response.test.ts +++ b/packages/core/src/diff/rules/__tests__/property-removed.test.ts @@ -23,7 +23,7 @@ const cafe = (order: string) => outdent` schema: ${order} `; -describe('property-removed-from-response', () => { +describe('property-removed', () => { it('should report a property a response no longer sends, not one a request no longer takes', async () => { const result = diffDocuments({ base: makeDocumentFromString( @@ -36,7 +36,7 @@ describe('property-removed-from-response', () => { cafe('{ type: object, properties: { menuItemId: { type: string } } }'), 'revision.yaml' ), - config: await createConfig({ diff: { 'property-removed-from-response': 'major' } }), + config: await createConfig({ diff: { 'property-removed': 'major' } }), }); expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` @@ -67,8 +67,8 @@ describe('property-removed-from-response', () => { { "impact": "major", "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note", - "message": "Schema property was removed.", - "ruleId": "property-removed-from-response", + "message": "Property \`note\` was removed.", + "ruleId": "property-removed", }, ], }, @@ -83,7 +83,7 @@ describe('property-removed-from-response', () => { 'base.yaml' ), revision: makeDocumentFromString(cafe('{ oneOf: [{ type: string }] }'), 'revision.yaml'), - config: await createConfig({ diff: { 'property-removed-from-response': 'major' } }), + config: await createConfig({ diff: { 'property-removed': 'major' } }), }); expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` diff --git a/packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts b/packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts index 2efb9b5488..1552f4da37 100644 --- a/packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts @@ -52,7 +52,7 @@ describe('ref-target-changed', () => { { "impact": "major", "location": "revision.yaml#/paths/~1menu~1{menuItemId}/get/responses/200/content/application~1json/schema/$ref", - "message": "The reference target changed from '#/components/schemas/Beverage' to '#/components/schemas/Dessert'. The diff cannot check that the two targets are equivalent.", + "message": "Reference target changed from '#/components/schemas/Beverage' to '#/components/schemas/Dessert'. The diff cannot check that the two targets are equivalent.", "ruleId": "ref-target-changed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts b/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts index 239b9800c3..8dcb243957 100644 --- a/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts +++ b/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts @@ -45,7 +45,7 @@ describe('request-body-became-required', () => { { "impact": "major", "location": "revision.yaml#/paths/~1orders/post/requestBody/required", - "message": "The request body became required.", + "message": "Request body became required.", "ruleId": "request-body-became-required", }, ], diff --git a/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts b/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts index 53adab5eaf..df19954bd0 100644 --- a/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts @@ -46,7 +46,7 @@ describe('request-body-removed', () => { { "impact": "major", "location": "base.yaml#/paths/~1orders/post/requestBody", - "message": "The request body was removed.", + "message": "Request body was removed.", "ruleId": "request-body-removed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts b/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts index 593477878c..484467bd56 100644 --- a/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts +++ b/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts @@ -58,7 +58,7 @@ describe('required-properties-added', () => { { "impact": "major", "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", - "message": "Properties became required: quantity.", + "message": "Properties became required: \`quantity\`.", "ruleId": "required-properties-added", }, ], diff --git a/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts b/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts index 60a124c399..b09de5174b 100644 --- a/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts @@ -81,7 +81,7 @@ describe('required-properties-removed', () => { { "impact": "major", "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required", - "message": "Properties are no longer required: quantity.", + "message": "Properties are no longer required: \`quantity\`.", "ruleId": "required-properties-removed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts b/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts index 09f308e93b..459a6e8f6a 100644 --- a/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts @@ -50,7 +50,7 @@ describe('response-header-removed', () => { { "impact": "major", "location": "base.yaml#/paths/~1orders/post/responses/201/headers/X-Rate-Limit", - "message": "A response header was removed.", + "message": "Response header \`X-Rate-Limit\` was removed.", "ruleId": "response-header-removed", }, ], @@ -89,7 +89,7 @@ describe('response-header-removed', () => { { "impact": "major", "location": "base.yaml#/paths/~1orders/post/responses/201/headers", - "message": "The response headers were removed.", + "message": "All headers of response \`201\` were removed.", "ruleId": "response-header-removed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/response-removed.test.ts b/packages/core/src/diff/rules/__tests__/response-removed.test.ts index 4b90454557..500c16305f 100644 --- a/packages/core/src/diff/rules/__tests__/response-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/response-removed.test.ts @@ -43,7 +43,7 @@ describe('response-removed', () => { { "impact": "major", "location": "base.yaml#/paths/~1orders~1{orderId}/get/responses/404", - "message": "Response was removed.", + "message": "Response \`404\` was removed.", "ruleId": "response-removed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts index 06da954f8f..d652a127d3 100644 --- a/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts @@ -50,7 +50,7 @@ describe('schema-combinator-changed', () => { { "impact": "major", "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1", - "message": "A \`oneOf\` subschema was removed.", + "message": "\`oneOf\` subschema was removed.", "ruleId": "schema-combinator-changed", }, ], @@ -99,7 +99,7 @@ describe('schema-combinator-changed', () => { { "impact": "major", "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf/1", - "message": "A \`allOf\` subschema was added.", + "message": "\`allOf\` subschema was added.", "ruleId": "schema-combinator-changed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts index 5b4b8abd5c..77a9733806 100644 --- a/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts @@ -50,7 +50,7 @@ describe('schema-type-changed', () => { { "impact": "major", "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", - "message": "Schema type narrowed from 'number' to 'integer'.", + "message": "Type narrowed from 'number' to 'integer'.", "ruleId": "schema-type-changed", }, ], @@ -115,7 +115,7 @@ describe('schema-type-changed', () => { { "impact": "major", "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", - "message": "Schema type widened from 'integer' to 'number'.", + "message": "Type widened from 'integer' to 'number'.", "ruleId": "schema-type-changed", }, ], @@ -150,7 +150,7 @@ describe('schema-type-changed', () => { { "impact": "major", "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", - "message": "Schema type narrowed from 'number | null' to 'number'.", + "message": "Type narrowed from 'number | null' to 'number'.", "ruleId": "schema-type-changed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/security-requirement-added.test.ts b/packages/core/src/diff/rules/__tests__/security-requirement-added.test.ts index a03e1813f4..f2f423775e 100644 --- a/packages/core/src/diff/rules/__tests__/security-requirement-added.test.ts +++ b/packages/core/src/diff/rules/__tests__/security-requirement-added.test.ts @@ -53,7 +53,7 @@ describe('security-requirement-added', () => { { "impact": "major", "location": "revision.yaml#/paths/~1orders/get/security", - "message": "The API now requires authentication.", + "message": "Authentication became required.", "ruleId": "security-requirement-added", }, ], @@ -88,7 +88,7 @@ describe('security-requirement-added', () => { { "impact": "major", "location": "revision.yaml#/paths/~1orders/get/security/0", - "message": "The API now requires authentication.", + "message": "Authentication became required.", "ruleId": "security-requirement-added", }, ], diff --git a/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts b/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts index b69611f900..a73cc9db3a 100644 --- a/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts @@ -47,7 +47,7 @@ describe('security-scheme-changed', () => { { "impact": "major", "location": "revision.yaml#/components/securitySchemes/ApiKey/name", - "message": "Security scheme \`name\` changed from 'X-Api-Key' to 'X-Cafe-Key'.", + "message": "\`name\` of security scheme \`ApiKey\` changed from 'X-Api-Key' to 'X-Cafe-Key'.", "ruleId": "security-scheme-changed", }, ], @@ -85,7 +85,7 @@ describe('security-scheme-changed', () => { { "impact": "major", "location": "revision.yaml#/components/securitySchemes/ApiKey/type", - "message": "Security scheme \`type\` changed from 'apiKey' to 'http'.", + "message": "\`type\` of security scheme \`ApiKey\` changed from 'apiKey' to 'http'.", "ruleId": "security-scheme-changed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/security-scheme-removed.test.ts b/packages/core/src/diff/rules/__tests__/security-scheme-removed.test.ts index c0040878a3..56e1e34462 100644 --- a/packages/core/src/diff/rules/__tests__/security-scheme-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/security-scheme-removed.test.ts @@ -41,7 +41,7 @@ describe('security-scheme-removed', () => { { "impact": "major", "location": "base.yaml#/components/securitySchemes/Bearer", - "message": "A security scheme was removed.", + "message": "Security scheme \`Bearer\` was removed.", "ruleId": "security-scheme-removed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/security-scopes-added.test.ts b/packages/core/src/diff/rules/__tests__/security-scopes-added.test.ts index 94ceef12c7..2c2e20a194 100644 --- a/packages/core/src/diff/rules/__tests__/security-scopes-added.test.ts +++ b/packages/core/src/diff/rules/__tests__/security-scopes-added.test.ts @@ -62,7 +62,7 @@ describe('security-scopes-added', () => { { "impact": "major", "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth", - "message": "Scheme \`OAuth\` requires new scopes: orders:write.", + "message": "Security scheme \`OAuth\` requires new scopes: 'orders:write'.", "ruleId": "security-scopes-added", }, ], diff --git a/packages/core/src/diff/rules/__tests__/server-removed.test.ts b/packages/core/src/diff/rules/__tests__/server-removed.test.ts index 012c981395..bc7653936f 100644 --- a/packages/core/src/diff/rules/__tests__/server-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/server-removed.test.ts @@ -40,7 +40,7 @@ describe('server-removed', () => { { "impact": "major", "location": "base.yaml#/servers/sandbox", - "message": "The server was removed.", + "message": "Server \`sandbox\` was removed.", "ruleId": "server-removed", }, ], @@ -75,7 +75,7 @@ describe('server-removed', () => { { "impact": "major", "location": "base.yaml#/servers", - "message": "Every server was removed.", + "message": "All servers were removed.", "ruleId": "server-removed", }, ], @@ -84,3 +84,111 @@ describe('server-removed', () => { `); }); }); + +describe('server-removed for OpenAPI', () => { + const cafe = (servers?: string, operationServers?: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + ${servers === undefined ? '' : `servers: ${servers}`} + paths: + /menu: + get: + ${operationServers === undefined ? '' : `servers: ${operationServers}`} + responses: { 200: { description: OK } } + `; + const production = '{ url: https://api.cafe.example }'; + const sandbox = '{ url: https://sandbox.cafe.example }'; + + it('should report a server that is gone, whatever its position in the list', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`[${sandbox}, ${production}]`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`[${production}]`), 'revision.yaml'), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers/0", + "value": { + "url": "https://sandbox.cafe.example", + }, + }, + "impact": "major", + "key": "#/servers/{https:~1~1sandbox.cafe.example}", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/servers/0", + "message": "Server \`https://sandbox.cafe.example\` was removed.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + }); + + it('should report the document losing its servers list', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`[${production}]`), 'base.yaml'), + revision: makeDocumentFromString(cafe(), 'revision.yaml'), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers", + "value": [ + { + "url": "https://api.cafe.example", + }, + ], + }, + "impact": "major", + "key": "#/servers", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/servers", + "message": "All servers were removed.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + }); + + it('should not report an operation losing its own servers, which falls back to the document', async () => { + const result = diffDocuments({ + base: makeDocumentFromString(cafe(`[${production}]`, `[${sandbox}]`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`[${production}]`), 'revision.yaml'), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/servers", + "value": [ + { + "url": "https://sandbox.cafe.example", + }, + ], + }, + "impact": "patch", + "key": "#/paths/~1menu/get/servers", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/channel-address-changed.ts b/packages/core/src/diff/rules/channel-address-changed.ts index 8f591e9471..e1625ff5a7 100644 --- a/packages/core/src/diff/rules/channel-address-changed.ts +++ b/packages/core/src/diff/rules/channel-address-changed.ts @@ -1,10 +1,15 @@ import type { DiffRule } from '../types.js'; +import { describeChange, nameOf } from './utils.js'; export const ChannelAddressChanged: DiffRule = () => ({ Channel(change, { report }) { if (change.kind !== 'modified' || change.property !== 'address') return; report({ - message: `The channel address changed from '${change.base.value}' to '${change.revision.value}'.`, + message: describeChange( + `Channel \`${nameOf(change.node)}\` address`, + change.base.value, + change.revision.value + ), }); }, }); diff --git a/packages/core/src/diff/rules/channel-removed.ts b/packages/core/src/diff/rules/channel-removed.ts index 19e9267a83..fcc51fddf2 100644 --- a/packages/core/src/diff/rules/channel-removed.ts +++ b/packages/core/src/diff/rules/channel-removed.ts @@ -1,12 +1,14 @@ import type { DiffRule } from '../types.js'; +import { nameOf } from './utils.js'; // A channel is where AsyncAPI messages travel, so both sides of it break together and the // rule does not read the direction. export const ChannelRemoved: DiffRule = () => ({ Channel(change, { report }) { - if (change.kind === 'removed') report({ message: 'The channel was removed.' }); + if (change.kind === 'removed') + report({ message: `Channel \`${nameOf(change.node)}\` was removed.` }); }, NamedChannels(change, { report }) { - if (change.kind === 'removed') report({ message: 'Every channel was removed.' }); + if (change.kind === 'removed') report({ message: 'All channels were removed.' }); }, }); diff --git a/packages/core/src/diff/rules/enum-values-added.ts b/packages/core/src/diff/rules/enum-values-added.ts index 8bc4f38c9f..c6d9bf986d 100644 --- a/packages/core/src/diff/rules/enum-values-added.ts +++ b/packages/core/src/diff/rules/enum-values-added.ts @@ -1,11 +1,12 @@ import type { DiffRule } from '../types.js'; -import { itemsOnlyIn } from './utils.js'; +import { itemsOnlyIn, ofSchema, quoted } from './utils.js'; export const EnumValuesAdded: DiffRule = () => ({ Schema(change, { report, directions }) { if (change.kind !== 'modified' || change.property !== 'enum') return; if (!directions.includes('response')) return; const added = itemsOnlyIn(change.revision.value, change.base.value); - if (added.length) report({ message: `Enum values added: ${added.join(', ')}.` }); + if (added.length) + report({ message: `Enum${ofSchema(change.node)} gained values: ${quoted(added)}.` }); }, }); diff --git a/packages/core/src/diff/rules/enum-values-removed.ts b/packages/core/src/diff/rules/enum-values-removed.ts index 290c044ce4..0111505b5c 100644 --- a/packages/core/src/diff/rules/enum-values-removed.ts +++ b/packages/core/src/diff/rules/enum-values-removed.ts @@ -1,11 +1,12 @@ import type { DiffRule } from '../types.js'; -import { itemsOnlyIn } from './utils.js'; +import { itemsOnlyIn, ofSchema, quoted } from './utils.js'; export const EnumValuesRemoved: DiffRule = () => ({ Schema(change, { report, directions }) { if (change.kind !== 'modified' || change.property !== 'enum') return; if (!directions.includes('request')) return; const removed = itemsOnlyIn(change.base.value, change.revision.value); - if (removed.length) report({ message: `Enum values removed: ${removed.join(', ')}.` }); + if (removed.length) + report({ message: `Enum${ofSchema(change.node)} lost values: ${quoted(removed)}.` }); }, }); diff --git a/packages/core/src/diff/rules/index.ts b/packages/core/src/diff/rules/index.ts index 216e424bab..068586bb08 100644 --- a/packages/core/src/diff/rules/index.ts +++ b/packages/core/src/diff/rules/index.ts @@ -15,7 +15,7 @@ import { ParameterBecameRequired } from './parameter-became-required.js'; import { ParameterRemoved } from './parameter-removed.js'; import { ParameterSerializationChanged } from './parameter-serialization-changed.js'; import { PathRemoved } from './path-removed.js'; -import { PropertyRemovedFromResponse } from './property-removed-from-response.js'; +import { PropertyRemoved } from './property-removed.js'; import { RefTargetChanged } from './ref-target-changed.js'; import { RequestBodyBecameRequired } from './request-body-became-required.js'; import { RequestBodyRemoved } from './request-body-removed.js'; @@ -43,7 +43,7 @@ const schemaRules = { 'enum-values-added': EnumValuesAdded, 'required-properties-added': RequiredPropertiesAdded, 'required-properties-removed': RequiredPropertiesRemoved, - 'property-removed-from-response': PropertyRemovedFromResponse, + 'property-removed': PropertyRemoved, 'numeric-range-changed': NumericRangeChanged, 'string-length-changed': StringLengthChanged, 'schema-format-changed': SchemaFormatChanged, @@ -66,6 +66,7 @@ export const oas3Rules = { 'security-scopes-added': SecurityScopesAdded, 'security-scheme-changed': SecuritySchemeChanged, 'security-scheme-removed': SecuritySchemeRemoved, + 'server-removed': ServerRemoved, 'ref-target-changed': RefTargetChanged, ...schemaRules, 'schema-combinator-changed': SchemaCombinatorChanged, diff --git a/packages/core/src/diff/rules/media-type-removed.ts b/packages/core/src/diff/rules/media-type-removed.ts index fcdca42a63..2dbf48bcdd 100644 --- a/packages/core/src/diff/rules/media-type-removed.ts +++ b/packages/core/src/diff/rules/media-type-removed.ts @@ -1,7 +1,9 @@ import type { DiffRule } from '../types.js'; +import { nameOf } from './utils.js'; export const MediaTypeRemoved: DiffRule = () => ({ MediaType(change, { report }) { - if (change.kind === 'removed') report({ message: 'Media type was removed.' }); + if (change.kind === 'removed') + report({ message: `Media type \`${nameOf(change.node)}\` was removed.` }); }, }); diff --git a/packages/core/src/diff/rules/message-content-type-changed.ts b/packages/core/src/diff/rules/message-content-type-changed.ts index 1d4496741b..851d00f3da 100644 --- a/packages/core/src/diff/rules/message-content-type-changed.ts +++ b/packages/core/src/diff/rules/message-content-type-changed.ts @@ -1,10 +1,15 @@ import type { DiffRule } from '../types.js'; +import { describeChange, nameOf } from './utils.js'; export const MessageContentTypeChanged: DiffRule = () => ({ Message(change, { report }) { if (change.kind !== 'modified' || change.property !== 'contentType') return; report({ - message: `The message content type changed from '${change.base.value}' to '${change.revision.value}'.`, + message: describeChange( + `Message \`${nameOf(change.node)}\` content type`, + change.base.value, + change.revision.value + ), }); }, }); diff --git a/packages/core/src/diff/rules/message-removed.ts b/packages/core/src/diff/rules/message-removed.ts index 70f21678b1..5ea94cecce 100644 --- a/packages/core/src/diff/rules/message-removed.ts +++ b/packages/core/src/diff/rules/message-removed.ts @@ -1,10 +1,15 @@ import type { DiffRule } from '../types.js'; +import { nameOf } from './utils.js'; export const MessageRemoved: DiffRule = () => ({ Message(change, { report }) { - if (change.kind === 'removed') report({ message: 'The message was removed.' }); + if (change.kind === 'removed') + report({ message: `Message \`${nameOf(change.node)}\` was removed.` }); }, NamedMessages(change, { report }) { - if (change.kind === 'removed') report({ message: 'Every message of the channel was removed.' }); + if (change.kind === 'removed') + report({ + message: `All messages of channel \`${nameOf(change.node.parent!)}\` were removed.`, + }); }, }); diff --git a/packages/core/src/diff/rules/operation-action-changed.ts b/packages/core/src/diff/rules/operation-action-changed.ts index ee67f515a6..dbf0e48676 100644 --- a/packages/core/src/diff/rules/operation-action-changed.ts +++ b/packages/core/src/diff/rules/operation-action-changed.ts @@ -1,4 +1,5 @@ import type { DiffRule } from '../types.js'; +import { describeChange, nameOf } from './utils.js'; // AsyncAPI only: an operation states its own direction, and swapping it turns every // message of the channel around. @@ -6,7 +7,11 @@ export const OperationActionChanged: DiffRule = () => ({ Operation(change, { report }) { if (change.kind !== 'modified' || change.property !== 'action') return; report({ - message: `The operation action changed from '${change.base.value}' to '${change.revision.value}'.`, + message: describeChange( + `Operation \`${nameOf(change.node)}\` action`, + change.base.value, + change.revision.value + ), }); }, }); diff --git a/packages/core/src/diff/rules/operation-removed.ts b/packages/core/src/diff/rules/operation-removed.ts index e2f5bfe871..536c0b5f9e 100644 --- a/packages/core/src/diff/rules/operation-removed.ts +++ b/packages/core/src/diff/rules/operation-removed.ts @@ -1,7 +1,20 @@ -import type { DiffRule } from '../types.js'; +import { typeOf } from '../diff-tree.js'; +import type { DiffNode, DiffRule } from '../types.js'; +import { nameOf } from './utils.js'; + +// An OpenAPI operation is named by its method and path; an AsyncAPI one by its own key. +function describeOperation(operation: DiffNode): string { + const pathItem = operation.parent; + if (pathItem && typeOf(pathItem) === 'PathItem') { + return `${nameOf(operation).toUpperCase()} ${nameOf(pathItem)}`; + } + return nameOf(operation); +} export const OperationRemoved: DiffRule = () => ({ Operation(change, { report }) { - if (change.kind === 'removed') report({ message: 'Operation was removed.' }); + if (change.kind === 'removed') { + report({ message: `Operation \`${describeOperation(change.node)}\` was removed.` }); + } }, }); diff --git a/packages/core/src/diff/rules/parameter-added-required.ts b/packages/core/src/diff/rules/parameter-added-required.ts index 128b37840d..7a57e064e4 100644 --- a/packages/core/src/diff/rules/parameter-added-required.ts +++ b/packages/core/src/diff/rules/parameter-added-required.ts @@ -1,5 +1,6 @@ import { isPlainObject } from '../../utils/is-plain-object.js'; import type { DiffRule } from '../types.js'; +import { describeParameter } from './utils.js'; function hasRequiredParameter(parameters: unknown[]): boolean { return parameters.some((parameter) => isPlainObject(parameter) && parameter.required === true); @@ -11,13 +12,13 @@ export const ParameterAddedRequired: DiffRule = () => ({ Parameter(change, { report, directions }) { if (change.kind !== 'added' || !directions.includes('request')) return; if (hasRequiredParameter([change.revision.value])) { - report({ message: 'A new required parameter was added.' }); + report({ message: `Required ${describeParameter(change.node)} was added.` }); } }, ParameterList(change, { report, directions }) { if (change.kind !== 'added' || !directions.includes('request')) return; if (Array.isArray(change.revision.value) && hasRequiredParameter(change.revision.value)) { - report({ message: 'A new required parameter was added.' }); + report({ message: 'Required parameters were added.' }); } }, }); diff --git a/packages/core/src/diff/rules/parameter-became-required.ts b/packages/core/src/diff/rules/parameter-became-required.ts index 07e5026146..e74e85fba4 100644 --- a/packages/core/src/diff/rules/parameter-became-required.ts +++ b/packages/core/src/diff/rules/parameter-became-required.ts @@ -1,10 +1,11 @@ import type { DiffRule } from '../types.js'; +import { describeParameter } from './utils.js'; export const ParameterBecameRequired: DiffRule = () => ({ Parameter(change, { report, directions }) { if (change.kind !== 'modified' || change.property !== 'required') return; if (directions.includes('request') && change.revision.value === true) { - report({ message: 'Parameter became required.' }); + report({ message: `${describeParameter(change.node)} became required.` }); } }, }); diff --git a/packages/core/src/diff/rules/parameter-removed.ts b/packages/core/src/diff/rules/parameter-removed.ts index 014b8f45e1..09513e42fd 100644 --- a/packages/core/src/diff/rules/parameter-removed.ts +++ b/packages/core/src/diff/rules/parameter-removed.ts @@ -1,16 +1,17 @@ import type { DiffRule } from '../types.js'; +import { describeParameter } from './utils.js'; // The last parameter of an operation leaves with the whole `parameters` list, so that change // lands on the list rather than on a parameter. export const ParameterRemoved: DiffRule = () => ({ Parameter(change, { report, directions }) { if (change.kind === 'removed' && directions.includes('request')) { - report({ message: 'Parameter was removed.' }); + report({ message: `${describeParameter(change.node)} was removed.` }); } }, ParameterList(change, { report, directions }) { if (change.kind === 'removed' && directions.includes('request')) { - report({ message: 'Every parameter was removed.' }); + report({ message: 'All parameters were removed.' }); } }, }); diff --git a/packages/core/src/diff/rules/parameter-serialization-changed.ts b/packages/core/src/diff/rules/parameter-serialization-changed.ts index b7c31d52f6..459cda2ac3 100644 --- a/packages/core/src/diff/rules/parameter-serialization-changed.ts +++ b/packages/core/src/diff/rules/parameter-serialization-changed.ts @@ -1,4 +1,5 @@ import type { DiffRule } from '../types.js'; +import { describeChange, describeParameter } from './utils.js'; // How a value is put on the wire is part of the contract: a client that encoded // the old way is not understood after the change. @@ -9,7 +10,11 @@ export const ParameterSerializationChanged: DiffRule = () => ({ if (change.kind !== 'modified' || !SERIALIZATION.has(change.property)) return; if (!directions.includes('request')) return; report({ - message: `Parameter \`${change.property}\` changed from '${change.base.value}' to '${change.revision.value}'.`, + message: describeChange( + `\`${change.property}\` of ${describeParameter(change.node)}`, + change.base.value, + change.revision.value + ), }); }, }); diff --git a/packages/core/src/diff/rules/path-removed.ts b/packages/core/src/diff/rules/path-removed.ts index 7a54929162..a38e398693 100644 --- a/packages/core/src/diff/rules/path-removed.ts +++ b/packages/core/src/diff/rules/path-removed.ts @@ -1,7 +1,9 @@ import type { DiffRule } from '../types.js'; +import { nameOf } from './utils.js'; export const PathRemoved: DiffRule = () => ({ PathItem(change, { report }) { - if (change.kind === 'removed') report({ message: 'Path was removed.' }); + if (change.kind === 'removed') + report({ message: `Path \`${nameOf(change.node)}\` was removed.` }); }, }); diff --git a/packages/core/src/diff/rules/property-removed-from-response.ts b/packages/core/src/diff/rules/property-removed.ts similarity index 56% rename from packages/core/src/diff/rules/property-removed-from-response.ts rename to packages/core/src/diff/rules/property-removed.ts index 4f54fa5b28..096eb85b7f 100644 --- a/packages/core/src/diff/rules/property-removed-from-response.ts +++ b/packages/core/src/diff/rules/property-removed.ts @@ -1,11 +1,14 @@ import type { DiffRule } from '../types.js'; +import { nameOf, ofSchema } from './utils.js'; // A member of a `properties` map; an alternative of a `oneOf` is a schema too, but not a property. -export const PropertyRemovedFromResponse: DiffRule = () => ({ +export const PropertyRemoved: DiffRule = () => ({ SchemaProperties: { Schema(change, { report, directions }) { if (change.kind === 'removed' && directions.includes('response')) { - report({ message: 'Schema property was removed.' }); + report({ + message: `Property \`${nameOf(change.node)}\`${ofSchema(change.node.parent?.parent)} was removed.`, + }); } }, }, diff --git a/packages/core/src/diff/rules/ref-target-changed.ts b/packages/core/src/diff/rules/ref-target-changed.ts index 119f655e0a..785185406b 100644 --- a/packages/core/src/diff/rules/ref-target-changed.ts +++ b/packages/core/src/diff/rules/ref-target-changed.ts @@ -10,7 +10,7 @@ export const RefTargetChanged: DiffRule = () => ({ any(change, { report }) { if (change.kind !== 'modified' || change.property !== '$ref') return; report({ - message: `The reference target changed from ${describeTarget(change.base.value)} to ${describeTarget( + message: `Reference target changed from ${describeTarget(change.base.value)} to ${describeTarget( change.revision.value )}. The diff cannot check that the two targets are equivalent.`, }); diff --git a/packages/core/src/diff/rules/request-body-became-required.ts b/packages/core/src/diff/rules/request-body-became-required.ts index 8aa3ca7ce4..08f6044971 100644 --- a/packages/core/src/diff/rules/request-body-became-required.ts +++ b/packages/core/src/diff/rules/request-body-became-required.ts @@ -4,7 +4,7 @@ export const RequestBodyBecameRequired: DiffRule = () => ({ RequestBody(change, { report, directions }) { if (change.kind !== 'modified' || change.property !== 'required') return; if (directions.includes('request') && change.revision.value === true) { - report({ message: 'The request body became required.' }); + report({ message: 'Request body became required.' }); } }, }); diff --git a/packages/core/src/diff/rules/request-body-removed.ts b/packages/core/src/diff/rules/request-body-removed.ts index 7993e7efad..0d7c2af2a3 100644 --- a/packages/core/src/diff/rules/request-body-removed.ts +++ b/packages/core/src/diff/rules/request-body-removed.ts @@ -3,7 +3,7 @@ import type { DiffRule } from '../types.js'; export const RequestBodyRemoved: DiffRule = () => ({ RequestBody(change, { report, directions }) { if (change.kind === 'removed' && directions.includes('request')) { - report({ message: 'The request body was removed.' }); + report({ message: 'Request body was removed.' }); } }, }); diff --git a/packages/core/src/diff/rules/required-properties-added.ts b/packages/core/src/diff/rules/required-properties-added.ts index 239ac65751..6d8502a837 100644 --- a/packages/core/src/diff/rules/required-properties-added.ts +++ b/packages/core/src/diff/rules/required-properties-added.ts @@ -1,11 +1,12 @@ import type { DiffRule } from '../types.js'; -import { itemsOnlyIn } from './utils.js'; +import { itemsOnlyIn, named, ofSchema } from './utils.js'; export const RequiredPropertiesAdded: DiffRule = () => ({ Schema(change, { report, directions }) { if (change.kind !== 'modified' || change.property !== 'required') return; if (!directions.includes('request')) return; const added = itemsOnlyIn(change.revision.value, change.base.value); - if (added.length) report({ message: `Properties became required: ${added.join(', ')}.` }); + if (added.length) + report({ message: `Properties${ofSchema(change.node)} became required: ${named(added)}.` }); }, }); diff --git a/packages/core/src/diff/rules/required-properties-removed.ts b/packages/core/src/diff/rules/required-properties-removed.ts index ea220aa347..8b564e0ee0 100644 --- a/packages/core/src/diff/rules/required-properties-removed.ts +++ b/packages/core/src/diff/rules/required-properties-removed.ts @@ -1,5 +1,5 @@ import type { DiffRule } from '../types.js'; -import { itemsOnlyIn } from './utils.js'; +import { itemsOnlyIn, named, ofSchema } from './utils.js'; export const RequiredPropertiesRemoved: DiffRule = () => ({ Schema(change, { report, directions }) { @@ -7,7 +7,9 @@ export const RequiredPropertiesRemoved: DiffRule = () => ({ if (!directions.includes('response')) return; const removed = itemsOnlyIn(change.base.value, change.revision.value); if (removed.length) { - report({ message: `Properties are no longer required: ${removed.join(', ')}.` }); + report({ + message: `Properties${ofSchema(change.node)} are no longer required: ${named(removed)}.`, + }); } }, }); diff --git a/packages/core/src/diff/rules/response-header-removed.ts b/packages/core/src/diff/rules/response-header-removed.ts index 711a19dc8d..e68d7545fa 100644 --- a/packages/core/src/diff/rules/response-header-removed.ts +++ b/packages/core/src/diff/rules/response-header-removed.ts @@ -1,16 +1,19 @@ import type { DiffRule } from '../types.js'; +import { nameOf } from './utils.js'; // Dropping every header collapses into a single change on the map, dropping one lands on // the header itself. export const ResponseHeaderRemoved: DiffRule = () => ({ Header(change, { report, directions }) { if (change.kind === 'removed' && directions.includes('response')) { - report({ message: 'A response header was removed.' }); + report({ message: `Response header \`${nameOf(change.node)}\` was removed.` }); } }, HeadersMap(change, { report, directions }) { if (change.kind === 'removed' && directions.includes('response')) { - report({ message: 'The response headers were removed.' }); + report({ + message: `All headers of response \`${nameOf(change.node.parent!)}\` were removed.`, + }); } }, }); diff --git a/packages/core/src/diff/rules/response-removed.ts b/packages/core/src/diff/rules/response-removed.ts index 218b29c680..465b629910 100644 --- a/packages/core/src/diff/rules/response-removed.ts +++ b/packages/core/src/diff/rules/response-removed.ts @@ -1,7 +1,9 @@ import type { DiffRule } from '../types.js'; +import { nameOf } from './utils.js'; export const ResponseRemoved: DiffRule = () => ({ Response(change, { report }) { - if (change.kind === 'removed') report({ message: 'Response was removed.' }); + if (change.kind === 'removed') + report({ message: `Response \`${nameOf(change.node)}\` was removed.` }); }, }); diff --git a/packages/core/src/diff/rules/schema-combinator-changed.ts b/packages/core/src/diff/rules/schema-combinator-changed.ts index de0d402dd2..91c2a06d98 100644 --- a/packages/core/src/diff/rules/schema-combinator-changed.ts +++ b/packages/core/src/diff/rules/schema-combinator-changed.ts @@ -1,5 +1,5 @@ import type { DiffRule, DiffVisit } from '../types.js'; -import { breakingDirection } from './utils.js'; +import { breakingDirection, ofSchema } from './utils.js'; // `oneOf`/`anyOf` list alternatives, so dropping one accepts less; `allOf` combines // constraints, so adding one accepts less. Only the OpenAPI type tree names each list after @@ -11,7 +11,10 @@ function combinatorChanged(combinator: 'allOf' | 'anyOf' | 'oneOf'): DiffVisit { const removed = change.kind === 'removed'; const acceptsLess = combinator === 'allOf' ? !removed : removed; if (directions.includes(breakingDirection(acceptsLess))) { - report({ message: `A \`${combinator}\` subschema was ${removed ? 'removed' : 'added'}.` }); + const owner = ofSchema(change.node.parent?.parent); + report({ + message: `\`${combinator}\` subschema${owner} was ${removed ? 'removed' : 'added'}.`, + }); } }; } diff --git a/packages/core/src/diff/rules/schema-type-changed.ts b/packages/core/src/diff/rules/schema-type-changed.ts index a08b6b2627..62618d346d 100644 --- a/packages/core/src/diff/rules/schema-type-changed.ts +++ b/packages/core/src/diff/rules/schema-type-changed.ts @@ -1,6 +1,7 @@ import { fieldOf } from '../../node-tree/access.js'; import { dequal } from '../../utils/dequal.js'; import type { DiffRule } from '../types.js'; +import { ofSchema } from './utils.js'; // `integer` accepts a subset of what `number` does, so it is the one implicit widening among // the JSON Schema primitive types. @@ -40,10 +41,10 @@ export const SchemaTypeChanged: DiffRule = () => ({ const described = `from '${before.join(' | ')}' to '${after.join(' | ')}'`; if (directions.includes('request') && typesNotIn(before, after).length) { - report({ message: `Schema type narrowed ${described}.` }); + report({ message: `Type${ofSchema(change.node)} narrowed ${described}.` }); } if (directions.includes('response') && typesNotIn(after, before).length) { - report({ message: `Schema type widened ${described}.` }); + report({ message: `Type${ofSchema(change.node)} widened ${described}.` }); } }, }); diff --git a/packages/core/src/diff/rules/security-requirement-added.ts b/packages/core/src/diff/rules/security-requirement-added.ts index 24312df5fc..09bd220e7c 100644 --- a/packages/core/src/diff/rules/security-requirement-added.ts +++ b/packages/core/src/diff/rules/security-requirement-added.ts @@ -11,12 +11,12 @@ import type { DiffRule } from '../types.js'; */ export const SecurityRequirementAdded: DiffRule = () => ({ SecurityRequirementList(change, { report }) { - if (change.kind === 'added') report({ message: 'The API now requires authentication.' }); + if (change.kind === 'added') report({ message: 'Authentication became required.' }); }, SecurityRequirement(change, { report }) { const list = change.node.parent?.base?.value; if (change.kind === 'added' && Array.isArray(list) && list.length === 0) { - report({ message: 'The API now requires authentication.' }); + report({ message: 'Authentication became required.' }); } }, }); diff --git a/packages/core/src/diff/rules/security-scheme-changed.ts b/packages/core/src/diff/rules/security-scheme-changed.ts index 85a839b18b..9ab51669fe 100644 --- a/packages/core/src/diff/rules/security-scheme-changed.ts +++ b/packages/core/src/diff/rules/security-scheme-changed.ts @@ -1,5 +1,6 @@ import { fieldOf } from '../../node-tree/access.js'; import type { DiffRule } from '../types.js'; +import { describeChange, nameOf } from './utils.js'; const SCHEME_IDENTITY = new Set([ 'type', @@ -20,7 +21,11 @@ export const SecuritySchemeChanged: DiffRule = () => ({ if (typeChanged && change.property !== 'type') return; report({ - message: `Security scheme \`${change.property}\` changed from '${change.base.value}' to '${change.revision.value}'.`, + message: describeChange( + `\`${change.property}\` of security scheme \`${nameOf(change.node)}\``, + change.base.value, + change.revision.value + ), }); }, }); diff --git a/packages/core/src/diff/rules/security-scheme-removed.ts b/packages/core/src/diff/rules/security-scheme-removed.ts index b1fe29ece3..04e75a0b38 100644 --- a/packages/core/src/diff/rules/security-scheme-removed.ts +++ b/packages/core/src/diff/rules/security-scheme-removed.ts @@ -1,7 +1,9 @@ import type { DiffRule } from '../types.js'; +import { nameOf } from './utils.js'; export const SecuritySchemeRemoved: DiffRule = () => ({ SecurityScheme(change, { report }) { - if (change.kind === 'removed') report({ message: 'A security scheme was removed.' }); + if (change.kind === 'removed') + report({ message: `Security scheme \`${nameOf(change.node)}\` was removed.` }); }, }); diff --git a/packages/core/src/diff/rules/security-scopes-added.ts b/packages/core/src/diff/rules/security-scopes-added.ts index 2b32cac0d3..f2b9b46880 100644 --- a/packages/core/src/diff/rules/security-scopes-added.ts +++ b/packages/core/src/diff/rules/security-scopes-added.ts @@ -1,5 +1,5 @@ import type { DiffRule } from '../types.js'; -import { itemsOnlyIn } from './utils.js'; +import { itemsOnlyIn, quoted } from './utils.js'; export const SecurityScopesAdded: DiffRule = () => ({ SecurityRequirement(change, { report }) { @@ -7,6 +7,8 @@ export const SecurityScopesAdded: DiffRule = () => ({ if (change.kind !== 'modified') return; const added = itemsOnlyIn(change.revision.value, change.base.value); if (!added.length) return; - report({ message: `Scheme \`${change.property}\` requires new scopes: ${added.join(', ')}.` }); + report({ + message: `Security scheme \`${change.property}\` requires new scopes: ${quoted(added)}.`, + }); }, }); diff --git a/packages/core/src/diff/rules/server-removed.ts b/packages/core/src/diff/rules/server-removed.ts index a8f07f74e2..4d8d118727 100644 --- a/packages/core/src/diff/rules/server-removed.ts +++ b/packages/core/src/diff/rules/server-removed.ts @@ -1,12 +1,30 @@ -import type { DiffRule } from '../types.js'; +import { fieldOf } from '../../node-tree/access.js'; +import { latestOf, typeOf } from '../diff-tree.js'; +import type { DiffNode, DiffRule } from '../types.js'; +import { nameOf } from './utils.js'; -// Dropping every server collapses into a single change on the map, dropping one lands on -// the server itself. +// An OpenAPI server sits in a list and is known by its URL; an AsyncAPI one by its key. +function describeServer(server: DiffNode): string { + const url = fieldOf(latestOf(server), 'url'); + return typeof url === 'string' ? url : nameOf(server); +} + +// Dropping every server collapses into a single change on the map or list, dropping one lands +// on the server itself. An OpenAPI path or operation that loses its own `servers` falls back to +// the document's, so only the document's list counts. export const ServerRemoved: DiffRule = () => ({ Server(change, { report }) { - if (change.kind === 'removed') report({ message: 'The server was removed.' }); + if (change.kind === 'removed') { + report({ message: `Server \`${describeServer(change.node)}\` was removed.` }); + } }, ServerMap(change, { report }) { - if (change.kind === 'removed') report({ message: 'Every server was removed.' }); + if (change.kind === 'removed') report({ message: 'All servers were removed.' }); + }, + ServerList(change, { report }) { + const isDocumentList = change.node.parent && typeOf(change.node.parent) === 'Root'; + if (change.kind === 'removed' && isDocumentList) { + report({ message: 'All servers were removed.' }); + } }, }); diff --git a/packages/core/src/diff/rules/utils.ts b/packages/core/src/diff/rules/utils.ts index f871cf75db..e095430377 100644 --- a/packages/core/src/diff/rules/utils.ts +++ b/packages/core/src/diff/rules/utils.ts @@ -1,4 +1,42 @@ -import type { DiffRule, Direction } from '../types.js'; +import { fieldOf } from '../../node-tree/access.js'; +import { latestOf, typeOf } from '../diff-tree.js'; +import type { DiffNode, DiffRule, Direction } from '../types.js'; + +/** What a node is called in its document: its key, or the key of the node a `$ref` item points at. */ +export function nameOf(node: DiffNode): string { + const entry = latestOf(node); + return String(entry.target?.key ?? entry.key); +} + +/** ` of `name`` for a property or a named component schema, and nothing for an inline one. */ +export function ofSchema(node: DiffNode | null | undefined): string { + const container = node?.parent && typeOf(node.parent); + if (container !== 'SchemaProperties' && container !== 'NamedSchemas') return ''; + return ` of \`${nameOf(node!)}\``; +} + +/** A parameter as a message names it, such as ``\`limit\` query parameter``. */ +export function describeParameter(node: DiffNode): string { + const location = fieldOf(latestOf(node), 'in'); + const name = fieldOf(latestOf(node), 'name'); + return [ + typeof name === 'string' ? `\`${name}\`` : undefined, + typeof location === 'string' ? location : undefined, + 'parameter', + ] + .filter(Boolean) + .join(' '); +} + +/** The values of a list, each quoted, for a message. */ +export function quoted(values: unknown[]): string { + return values.map((value) => `'${value}'`).join(', '); +} + +/** The names of a list, each in backticks, for a message. */ +export function named(values: unknown[]): string { + return values.map((value) => `\`${value}\``).join(', '); +} /** The items of `list` that `other` does not have. */ export function itemsOnlyIn(list: unknown, other: unknown): unknown[] { @@ -48,10 +86,11 @@ export function acceptsLess(constraint: string, before: unknown, after: unknown) return true; } -function describeConstraint(constraint: string, before: unknown, after: unknown): string { - if (before === undefined) return `\`${constraint}\` was added with value '${after}'.`; - if (after === undefined) return `\`${constraint}\` was removed.`; - return `\`${constraint}\` changed from '${before}' to '${after}'.`; +/** How a value moved, for a message: set where it was absent, dropped, or changed. */ +export function describeChange(subject: string, before: unknown, after: unknown): string { + if (before === undefined) return `${subject} was added with value '${after}'.`; + if (after === undefined) return `${subject} was removed.`; + return `${subject} changed from '${before}' to '${after}'.`; } /** @@ -66,7 +105,8 @@ export function constraintRule(constraints: string[]): DiffRule { const before = change.base.value; const after = change.revision.value; if (directions.includes(breakingDirection(acceptsLess(change.property, before, after)))) { - report({ message: describeConstraint(change.property, before, after) }); + const subject = `\`${change.property}\`${ofSchema(change.node)}`; + report({ message: describeChange(subject, before, after) }); } }, }); diff --git a/packages/core/src/diff/types.ts b/packages/core/src/diff/types.ts index f68778096d..0e7087d6ff 100644 --- a/packages/core/src/diff/types.ts +++ b/packages/core/src/diff/types.ts @@ -59,8 +59,10 @@ export type JudgedChange = Change & { impact: Impact; verdicts: RuleVerdict[] }; export type DiffSummary = Record; export type DiffResult = { - version: '1'; + files: { base: string; revision: string }; specVersions: { base: SpecVersion; revision: SpecVersion }; + /** The `info.version` each document declares, when it is a string. */ + infoVersions: { base?: string; revision?: string }; summary: DiffSummary; bump?: Impact; changes: JudgedChange[]; diff --git a/tests/e2e/diff/async3-channel-address-changed/snapshot.txt b/tests/e2e/diff/async3-channel-address-changed/snapshot.txt index cdc7e2f902..a52b6f7d4c 100644 --- a/tests/e2e/diff/async3-channel-address-changed/snapshot.txt +++ b/tests/e2e/diff/async3-channel-address-changed/snapshot.txt @@ -1,6 +1,6 @@ -channels - ✖ major modified userSignedUp · address - The channel address changed from 'user/signedup' to 'user/signed-up'. (channel-address-changed) +channel userSignedUp + ✖ major modified address + Channel `userSignedUp` address changed from 'user/signedup' to 'user/signed-up'. (channel-address-changed) at revision.yaml:7:14 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/async3-channel-removed/snapshot.txt b/tests/e2e/diff/async3-channel-removed/snapshot.txt index b7b7d08966..6d1722ccca 100644 --- a/tests/e2e/diff/async3-channel-removed/snapshot.txt +++ b/tests/e2e/diff/async3-channel-removed/snapshot.txt @@ -1,11 +1,11 @@ -channels - ✖ major removed userDeleted - The channel was removed. (channel-removed) +channel userDeleted + ✖ major removed + Channel `userDeleted` was removed. (channel-removed) at base.yaml:21:5 -operations - ✖ major removed onUserDeleted - Operation was removed. (operation-removed) +operation onUserDeleted + ✖ major removed + Operation `onUserDeleted` was removed. (operation-removed) at base.yaml:34:5 2 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt b/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt index 5af48c4949..d4c04fdf70 100644 --- a/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt +++ b/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt @@ -1,6 +1,6 @@ -channels - ✖ major modified userSignedUp/messages/signup · contentType - The message content type changed from 'application/json' to 'application/avro'. (message-content-type-changed) +channel userSignedUp + ✖ major modified messages/signup · contentType + Message `signup` content type changed from 'application/json' to 'application/avro'. (message-content-type-changed) at revision.yaml:10:22 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/async3-message-removed/snapshot.txt b/tests/e2e/diff/async3-message-removed/snapshot.txt index 806de7aee9..350eb6220a 100644 --- a/tests/e2e/diff/async3-message-removed/snapshot.txt +++ b/tests/e2e/diff/async3-message-removed/snapshot.txt @@ -1,6 +1,6 @@ -channels - ✖ major removed userSignedUp/messages/signout - The message was removed. (message-removed) +channel userSignedUp + ✖ major removed messages/signout + Message `signout` was removed. (message-removed) at base.yaml:21:9 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/async3-operation-action-changed/snapshot.txt b/tests/e2e/diff/async3-operation-action-changed/snapshot.txt index 43f61ffcbb..c3de0383c5 100644 --- a/tests/e2e/diff/async3-operation-action-changed/snapshot.txt +++ b/tests/e2e/diff/async3-operation-action-changed/snapshot.txt @@ -1,6 +1,6 @@ -operations - ✖ major modified onUserSignedUp · action - The operation action changed from 'receive' to 'send'. (operation-action-changed) +operation onUserSignedUp + ✖ major modified action + Operation `onUserSignedUp` action changed from 'receive' to 'send'. (operation-action-changed) at revision.yaml:22:13 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/async3-payload-property-removed/snapshot.txt b/tests/e2e/diff/async3-payload-property-removed/snapshot.txt index c1f4191fee..42bc958d2d 100644 --- a/tests/e2e/diff/async3-payload-property-removed/snapshot.txt +++ b/tests/e2e/diff/async3-payload-property-removed/snapshot.txt @@ -1,6 +1,6 @@ -channels - ✖ major removed userSignedUp/messages/signup/payload/properties/plan - Schema property was removed. (property-removed-from-response) +channel userSignedUp + ✖ major removed messages/signup/payload/properties/plan + Property `plan` was removed. (property-removed) at base.yaml:19:15 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/async3-payload-required-added/snapshot.txt b/tests/e2e/diff/async3-payload-required-added/snapshot.txt index dee53c2fe8..042beedafd 100644 --- a/tests/e2e/diff/async3-payload-required-added/snapshot.txt +++ b/tests/e2e/diff/async3-payload-required-added/snapshot.txt @@ -1,6 +1,6 @@ -channels - ✖ major modified userSignedUp/messages/signup/payload · required - Properties became required: plan. (required-properties-added) +channel userSignedUp + ✖ major modified messages/signup/payload · required + Properties became required: `plan`. (required-properties-added) at revision.yaml:14:13 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt b/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt index b671b8536a..c128cb0fea 100644 --- a/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt +++ b/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt @@ -1,5 +1,5 @@ -channels - · patch modified userSignedUp/messages/signup/payload · required +channel userSignedUp + · patch modified messages/signup/payload · required at revision.yaml:14:13 0 major, 0 minor, 1 patch. @@ -7,3 +7,4 @@ channels base.yaml vs revision.yaml: diff processed in ms +✅ Diff passed. diff --git a/tests/e2e/diff/async3-server-removed/snapshot.txt b/tests/e2e/diff/async3-server-removed/snapshot.txt index 9fd29b3854..de55e77f81 100644 --- a/tests/e2e/diff/async3-server-removed/snapshot.txt +++ b/tests/e2e/diff/async3-server-removed/snapshot.txt @@ -1,6 +1,6 @@ -servers - ✖ major removed staging - The server was removed. (server-removed) +server staging + ✖ major removed + Server `staging` was removed. (server-removed) at base.yaml:10:5 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/cli.test.ts b/tests/e2e/diff/cli.test.ts index 4e48fd6e17..05dccb91e3 100644 --- a/tests/e2e/diff/cli.test.ts +++ b/tests/e2e/diff/cli.test.ts @@ -38,11 +38,10 @@ describe('diff command', () => { expect(output).toContain('prints to stdout only'); }); - test('warns that --check-version was skipped when info.version is not semver', () => { - // Every diff fixture keeps a two-part `info.version` (e.g. '1.0'), so --check-version - // has nothing to compare and skips with a warning rather than passing judgment. + test('fails --check-version when info.version is not semver', () => { + // Every diff fixture keeps a two-part `info.version` (e.g. '1.0'), which is not semver. const output = runDiff('oas3-parameter-removed', '--check-version', '--fail-on=none'); - expect(output).toContain('--check-version was skipped'); + expect(output).toContain('--check-version cannot compare it'); }); test('warns that an unresolved reference leaves part of the description unjudged', () => { diff --git a/tests/e2e/diff/oas3-allof-ref-replaced/snapshot.txt b/tests/e2e/diff/oas3-allof-ref-replaced/snapshot.txt index a6c71935a6..8767d17755 100644 --- a/tests/e2e/diff/oas3-allof-ref-replaced/snapshot.txt +++ b/tests/e2e/diff/oas3-allof-ref-replaced/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major removed responses/200/content/application/json/schema/allOf/{#/components/schemas/Named} - A `allOf` subschema was removed. (schema-combinator-changed) + `allOf` subschema was removed. (schema-combinator-changed) at base.yaml:15:21 ✔ minor added responses/200/content/application/json/schema/allOf/{#/components/schemas/Tagged} at revision.yaml:15:21 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 index a9d7ffd234..99bd97e447 100644 --- a/tests/e2e/diff/oas3-enum-values-added-to-request/snapshot.txt +++ b/tests/e2e/diff/oas3-enum-values-added-to-request/snapshot.txt @@ -7,3 +7,4 @@ POST /pets base.yaml vs revision.yaml: diff processed in ms +✅ Diff passed. diff --git a/tests/e2e/diff/oas3-enum-values-added/snapshot.txt b/tests/e2e/diff/oas3-enum-values-added/snapshot.txt index 7cc4b19dec..7b7862a686 100644 --- a/tests/e2e/diff/oas3-enum-values-added/snapshot.txt +++ b/tests/e2e/diff/oas3-enum-values-added/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major modified responses/200/content/application/json/schema · enum - Enum values added: sold. (enum-values-added) + Enum gained values: 'sold'. (enum-values-added) at revision.yaml:15:23 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt b/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt index fe5dfd7ee7..dc88ccf0e1 100644 --- a/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major modified parameters/{query:sort}/schema · enum - Enum values removed: desc. (enum-values-removed) + Enum lost values: 'desc'. (enum-values-removed) at revision.yaml:14:15 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-media-type-removed/snapshot.txt b/tests/e2e/diff/oas3-media-type-removed/snapshot.txt index e41bca5081..f1e696eaf6 100644 --- a/tests/e2e/diff/oas3-media-type-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-media-type-removed/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major removed responses/200/content/application/xml - Media type was removed. (media-type-removed) + Media type `application/xml` was removed. (media-type-removed) at base.yaml:15:15 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-nullability-changed/snapshot.txt b/tests/e2e/diff/oas3-nullability-changed/snapshot.txt index 2a1c2a54f0..13f4f9a954 100644 --- a/tests/e2e/diff/oas3-nullability-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-nullability-changed/snapshot.txt @@ -1,6 +1,6 @@ POST /pets ✖ major modified requestBody/content/application/json/schema/properties/name · type - Schema type narrowed from 'string | null' to 'string'. (schema-type-changed) + Type of `name` narrowed from 'string | null' to 'string'. (schema-type-changed) at revision.yaml:15:25 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt index 5a4cd26906..65a0247333 100644 --- a/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt +++ b/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt @@ -1,4 +1,4 @@ -openapi +document · patch modified openapi at revision.yaml:1:10 @@ -13,3 +13,4 @@ POST /pets base.yaml vs revision.yaml: diff processed in ms +✅ Diff passed. diff --git a/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt b/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt index 128eccfeb4..8ba0af368c 100644 --- a/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt @@ -1,6 +1,6 @@ POST /pets ✖ major modified requestBody/content/application/json/schema/properties/age · minimum - `minimum` changed from '0' to '10'. (numeric-range-changed) + `minimum` of `age` changed from '0' to '10'. (numeric-range-changed) at revision.yaml:16:28 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-operation-removed/snapshot.txt b/tests/e2e/diff/oas3-operation-removed/snapshot.txt index 43c538de1c..1f90f346c8 100644 --- a/tests/e2e/diff/oas3-operation-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-operation-removed/snapshot.txt @@ -1,6 +1,6 @@ DELETE /pets - ✖ major removed paths · /pets · delete - Operation was removed. (operation-removed) + ✖ major removed + Operation `DELETE /pets` was removed. (operation-removed) at base.yaml:12:7 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-parameter-added-optional/snapshot.txt b/tests/e2e/diff/oas3-parameter-added-optional/snapshot.txt index 4848dc299b..f8fc9a32b4 100644 --- a/tests/e2e/diff/oas3-parameter-added-optional/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-added-optional/snapshot.txt @@ -7,3 +7,4 @@ GET /pets base.yaml vs revision.yaml: diff processed in ms +✅ Diff passed. diff --git a/tests/e2e/diff/oas3-parameter-added-required/snapshot.txt b/tests/e2e/diff/oas3-parameter-added-required/snapshot.txt index c0a2e03385..e718277e97 100644 --- a/tests/e2e/diff/oas3-parameter-added-required/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-added-required/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major added parameters - A new required parameter was added. (parameter-added-required) + Required parameters were added. (parameter-added-required) at revision.yaml:9:9 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt b/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt index f81f490298..7df06bf9bc 100644 --- a/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major modified parameters/{query:limit} · required - Parameter became required. (parameter-became-required) + `limit` query parameter became required. (parameter-became-required) at revision.yaml:11:21 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-parameter-ref-replaced/snapshot.txt b/tests/e2e/diff/oas3-parameter-ref-replaced/snapshot.txt index a18698e55b..7e583419e7 100644 --- a/tests/e2e/diff/oas3-parameter-ref-replaced/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-ref-replaced/snapshot.txt @@ -1,9 +1,9 @@ GET /pets ✖ major removed parameters/{query:limit} - Parameter was removed. (parameter-removed) + `limit` query parameter was removed. (parameter-removed) at base.yaml:9:11 ✖ major added parameters/{query:owner} - A new required parameter was added. (parameter-added-required) + Required `owner` query parameter was added. (parameter-added-required) at revision.yaml:9:11 2 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-parameter-refs-reordered/snapshot.txt b/tests/e2e/diff/oas3-parameter-refs-reordered/snapshot.txt index c8e7c5c153..65589573a0 100644 --- a/tests/e2e/diff/oas3-parameter-refs-reordered/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-refs-reordered/snapshot.txt @@ -3,3 +3,4 @@ base.yaml vs revision.yaml: diff processed in ms +✅ Diff passed. diff --git a/tests/e2e/diff/oas3-parameter-removed-last/snapshot.txt b/tests/e2e/diff/oas3-parameter-removed-last/snapshot.txt index 9692671a25..619534550b 100644 --- a/tests/e2e/diff/oas3-parameter-removed-last/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-removed-last/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major removed parameters - Every parameter was removed. (parameter-removed) + All parameters were removed. (parameter-removed) at base.yaml:9:9 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-parameter-removed/snapshot.txt b/tests/e2e/diff/oas3-parameter-removed/snapshot.txt index a9ad006048..edb5ddbfed 100644 --- a/tests/e2e/diff/oas3-parameter-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-removed/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major removed parameters/{query:limit} - Parameter was removed. (parameter-removed) + `limit` query parameter was removed. (parameter-removed) at base.yaml:9:11 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt b/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt index dcdc68559f..b20ff23265 100644 --- a/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major modified parameters/{query:tags} · style - Parameter `style` changed from 'form' to 'spaceDelimited'. (parameter-serialization-changed) + `style` of `tags` query parameter changed from 'form' to 'spaceDelimited'. (parameter-serialization-changed) at revision.yaml:11:18 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt b/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt index 36cb9cbb3b..661ced879c 100644 --- a/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt +++ b/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt @@ -11,3 +11,4 @@ GET /pets/{petId} base.yaml vs revision.yaml: diff processed in ms +✅ Diff passed. diff --git a/tests/e2e/diff/oas3-path-removed/snapshot.txt b/tests/e2e/diff/oas3-path-removed/snapshot.txt index 4b52ab3bfc..4fc2c4f7d6 100644 --- a/tests/e2e/diff/oas3-path-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-path-removed/snapshot.txt @@ -1,6 +1,6 @@ /pets/{petId} - ✖ major removed paths · /pets/{petId} - Path was removed. (path-removed) + ✖ major removed + Path `/pets/{petId}` was removed. (path-removed) at base.yaml:11:5 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-property-removed-from-response/base.yaml b/tests/e2e/diff/oas3-property-removed/base.yaml similarity index 90% rename from tests/e2e/diff/oas3-property-removed-from-response/base.yaml rename to tests/e2e/diff/oas3-property-removed/base.yaml index 425629b615..166dda32ca 100644 --- a/tests/e2e/diff/oas3-property-removed-from-response/base.yaml +++ b/tests/e2e/diff/oas3-property-removed/base.yaml @@ -1,6 +1,6 @@ openapi: 3.1.0 info: - title: property-removed-from-response + title: property-removed version: '1.0' paths: /pets: diff --git a/tests/e2e/diff/oas3-property-removed-from-response/revision.yaml b/tests/e2e/diff/oas3-property-removed/revision.yaml similarity index 88% rename from tests/e2e/diff/oas3-property-removed-from-response/revision.yaml rename to tests/e2e/diff/oas3-property-removed/revision.yaml index 3fd7e36afd..c4484bc1c8 100644 --- a/tests/e2e/diff/oas3-property-removed-from-response/revision.yaml +++ b/tests/e2e/diff/oas3-property-removed/revision.yaml @@ -1,6 +1,6 @@ openapi: 3.1.0 info: - title: property-removed-from-response + title: property-removed version: '1.0' paths: /pets: diff --git a/tests/e2e/diff/oas3-property-removed-from-response/snapshot.txt b/tests/e2e/diff/oas3-property-removed/snapshot.txt similarity index 78% rename from tests/e2e/diff/oas3-property-removed-from-response/snapshot.txt rename to tests/e2e/diff/oas3-property-removed/snapshot.txt index 2a5142e08a..51293630f2 100644 --- a/tests/e2e/diff/oas3-property-removed-from-response/snapshot.txt +++ b/tests/e2e/diff/oas3-property-removed/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major removed responses/200/content/application/json/schema/properties/tag - Schema property was removed. (property-removed-from-response) + Property `tag` was removed. (property-removed) at base.yaml:19:21 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt b/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt index 0feccb60af..8eb61fb37a 100644 --- a/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major modified responses/200/content/application/json/schema · $ref - 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) + 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:23 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt b/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt index 310c52a458..091a997861 100644 --- a/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt +++ b/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt @@ -1,6 +1,6 @@ POST /pets ✖ major modified requestBody · required - The request body became required. (request-body-became-required) + Request body became required. (request-body-became-required) at revision.yaml:9:19 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-request-body-removed/snapshot.txt b/tests/e2e/diff/oas3-request-body-removed/snapshot.txt index b0f72ac448..fc0be6af8f 100644 --- a/tests/e2e/diff/oas3-request-body-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-request-body-removed/snapshot.txt @@ -1,6 +1,6 @@ POST /pets ✖ major removed requestBody - The request body was removed. (request-body-removed) + Request body was removed. (request-body-removed) at base.yaml:9:9 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-required-properties-added/snapshot.txt b/tests/e2e/diff/oas3-required-properties-added/snapshot.txt index b771ceab62..91dcddee94 100644 --- a/tests/e2e/diff/oas3-required-properties-added/snapshot.txt +++ b/tests/e2e/diff/oas3-required-properties-added/snapshot.txt @@ -1,6 +1,6 @@ POST /pets ✖ major modified requestBody/content/application/json/schema · required - Properties became required: name. (required-properties-added) + Properties became required: `name`. (required-properties-added) at revision.yaml:13:25 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt b/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt index 28fb6d288f..506ce00145 100644 --- a/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major modified responses/200/content/application/json/schema · required - Properties are no longer required: name. (required-properties-removed) + Properties are no longer required: `name`. (required-properties-removed) at revision.yaml:15:27 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-response-header-removed/snapshot.txt b/tests/e2e/diff/oas3-response-header-removed/snapshot.txt index 6b56a015b7..e758b7e0df 100644 --- a/tests/e2e/diff/oas3-response-header-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-response-header-removed/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major removed responses/200/headers - The response headers were removed. (response-header-removed) + All headers of response `200` were removed. (response-header-removed) at base.yaml:12:13 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-response-removed/snapshot.txt b/tests/e2e/diff/oas3-response-removed/snapshot.txt index 6f1c0fe39f..492f6e2ad7 100644 --- a/tests/e2e/diff/oas3-response-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-response-removed/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major removed responses/404 - Response was removed. (response-removed) + Response `404` was removed. (response-removed) at base.yaml:10:16 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt b/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt index e7a7839629..2887cd38a6 100644 --- a/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt @@ -1,6 +1,6 @@ POST /pets ✖ major removed requestBody/content/application/json/schema/oneOf/1 - A `oneOf` subschema was removed. (schema-combinator-changed) + `oneOf` subschema was removed. (schema-combinator-changed) at base.yaml:14:19 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt b/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt index 3782b94ea4..3e60a68651 100644 --- a/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt @@ -1,6 +1,6 @@ POST /pets ✖ major modified requestBody/content/application/json/schema/properties/id · format - `format` was added with value 'uuid'. (schema-format-changed) + `format` of `id` was added with value 'uuid'. (schema-format-changed) at revision.yaml:16:27 1 major, 0 minor, 0 patch. 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 index ee2ba0e70f..5068eb9397 100644 --- a/tests/e2e/diff/oas3-schema-type-widened-in-request/snapshot.txt +++ b/tests/e2e/diff/oas3-schema-type-widened-in-request/snapshot.txt @@ -7,3 +7,4 @@ POST /pets base.yaml vs revision.yaml: diff processed in ms +✅ Diff passed. 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 index 8ccc0cad60..9720d18396 100644 --- 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 @@ -1,6 +1,6 @@ GET /pets ✖ major added security/{apiKey} - The API now requires authentication. (security-requirement-added) + Authentication became required. (security-requirement-added) at revision.yaml:9:11 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-security-requirement-added/snapshot.txt b/tests/e2e/diff/oas3-security-requirement-added/snapshot.txt index 635507d7bb..57da7e57f6 100644 --- a/tests/e2e/diff/oas3-security-requirement-added/snapshot.txt +++ b/tests/e2e/diff/oas3-security-requirement-added/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major added security - The API now requires authentication. (security-requirement-added) + Authentication became required. (security-requirement-added) at revision.yaml:9:9 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt b/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt index e229290eaf..a9f173af61 100644 --- a/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt @@ -1,6 +1,6 @@ components ✖ major modified securitySchemes/apiKey · type - Security scheme `type` changed from 'apiKey' to 'http'. (security-scheme-changed) + `type` of security scheme `apiKey` changed from 'apiKey' to 'http'. (security-scheme-changed) at revision.yaml:16:13 · patch modified securitySchemes/apiKey · in at revision.yaml:16:7 diff --git a/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt b/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt index 16dda27320..10bb81faed 100644 --- a/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt @@ -1,6 +1,6 @@ components ✖ major removed securitySchemes/basicAuth - A security scheme was removed. (security-scheme-removed) + Security scheme `basicAuth` was removed. (security-scheme-removed) at base.yaml:17:7 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt b/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt index ef6858f203..6ed4b7f1a4 100644 --- a/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt +++ b/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt @@ -1,6 +1,6 @@ GET /pets ✖ major modified security/{oauth} · oauth - Scheme `oauth` requires new scopes: pets:write. (security-scopes-added) + Security scheme `oauth` requires new scopes: 'pets:write'. (security-scopes-added) at revision.yaml:10:13 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-string-length-changed/snapshot.txt b/tests/e2e/diff/oas3-string-length-changed/snapshot.txt index fb95f97030..26ff6f62ea 100644 --- a/tests/e2e/diff/oas3-string-length-changed/snapshot.txt +++ b/tests/e2e/diff/oas3-string-length-changed/snapshot.txt @@ -1,6 +1,6 @@ POST /pets ✖ major modified requestBody/content/application/json/schema/properties/name · maxLength - `maxLength` changed from '100' to '10'. (string-length-changed) + `maxLength` of `name` changed from '100' to '10'. (string-length-changed) at revision.yaml:16:30 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt b/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt index d5e50cef99..8f90dffaf5 100644 --- a/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt +++ b/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt @@ -1,6 +1,6 @@ -webhooks - ✖ major removed newPet/post/requestBody/content/application/json/schema/properties/tag - Schema property was removed. (property-removed-from-response) +POST newPet (webhook) + ✖ major removed requestBody/content/application/json/schema/properties/tag + Property `tag` was removed. (property-removed) at base.yaml:18:19 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/rules.test.ts b/tests/e2e/diff/rules.test.ts index 3deb567962..9a693844fc 100644 --- a/tests/e2e/diff/rules.test.ts +++ b/tests/e2e/diff/rules.test.ts @@ -72,14 +72,14 @@ const OPENAPI: RuleCase[] = [ describes: 'the request body disappears', }, { - fixture: 'oas3-property-removed-from-response', - ruleId: 'property-removed-from-response', + fixture: 'oas3-property-removed', + ruleId: 'property-removed', 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', + ruleId: 'property-removed', describes: 'a webhook payload drops a property', }, { @@ -213,7 +213,7 @@ const ASYNCAPI: RuleCase[] = [ }, { fixture: 'async3-payload-property-removed', - ruleId: 'property-removed-from-response', + ruleId: 'property-removed', describes: 'a sent payload drops a property', }, ]; From 522aa7931736f23264ae5946f87315f5c7c903e2 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:06:03 +0300 Subject: [PATCH 49/57] test(diff): replace the per-rule e2e fixtures with user stories across formats, specifications, configuration and plugins --- .../async3-channel-address-changed/base.yaml | 26 -- .../revision.yaml | 26 -- .../snapshot.txt | 11 - .../e2e/diff/async3-channel-removed/base.yaml | 36 --- .../diff/async3-channel-removed/revision.yaml | 26 -- .../diff/async3-channel-removed/snapshot.txt | 16 - .../base.yaml | 26 -- .../revision.yaml | 26 -- .../snapshot.txt | 11 - .../e2e/diff/async3-message-removed/base.yaml | 29 -- .../diff/async3-message-removed/revision.yaml | 26 -- .../diff/async3-message-removed/snapshot.txt | 11 - .../async3-operation-action-changed/base.yaml | 26 -- .../revision.yaml | 26 -- .../snapshot.txt | 11 - .../async3-payload-property-removed/base.yaml | 26 -- .../revision.yaml | 24 -- .../snapshot.txt | 11 - .../async3-payload-required-added/base.yaml | 26 -- .../revision.yaml | 27 -- .../snapshot.txt | 11 - .../base.yaml | 26 -- .../revision.yaml | 27 -- .../snapshot.txt | 10 - .../e2e/diff/async3-server-removed/base.yaml | 33 -- .../diff/async3-server-removed/revision.yaml | 30 -- tests/e2e/diff/asyncapi3/base.yaml | 36 +++ tests/e2e/diff/asyncapi3/revision.yaml | 28 ++ tests/e2e/diff/asyncapi3/snapshot.txt | 42 +++ tests/e2e/diff/cli.test.ts | 57 ---- tests/e2e/diff/config-file/base.yaml | 14 + tests/e2e/diff/config-file/redocly.yaml | 2 + tests/e2e/diff/config-file/relaxed.yaml | 7 + tests/e2e/diff/config-file/revision.yaml | 14 + tests/e2e/diff/config-file/snapshot.txt | 26 ++ tests/e2e/diff/configuration/base.yaml | 65 ++++ tests/e2e/diff/configuration/redocly.yaml | 12 + tests/e2e/diff/configuration/revision.yaml | 62 ++++ .../diff/configuration/snapshot-skip-rule.txt | 59 ++++ tests/e2e/diff/configuration/snapshot.txt | 62 ++++ tests/e2e/diff/cross-family/base.yaml | 5 - tests/e2e/diff/cross-family/revision.yaml | 5 - tests/e2e/diff/diff.test.ts | 187 ++++++++++++ tests/e2e/diff/github-actions/base.yaml | 14 + tests/e2e/diff/github-actions/revision.yaml | 14 + .../diff/github-actions/snapshot-output.txt | 3 + tests/e2e/diff/github-actions/snapshot.txt | 7 + tests/e2e/diff/helpers.ts | 36 --- .../e2e/diff/json-and-next-version/base.yaml | 14 + .../diff/json-and-next-version/revision.yaml | 14 + .../json-and-next-version/snapshot-json.txt | 161 ++++++++++ .../snapshot-next-version.txt} | 4 +- tests/e2e/diff/markdown-and-html/base.yaml | 14 + .../e2e/diff/markdown-and-html/revision.yaml | 14 + .../diff/markdown-and-html/snapshot-html.txt | 158 ++++++++++ .../markdown-and-html/snapshot-markdown.txt | 11 + .../base.yaml | 19 -- .../revision.yaml | 19 -- .../snapshot.txt | 11 - .../diff/oas3-allof-ref-replaced/base.yaml | 25 -- .../oas3-allof-ref-replaced/revision.yaml | 25 -- .../diff/oas3-allof-ref-replaced/snapshot.txt | 13 - .../e2e/diff/oas3-breaking-changes/base.yaml | 33 -- .../diff/oas3-breaking-changes/revision.yaml | 28 -- .../base.yaml | 15 - .../revision.yaml | 15 - .../snapshot.txt | 10 - .../e2e/diff/oas3-enum-values-added/base.yaml | 15 - .../diff/oas3-enum-values-added/revision.yaml | 15 - .../diff/oas3-enum-values-added/snapshot.txt | 11 - .../diff/oas3-enum-values-removed/base.yaml | 18 -- .../oas3-enum-values-removed/revision.yaml | 17 -- .../oas3-enum-values-removed/snapshot.txt | 11 - .../diff/oas3-media-type-removed/base.yaml | 15 - .../oas3-media-type-removed/revision.yaml | 13 - .../diff/oas3-media-type-removed/snapshot.txt | 11 - .../diff/oas3-nullability-changed/base.yaml | 20 -- .../oas3-nullability-changed/revision.yaml | 18 -- .../oas3-nullability-changed/snapshot.txt | 11 - .../base.yaml | 19 -- .../revision.yaml | 20 -- .../snapshot.txt | 16 - .../diff/oas3-numeric-range-changed/base.yaml | 19 -- .../oas3-numeric-range-changed/revision.yaml | 19 -- .../oas3-numeric-range-changed/snapshot.txt | 11 - .../e2e/diff/oas3-operation-removed/base.yaml | 14 - .../diff/oas3-operation-removed/revision.yaml | 10 - .../diff/oas3-operation-removed/snapshot.txt | 11 - .../oas3-parameter-added-optional/base.yaml | 9 - .../revision.yaml | 13 - .../snapshot.txt | 10 - .../revision.yaml | 14 - .../snapshot.txt | 11 - .../oas3-parameter-became-required/base.yaml | 15 - .../revision.yaml | 16 - .../snapshot.txt | 11 - .../oas3-parameter-ref-replaced/base.yaml | 23 -- .../oas3-parameter-ref-replaced/revision.yaml | 23 -- .../oas3-parameter-ref-replaced/snapshot.txt | 14 - .../oas3-parameter-refs-reordered/base.yaml | 24 -- .../revision.yaml | 24 -- .../oas3-parameter-removed-last/base.yaml | 13 - .../oas3-parameter-removed-last/revision.yaml | 9 - .../oas3-parameter-removed-last/snapshot.txt | 11 - .../e2e/diff/oas3-parameter-removed/base.yaml | 16 - .../diff/oas3-parameter-removed/revision.yaml | 13 - .../diff/oas3-parameter-removed/snapshot.txt | 11 - .../base.yaml | 18 -- .../revision.yaml | 18 -- .../snapshot.txt | 11 - .../oas3-path-parameter-renamed/base.yaml | 14 - .../oas3-path-parameter-renamed/revision.yaml | 14 - .../oas3-path-parameter-renamed/snapshot.txt | 14 - tests/e2e/diff/oas3-path-removed/base.yaml | 18 -- tests/e2e/diff/oas3-path-removed/snapshot.txt | 11 - .../e2e/diff/oas3-property-removed/base.yaml | 19 -- .../diff/oas3-property-removed/revision.yaml | 17 -- .../diff/oas3-property-removed/snapshot.txt | 11 - .../diff/oas3-ref-target-changed/base.yaml | 24 -- .../oas3-ref-target-changed/revision.yaml | 24 -- .../diff/oas3-ref-target-changed/snapshot.txt | 11 - .../base.yaml | 18 -- .../revision.yaml | 19 -- .../snapshot.txt | 11 - .../diff/oas3-request-body-removed/base.yaml | 18 -- .../oas3-request-body-removed/revision.yaml | 10 - .../oas3-request-body-removed/snapshot.txt | 11 - .../oas3-required-properties-added/base.yaml | 18 -- .../revision.yaml | 18 -- .../snapshot.txt | 11 - .../base.yaml | 18 -- .../revision.yaml | 18 -- .../snapshot.txt | 11 - .../oas3-response-header-removed/base.yaml | 14 - .../revision.yaml | 10 - .../oas3-response-header-removed/snapshot.txt | 11 - .../e2e/diff/oas3-response-removed/base.yaml | 10 - .../diff/oas3-response-removed/revision.yaml | 9 - .../diff/oas3-response-removed/snapshot.txt | 11 - .../oas3-schema-combinator-changed/base.yaml | 17 -- .../revision.yaml | 16 - .../snapshot.txt | 11 - .../diff/oas3-schema-format-changed/base.yaml | 18 -- .../oas3-schema-format-changed/revision.yaml | 19 -- .../oas3-schema-format-changed/snapshot.txt | 11 - .../base.yaml | 18 -- .../revision.yaml | 20 -- .../snapshot.txt | 10 - .../base.yaml | 17 -- .../revision.yaml | 18 -- .../snapshot.txt | 11 - .../oas3-security-requirement-added/base.yaml | 16 - .../revision.yaml | 18 -- .../snapshot.txt | 11 - .../oas3-security-scheme-changed/base.yaml | 18 -- .../revision.yaml | 17 -- .../oas3-security-scheme-changed/snapshot.txt | 17 -- .../oas3-security-scheme-removed/base.yaml | 18 -- .../revision.yaml | 15 - .../oas3-security-scheme-removed/snapshot.txt | 11 - .../diff/oas3-security-scopes-added/base.yaml | 23 -- .../oas3-security-scopes-added/revision.yaml | 24 -- .../oas3-security-scopes-added/snapshot.txt | 11 - .../diff/oas3-string-length-changed/base.yaml | 19 -- .../oas3-string-length-changed/revision.yaml | 19 -- .../oas3-string-length-changed/snapshot.txt | 11 - .../base.yaml | 21 -- .../revision.yaml | 19 -- .../snapshot.txt | 11 - tests/e2e/diff/openapi-3-0-to-3-1/base.yaml | 21 ++ .../e2e/diff/openapi-3-0-to-3-1/revision.yaml | 21 ++ .../e2e/diff/openapi-3-0-to-3-1/snapshot.txt | 16 + .../diff/openapi-breaking-changes/base.yaml | 65 ++++ .../openapi-breaking-changes/revision.yaml | 62 ++++ .../snapshot-check-version.txt | 63 ++++ .../openapi-breaking-changes/snapshot.txt | 63 ++++ .../diff/openapi-compatible-changes/base.yaml | 65 ++++ .../openapi-compatible-changes/revision.yaml | 70 +++++ .../snapshot-fail-on-minor.txt | 18 ++ .../openapi-compatible-changes/snapshot.txt | 18 ++ .../base.yaml | 7 +- tests/e2e/diff/plugin/plugins/cafe.cjs | 18 ++ tests/e2e/diff/plugin/redocly.yaml | 6 + .../revision.yaml | 7 +- .../snapshot.txt | 8 +- tests/e2e/diff/rules.test.ts | 284 ------------------ tests/e2e/diff/unresolved-ref/base.yaml | 19 -- tests/e2e/diff/unresolved-ref/revision.yaml | 19 -- 188 files changed, 1558 insertions(+), 2785 deletions(-) delete mode 100644 tests/e2e/diff/async3-channel-address-changed/base.yaml delete mode 100644 tests/e2e/diff/async3-channel-address-changed/revision.yaml delete mode 100644 tests/e2e/diff/async3-channel-address-changed/snapshot.txt delete mode 100644 tests/e2e/diff/async3-channel-removed/base.yaml delete mode 100644 tests/e2e/diff/async3-channel-removed/revision.yaml delete mode 100644 tests/e2e/diff/async3-channel-removed/snapshot.txt delete mode 100644 tests/e2e/diff/async3-message-content-type-changed/base.yaml delete mode 100644 tests/e2e/diff/async3-message-content-type-changed/revision.yaml delete mode 100644 tests/e2e/diff/async3-message-content-type-changed/snapshot.txt delete mode 100644 tests/e2e/diff/async3-message-removed/base.yaml delete mode 100644 tests/e2e/diff/async3-message-removed/revision.yaml delete mode 100644 tests/e2e/diff/async3-message-removed/snapshot.txt delete mode 100644 tests/e2e/diff/async3-operation-action-changed/base.yaml delete mode 100644 tests/e2e/diff/async3-operation-action-changed/revision.yaml delete mode 100644 tests/e2e/diff/async3-operation-action-changed/snapshot.txt delete mode 100644 tests/e2e/diff/async3-payload-property-removed/base.yaml delete mode 100644 tests/e2e/diff/async3-payload-property-removed/revision.yaml delete mode 100644 tests/e2e/diff/async3-payload-property-removed/snapshot.txt delete mode 100644 tests/e2e/diff/async3-payload-required-added/base.yaml delete mode 100644 tests/e2e/diff/async3-payload-required-added/revision.yaml delete mode 100644 tests/e2e/diff/async3-payload-required-added/snapshot.txt delete mode 100644 tests/e2e/diff/async3-sent-payload-required-added/base.yaml delete mode 100644 tests/e2e/diff/async3-sent-payload-required-added/revision.yaml delete mode 100644 tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt delete mode 100644 tests/e2e/diff/async3-server-removed/base.yaml delete mode 100644 tests/e2e/diff/async3-server-removed/revision.yaml create mode 100644 tests/e2e/diff/asyncapi3/base.yaml create mode 100644 tests/e2e/diff/asyncapi3/revision.yaml create mode 100644 tests/e2e/diff/asyncapi3/snapshot.txt delete mode 100644 tests/e2e/diff/cli.test.ts create mode 100644 tests/e2e/diff/config-file/base.yaml create mode 100644 tests/e2e/diff/config-file/redocly.yaml create mode 100644 tests/e2e/diff/config-file/relaxed.yaml create mode 100644 tests/e2e/diff/config-file/revision.yaml create mode 100644 tests/e2e/diff/config-file/snapshot.txt create mode 100644 tests/e2e/diff/configuration/base.yaml create mode 100644 tests/e2e/diff/configuration/redocly.yaml create mode 100644 tests/e2e/diff/configuration/revision.yaml create mode 100644 tests/e2e/diff/configuration/snapshot-skip-rule.txt create mode 100644 tests/e2e/diff/configuration/snapshot.txt delete mode 100644 tests/e2e/diff/cross-family/base.yaml delete mode 100644 tests/e2e/diff/cross-family/revision.yaml create mode 100644 tests/e2e/diff/diff.test.ts create mode 100644 tests/e2e/diff/github-actions/base.yaml create mode 100644 tests/e2e/diff/github-actions/revision.yaml create mode 100644 tests/e2e/diff/github-actions/snapshot-output.txt create mode 100644 tests/e2e/diff/github-actions/snapshot.txt delete mode 100644 tests/e2e/diff/helpers.ts create mode 100644 tests/e2e/diff/json-and-next-version/base.yaml create mode 100644 tests/e2e/diff/json-and-next-version/revision.yaml create mode 100644 tests/e2e/diff/json-and-next-version/snapshot-json.txt rename tests/e2e/diff/{oas3-parameter-refs-reordered/snapshot.txt => json-and-next-version/snapshot-next-version.txt} (56%) create mode 100644 tests/e2e/diff/markdown-and-html/base.yaml create mode 100644 tests/e2e/diff/markdown-and-html/revision.yaml create mode 100644 tests/e2e/diff/markdown-and-html/snapshot-html.txt create mode 100644 tests/e2e/diff/markdown-and-html/snapshot-markdown.txt delete mode 100644 tests/e2e/diff/oas3-additional-properties-changed/base.yaml delete mode 100644 tests/e2e/diff/oas3-additional-properties-changed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-allof-ref-replaced/base.yaml delete mode 100644 tests/e2e/diff/oas3-allof-ref-replaced/revision.yaml delete mode 100644 tests/e2e/diff/oas3-allof-ref-replaced/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-breaking-changes/base.yaml delete mode 100644 tests/e2e/diff/oas3-breaking-changes/revision.yaml delete mode 100644 tests/e2e/diff/oas3-enum-values-added-to-request/base.yaml delete mode 100644 tests/e2e/diff/oas3-enum-values-added-to-request/revision.yaml delete mode 100644 tests/e2e/diff/oas3-enum-values-added-to-request/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-enum-values-added/base.yaml delete mode 100644 tests/e2e/diff/oas3-enum-values-added/revision.yaml delete mode 100644 tests/e2e/diff/oas3-enum-values-added/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-enum-values-removed/base.yaml delete mode 100644 tests/e2e/diff/oas3-enum-values-removed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-enum-values-removed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-media-type-removed/base.yaml delete mode 100644 tests/e2e/diff/oas3-media-type-removed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-media-type-removed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-nullability-changed/base.yaml delete mode 100644 tests/e2e/diff/oas3-nullability-changed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-nullability-changed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-nullable-equivalence-across-versions/base.yaml delete mode 100644 tests/e2e/diff/oas3-nullable-equivalence-across-versions/revision.yaml delete mode 100644 tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-numeric-range-changed/base.yaml delete mode 100644 tests/e2e/diff/oas3-numeric-range-changed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-operation-removed/base.yaml delete mode 100644 tests/e2e/diff/oas3-operation-removed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-operation-removed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-parameter-added-optional/base.yaml delete mode 100644 tests/e2e/diff/oas3-parameter-added-optional/revision.yaml delete mode 100644 tests/e2e/diff/oas3-parameter-added-optional/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-parameter-added-required/revision.yaml delete mode 100644 tests/e2e/diff/oas3-parameter-added-required/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-parameter-became-required/base.yaml delete mode 100644 tests/e2e/diff/oas3-parameter-became-required/revision.yaml delete mode 100644 tests/e2e/diff/oas3-parameter-became-required/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-parameter-ref-replaced/base.yaml delete mode 100644 tests/e2e/diff/oas3-parameter-ref-replaced/revision.yaml delete mode 100644 tests/e2e/diff/oas3-parameter-ref-replaced/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-parameter-refs-reordered/base.yaml delete mode 100644 tests/e2e/diff/oas3-parameter-refs-reordered/revision.yaml delete mode 100644 tests/e2e/diff/oas3-parameter-removed-last/base.yaml delete mode 100644 tests/e2e/diff/oas3-parameter-removed-last/revision.yaml delete mode 100644 tests/e2e/diff/oas3-parameter-removed-last/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-parameter-removed/base.yaml delete mode 100644 tests/e2e/diff/oas3-parameter-removed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-parameter-removed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-parameter-serialization-changed/base.yaml delete mode 100644 tests/e2e/diff/oas3-parameter-serialization-changed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-path-parameter-renamed/base.yaml delete mode 100644 tests/e2e/diff/oas3-path-parameter-renamed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-path-removed/base.yaml delete mode 100644 tests/e2e/diff/oas3-path-removed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-property-removed/base.yaml delete mode 100644 tests/e2e/diff/oas3-property-removed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-property-removed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-ref-target-changed/base.yaml delete mode 100644 tests/e2e/diff/oas3-ref-target-changed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-ref-target-changed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-request-body-became-required/base.yaml delete mode 100644 tests/e2e/diff/oas3-request-body-became-required/revision.yaml delete mode 100644 tests/e2e/diff/oas3-request-body-became-required/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-request-body-removed/base.yaml delete mode 100644 tests/e2e/diff/oas3-request-body-removed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-request-body-removed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-required-properties-added/base.yaml delete mode 100644 tests/e2e/diff/oas3-required-properties-added/revision.yaml delete mode 100644 tests/e2e/diff/oas3-required-properties-added/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-required-properties-removed/base.yaml delete mode 100644 tests/e2e/diff/oas3-required-properties-removed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-required-properties-removed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-response-header-removed/base.yaml delete mode 100644 tests/e2e/diff/oas3-response-header-removed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-response-header-removed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-response-removed/base.yaml delete mode 100644 tests/e2e/diff/oas3-response-removed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-response-removed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-schema-combinator-changed/base.yaml delete mode 100644 tests/e2e/diff/oas3-schema-combinator-changed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-schema-format-changed/base.yaml delete mode 100644 tests/e2e/diff/oas3-schema-format-changed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-schema-format-changed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-schema-type-widened-in-request/base.yaml delete mode 100644 tests/e2e/diff/oas3-schema-type-widened-in-request/revision.yaml delete mode 100644 tests/e2e/diff/oas3-schema-type-widened-in-request/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-security-requirement-added-to-empty-list/base.yaml delete mode 100644 tests/e2e/diff/oas3-security-requirement-added-to-empty-list/revision.yaml delete mode 100644 tests/e2e/diff/oas3-security-requirement-added-to-empty-list/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-security-requirement-added/base.yaml delete mode 100644 tests/e2e/diff/oas3-security-requirement-added/revision.yaml delete mode 100644 tests/e2e/diff/oas3-security-requirement-added/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-security-scheme-changed/base.yaml delete mode 100644 tests/e2e/diff/oas3-security-scheme-changed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-security-scheme-removed/base.yaml delete mode 100644 tests/e2e/diff/oas3-security-scheme-removed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-security-scopes-added/base.yaml delete mode 100644 tests/e2e/diff/oas3-security-scopes-added/revision.yaml delete mode 100644 tests/e2e/diff/oas3-security-scopes-added/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-string-length-changed/base.yaml delete mode 100644 tests/e2e/diff/oas3-string-length-changed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-string-length-changed/snapshot.txt delete mode 100644 tests/e2e/diff/oas3-webhook-payload-property-removed/base.yaml delete mode 100644 tests/e2e/diff/oas3-webhook-payload-property-removed/revision.yaml delete mode 100644 tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt create mode 100644 tests/e2e/diff/openapi-3-0-to-3-1/base.yaml create mode 100644 tests/e2e/diff/openapi-3-0-to-3-1/revision.yaml create mode 100644 tests/e2e/diff/openapi-3-0-to-3-1/snapshot.txt create mode 100644 tests/e2e/diff/openapi-breaking-changes/base.yaml create mode 100644 tests/e2e/diff/openapi-breaking-changes/revision.yaml create mode 100644 tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt create mode 100644 tests/e2e/diff/openapi-breaking-changes/snapshot.txt create mode 100644 tests/e2e/diff/openapi-compatible-changes/base.yaml create mode 100644 tests/e2e/diff/openapi-compatible-changes/revision.yaml create mode 100644 tests/e2e/diff/openapi-compatible-changes/snapshot-fail-on-minor.txt create mode 100644 tests/e2e/diff/openapi-compatible-changes/snapshot.txt rename tests/e2e/diff/{oas3-parameter-added-required => plugin}/base.yaml (52%) create mode 100644 tests/e2e/diff/plugin/plugins/cafe.cjs create mode 100644 tests/e2e/diff/plugin/redocly.yaml rename tests/e2e/diff/{oas3-path-removed => plugin}/revision.yaml (55%) rename tests/e2e/diff/{async3-server-removed => plugin}/snapshot.txt (52%) delete mode 100644 tests/e2e/diff/rules.test.ts delete mode 100644 tests/e2e/diff/unresolved-ref/base.yaml delete mode 100644 tests/e2e/diff/unresolved-ref/revision.yaml diff --git a/tests/e2e/diff/async3-channel-address-changed/base.yaml b/tests/e2e/diff/async3-channel-address-changed/base.yaml deleted file mode 100644 index 6a91e5a603..0000000000 --- a/tests/e2e/diff/async3-channel-address-changed/base.yaml +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index b4f404c2cd..0000000000 --- a/tests/e2e/diff/async3-channel-address-changed/revision.yaml +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index a52b6f7d4c..0000000000 --- a/tests/e2e/diff/async3-channel-address-changed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -channel userSignedUp - ✖ major modified address - Channel `userSignedUp` address changed from 'user/signedup' to 'user/signed-up'. (channel-address-changed) - at revision.yaml:7:14 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/async3-channel-removed/base.yaml b/tests/e2e/diff/async3-channel-removed/base.yaml deleted file mode 100644 index 30c1077f53..0000000000 --- a/tests/e2e/diff/async3-channel-removed/base.yaml +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 63b85d9f7f..0000000000 --- a/tests/e2e/diff/async3-channel-removed/revision.yaml +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 6d1722ccca..0000000000 --- a/tests/e2e/diff/async3-channel-removed/snapshot.txt +++ /dev/null @@ -1,16 +0,0 @@ -channel userDeleted - ✖ major removed - Channel `userDeleted` was removed. (channel-removed) - at base.yaml:21:5 - -operation onUserDeleted - ✖ major removed - Operation `onUserDeleted` was removed. (operation-removed) - at base.yaml:34:5 - -2 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 2 major 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 deleted file mode 100644 index 0eb9420dae..0000000000 --- a/tests/e2e/diff/async3-message-content-type-changed/base.yaml +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 984e2d39e3..0000000000 --- a/tests/e2e/diff/async3-message-content-type-changed/revision.yaml +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index d4c04fdf70..0000000000 --- a/tests/e2e/diff/async3-message-content-type-changed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -channel userSignedUp - ✖ major modified messages/signup · contentType - Message `signup` content type changed from 'application/json' to 'application/avro'. (message-content-type-changed) - at revision.yaml:10:22 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/async3-message-removed/base.yaml b/tests/e2e/diff/async3-message-removed/base.yaml deleted file mode 100644 index 1987ea964d..0000000000 --- a/tests/e2e/diff/async3-message-removed/base.yaml +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index e8d79ec952..0000000000 --- a/tests/e2e/diff/async3-message-removed/revision.yaml +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 350eb6220a..0000000000 --- a/tests/e2e/diff/async3-message-removed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -channel userSignedUp - ✖ major removed messages/signout - Message `signout` was removed. (message-removed) - at base.yaml:21:9 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/async3-operation-action-changed/base.yaml b/tests/e2e/diff/async3-operation-action-changed/base.yaml deleted file mode 100644 index 9a3518e7d3..0000000000 --- a/tests/e2e/diff/async3-operation-action-changed/base.yaml +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 135d2a51f8..0000000000 --- a/tests/e2e/diff/async3-operation-action-changed/revision.yaml +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index c3de0383c5..0000000000 --- a/tests/e2e/diff/async3-operation-action-changed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -operation onUserSignedUp - ✖ major modified action - Operation `onUserSignedUp` action changed from 'receive' to 'send'. (operation-action-changed) - at revision.yaml:22:13 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/async3-payload-property-removed/base.yaml b/tests/e2e/diff/async3-payload-property-removed/base.yaml deleted file mode 100644 index 81ee7b0fc7..0000000000 --- a/tests/e2e/diff/async3-payload-property-removed/base.yaml +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 771a295c8a..0000000000 --- a/tests/e2e/diff/async3-payload-property-removed/revision.yaml +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 42bc958d2d..0000000000 --- a/tests/e2e/diff/async3-payload-property-removed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -channel userSignedUp - ✖ major removed messages/signup/payload/properties/plan - Property `plan` was removed. (property-removed) - at base.yaml:19:15 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/async3-payload-required-added/base.yaml b/tests/e2e/diff/async3-payload-required-added/base.yaml deleted file mode 100644 index 3e7f8fc2dc..0000000000 --- a/tests/e2e/diff/async3-payload-required-added/base.yaml +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index bb9b3f3102..0000000000 --- a/tests/e2e/diff/async3-payload-required-added/revision.yaml +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 042beedafd..0000000000 --- a/tests/e2e/diff/async3-payload-required-added/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -channel userSignedUp - ✖ major modified messages/signup/payload · required - Properties became required: `plan`. (required-properties-added) - at revision.yaml:14:13 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major 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 deleted file mode 100644 index 6cac870c6a..0000000000 --- a/tests/e2e/diff/async3-sent-payload-required-added/base.yaml +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 7307495421..0000000000 --- a/tests/e2e/diff/async3-sent-payload-required-added/revision.yaml +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index c128cb0fea..0000000000 --- a/tests/e2e/diff/async3-sent-payload-required-added/snapshot.txt +++ /dev/null @@ -1,10 +0,0 @@ -channel userSignedUp - · patch modified messages/signup/payload · required - at revision.yaml:14:13 - -0 major, 0 minor, 1 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -✅ Diff passed. diff --git a/tests/e2e/diff/async3-server-removed/base.yaml b/tests/e2e/diff/async3-server-removed/base.yaml deleted file mode 100644 index 5edceb3bc5..0000000000 --- a/tests/e2e/diff/async3-server-removed/base.yaml +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 6c0429246b..0000000000 --- a/tests/e2e/diff/async3-server-removed/revision.yaml +++ /dev/null @@ -1,30 +0,0 @@ -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/asyncapi3/base.yaml b/tests/e2e/diff/asyncapi3/base.yaml new file mode 100644 index 0000000000..75d1150692 --- /dev/null +++ b/tests/e2e/diff/asyncapi3/base.yaml @@ -0,0 +1,36 @@ +asyncapi: 3.0.0 +info: { title: Cafe kitchen, version: 1.0.0 } +servers: + production: { host: kitchen.cafe.test, protocol: mqtt } + staging: { host: staging.cafe.test, protocol: mqtt } +channels: + orderPlaced: + address: orders/placed + messages: + orderPlaced: + contentType: application/json + payload: + type: object + properties: + orderId: { type: string } + items: { type: array } + orderReady: + address: orders/ready + messages: + orderReady: + payload: { type: object, properties: { orderId: { type: string } } } + menuChanged: + address: menu/changed + messages: + menuChanged: + payload: { type: object } +operations: + onOrderPlaced: + action: receive + channel: { $ref: '#/channels/orderPlaced' } + sendOrderReady: + action: send + channel: { $ref: '#/channels/orderReady' } + sendMenuChanged: + action: send + channel: { $ref: '#/channels/menuChanged' } diff --git a/tests/e2e/diff/asyncapi3/revision.yaml b/tests/e2e/diff/asyncapi3/revision.yaml new file mode 100644 index 0000000000..23afe4bdf4 --- /dev/null +++ b/tests/e2e/diff/asyncapi3/revision.yaml @@ -0,0 +1,28 @@ +asyncapi: 3.0.0 +info: { title: Cafe kitchen, version: 1.0.0 } +servers: + production: { host: kitchen.cafe.test, protocol: mqtt } +channels: + orderPlaced: + address: orders/new + messages: + orderPlaced: + contentType: application/avro + payload: + type: object + required: [items] + properties: + orderId: { type: string } + items: { type: array } + orderReady: + address: orders/ready + messages: + orderReady: + payload: { type: object, properties: {} } +operations: + onOrderPlaced: + action: receive + channel: { $ref: '#/channels/orderPlaced' } + sendOrderReady: + action: receive + channel: { $ref: '#/channels/orderReady' } diff --git a/tests/e2e/diff/asyncapi3/snapshot.txt b/tests/e2e/diff/asyncapi3/snapshot.txt new file mode 100644 index 0000000000..c924105d84 --- /dev/null +++ b/tests/e2e/diff/asyncapi3/snapshot.txt @@ -0,0 +1,42 @@ +channel menuChanged + ✖ major removed + Channel `menuChanged` was removed. (channel-removed) + at base.yaml:23:5 + +channel orderPlaced + ✖ major modified address + Channel `orderPlaced` address changed from 'orders/placed' to 'orders/new'. (channel-address-changed) + at revision.yaml:7:14 + ✖ major modified messages/orderPlaced · contentType + Message `orderPlaced` content type changed from 'application/json' to 'application/avro'. (message-content-type-changed) + at revision.yaml:10:22 + ✖ major modified messages/orderPlaced/payload · required + Properties became required: `items`. (required-properties-added) + at revision.yaml:13:21 + +channel orderReady + ✖ major removed messages/orderReady/payload/properties/orderId + Property `orderId` was removed. (property-removed) + at base.yaml:21:57 + +operation sendMenuChanged + ✖ major removed + Operation `sendMenuChanged` was removed. (operation-removed) + at base.yaml:35:5 + +operation sendOrderReady + ✖ major modified action + Operation `sendOrderReady` action changed from 'send' to 'receive'. (operation-action-changed) + at revision.yaml:27:13 + +server staging + ✖ major removed + Server `staging` was removed. (server-removed) + at base.yaml:5:12 + +8 major, 0 minor, 0 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 8 major changes. diff --git a/tests/e2e/diff/cli.test.ts b/tests/e2e/diff/cli.test.ts deleted file mode 100644 index 05dccb91e3..0000000000 --- a/tests/e2e/diff/cli.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -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 major changes with the default --fail-on=major', () => { - 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 minor or patch', () => { - expect(diffExitCode('oas3-parameter-added-optional')).toBe(0); - }); - - test('exits 1 with --fail-on=minor when an optional parameter is added', () => { - expect(diffExitCode('oas3-parameter-added-optional', '--fail-on=minor')).toBe(1); - }); - - test('rejects --output for the github-actions format', () => { - const output = runDiff('oas3-breaking-changes', '--format=github-actions', '-o', 'out.txt'); - expect(output).toContain('prints to stdout only'); - }); - - test('fails --check-version when info.version is not semver', () => { - // Every diff fixture keeps a two-part `info.version` (e.g. '1.0'), which is not semver. - const output = runDiff('oas3-parameter-removed', '--check-version', '--fail-on=none'); - expect(output).toContain('--check-version cannot compare it'); - }); - - test('warns that an unresolved reference leaves part of the description unjudged', () => { - const output = runDiff('unresolved-ref'); - expect(output).toContain("Can't resolve $ref"); - expect(output).toContain('the diff may miss changes there'); - }); - - test('refuses to compare across specification families', () => { - const output = runDiff('cross-family'); - expect(output).toContain('Cannot compare oas2 with oas3_1.'); - }); -}); diff --git a/tests/e2e/diff/config-file/base.yaml b/tests/e2e/diff/config-file/base.yaml new file mode 100644 index 0000000000..d9fc441b18 --- /dev/null +++ b/tests/e2e/diff/config-file/base.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.2.0 } +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + responses: + '200': { description: OK } + /orders/{orderId}: + delete: + responses: + '204': { description: Cancelled } diff --git a/tests/e2e/diff/config-file/redocly.yaml b/tests/e2e/diff/config-file/redocly.yaml new file mode 100644 index 0000000000..94ad796aa0 --- /dev/null +++ b/tests/e2e/diff/config-file/redocly.yaml @@ -0,0 +1,2 @@ +extends: + - diff-recommended diff --git a/tests/e2e/diff/config-file/relaxed.yaml b/tests/e2e/diff/config-file/relaxed.yaml new file mode 100644 index 0000000000..1f4efc2ea8 --- /dev/null +++ b/tests/e2e/diff/config-file/relaxed.yaml @@ -0,0 +1,7 @@ +# Picked with --config in place of redocly.yaml: here a removed path and a parameter that became +# required only count as minor changes. +extends: + - diff-recommended +diff: + path-removed: minor + parameter-became-required: minor diff --git a/tests/e2e/diff/config-file/revision.yaml b/tests/e2e/diff/config-file/revision.yaml new file mode 100644 index 0000000000..68e0ea18b1 --- /dev/null +++ b/tests/e2e/diff/config-file/revision.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.3.0 } +paths: + /menu: + get: + summary: List the whole menu + parameters: + - { name: limit, in: query, required: true, schema: { type: integer } } + responses: + '200': { description: OK } + /specials: + get: + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/config-file/snapshot.txt b/tests/e2e/diff/config-file/snapshot.txt new file mode 100644 index 0000000000..da5857be3f --- /dev/null +++ b/tests/e2e/diff/config-file/snapshot.txt @@ -0,0 +1,26 @@ +info + · patch modified version + at revision.yaml:2:31 + +GET /menu + ✔ minor modified parameters/{query:limit} · required + `limit` query parameter became required. (parameter-became-required) + at revision.yaml:8:47 + · patch modified summary + at revision.yaml:6:16 + +/orders/{orderId} + ✔ minor removed + Path `/orders/{orderId}` was removed. (path-removed) + at base.yaml:12:5 + +/specials + ✔ minor added + at revision.yaml:12:5 + +0 major, 3 minor, 2 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +✅ Diff passed. diff --git a/tests/e2e/diff/configuration/base.yaml b/tests/e2e/diff/configuration/base.yaml new file mode 100644 index 0000000000..4f1c8df49b --- /dev/null +++ b/tests/e2e/diff/configuration/base.yaml @@ -0,0 +1,65 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.2.0 } +servers: + - url: https://api.cafe.test +security: + - apiKey: [] +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + - { name: search, in: query, schema: { type: string } } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } } + /menu/{id}: + get: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': { description: OK } + delete: + responses: + '204': { description: Deleted } + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + headers: + Location: { schema: { type: string } } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: + { type: object, properties: { orderId: { type: string }, eta: { type: string } } } + responses: + '200': { description: OK } +components: + securitySchemes: + apiKey: { type: apiKey, in: header, name: X-Key } + schemas: + MenuItem: + type: object + properties: + name: { type: string } + price: { type: number } + status: { type: string, enum: [available, sold-out] } + Order: + type: object + required: [items] + properties: + items: { type: array, items: { type: string } } + note: { type: string, maxLength: 200 } diff --git a/tests/e2e/diff/configuration/redocly.yaml b/tests/e2e/diff/configuration/redocly.yaml new file mode 100644 index 0000000000..30a442fec8 --- /dev/null +++ b/tests/e2e/diff/configuration/redocly.yaml @@ -0,0 +1,12 @@ +extends: + - diff-recommended +apis: + cafe@v1: + root: base.yaml + cafe@v2: + root: revision.yaml +# The cafe accepts new enum values in its responses, and treats a dropped header as a patch. +diff: + enum-values-added: off +oas3_1Diff: + response-header-removed: patch diff --git a/tests/e2e/diff/configuration/revision.yaml b/tests/e2e/diff/configuration/revision.yaml new file mode 100644 index 0000000000..24ebf27912 --- /dev/null +++ b/tests/e2e/diff/configuration/revision.yaml @@ -0,0 +1,62 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.3.0 } +servers: + - url: https://api.cafe.test +security: + - apiKey: [] +paths: + /menu: + get: + summary: List the whole menu + parameters: + - { name: limit, in: query, required: true, schema: { type: integer } } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } } + /menu/{menuItemId}: + get: + parameters: + - { name: menuItemId, in: path, required: true, schema: { type: string } } + responses: + '200': { description: OK } + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + /specials: + get: + responses: + '200': { description: OK } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: { type: object, properties: { orderId: { type: string } } } + responses: + '200': { description: OK } +components: + securitySchemes: + apiKey: { type: apiKey, in: header, name: X-Key } + schemas: + MenuItem: + type: object + properties: + name: { type: string } + status: { type: string, enum: [available, sold-out, seasonal] } + Order: + type: object + required: [items, pickupAt] + properties: + items: { type: array, items: { type: string } } + note: { type: string, maxLength: 100 } + pickupAt: { type: string, format: date-time } diff --git a/tests/e2e/diff/configuration/snapshot-skip-rule.txt b/tests/e2e/diff/configuration/snapshot-skip-rule.txt new file mode 100644 index 0000000000..12ca92eaad --- /dev/null +++ b/tests/e2e/diff/configuration/snapshot-skip-rule.txt @@ -0,0 +1,59 @@ +components + ✖ major modified schemas/Order · required + Properties of `Order` became required: `pickupAt`. (required-properties-added) + at revision.yaml:58:17 + ✖ major modified schemas/Order/properties/note · maxLength + `maxLength` of `note` changed from '200' to '100'. (string-length-changed) + at revision.yaml:61:42 + ✔ minor added schemas/Order/properties/pickupAt + at revision.yaml:62:19 + · patch removed schemas/MenuItem/properties/price + at base.yaml:58:16 + · patch modified schemas/MenuItem/properties/status · enum + at revision.yaml:55:39 + +info + · patch modified version + at revision.yaml:2:31 + +GET /menu + ✖ major modified parameters/{query:limit} · required + `limit` query parameter became required. (parameter-became-required) + at revision.yaml:12:47 + ✖ major removed parameters/{query:search} + `search` query parameter was removed. (parameter-removed) + at base.yaml:13:11 + · patch modified summary + at revision.yaml:10:16 + +/menu/{menuItemId} + · patch modified key + at revision.yaml:20:5 + +DELETE /menu/{menuItemId} + · patch removed + at base.yaml:27:7 + +GET /menu/{menuItemId} + · patch modified parameters/{path:0} · name + at revision.yaml:22:19 + +POST /orders + · patch removed responses/201/headers + All headers of response `201` were removed. (response-header-removed) + at base.yaml:39:13 + +/specials + ✔ minor added + at revision.yaml:35:5 + +POST orderReady (webhook) + · patch removed requestBody/content/application/json/schema/properties/eta + at base.yaml:47:77 + +4 major, 2 minor, 9 patch. + + +./tests/e2e/diff/configuration/base.yaml vs ./tests/e2e/diff/configuration/revision.yaml: diff processed in ms + +❌ Diff failed with 4 major changes. diff --git a/tests/e2e/diff/configuration/snapshot.txt b/tests/e2e/diff/configuration/snapshot.txt new file mode 100644 index 0000000000..66c99a018f --- /dev/null +++ b/tests/e2e/diff/configuration/snapshot.txt @@ -0,0 +1,62 @@ +components + ✖ major removed schemas/MenuItem/properties/price + Property `price` of `MenuItem` was removed. (property-removed) + at base.yaml:58:16 + ✖ major modified schemas/Order · required + Properties of `Order` became required: `pickupAt`. (required-properties-added) + at revision.yaml:58:17 + ✖ major modified schemas/Order/properties/note · maxLength + `maxLength` of `note` changed from '200' to '100'. (string-length-changed) + at revision.yaml:61:42 + ✔ minor added schemas/Order/properties/pickupAt + at revision.yaml:62:19 + · patch modified schemas/MenuItem/properties/status · enum + at revision.yaml:55:39 + +info + · patch modified version + at revision.yaml:2:31 + +GET /menu + ✖ major modified parameters/{query:limit} · required + `limit` query parameter became required. (parameter-became-required) + at revision.yaml:12:47 + ✖ major removed parameters/{query:search} + `search` query parameter was removed. (parameter-removed) + at base.yaml:13:11 + · patch modified summary + at revision.yaml:10:16 + +/menu/{menuItemId} + · patch modified key + at revision.yaml:20:5 + +DELETE /menu/{menuItemId} + ✖ major removed + Operation `DELETE /menu/{menuItemId}` was removed. (operation-removed) + at base.yaml:27:7 + +GET /menu/{menuItemId} + · patch modified parameters/{path:0} · name + at revision.yaml:22:19 + +POST /orders + · patch removed responses/201/headers + All headers of response `201` were removed. (response-header-removed) + at base.yaml:39:13 + +/specials + ✔ minor added + at revision.yaml:35:5 + +POST orderReady (webhook) + ✖ major removed requestBody/content/application/json/schema/properties/eta + Property `eta` was removed. (property-removed) + at base.yaml:47:77 + +7 major, 2 minor, 6 patch. + + +./tests/e2e/diff/configuration/base.yaml vs ./tests/e2e/diff/configuration/revision.yaml: diff processed in ms + +❌ Diff failed with 7 major changes. diff --git a/tests/e2e/diff/cross-family/base.yaml b/tests/e2e/diff/cross-family/base.yaml deleted file mode 100644 index 3d5ca76687..0000000000 --- a/tests/e2e/diff/cross-family/base.yaml +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index a52ea4d006..0000000000 --- a/tests/e2e/diff/cross-family/revision.yaml +++ /dev/null @@ -1,5 +0,0 @@ -openapi: 3.1.0 -info: - title: cross-family - version: '1.0' -paths: {} diff --git a/tests/e2e/diff/diff.test.ts b/tests/e2e/diff/diff.test.ts new file mode 100644 index 0000000000..ed85d6b637 --- /dev/null +++ b/tests/e2e/diff/diff.test.ts @@ -0,0 +1,187 @@ +import { readFileSync, rmSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { getCommandOutput, getParams, cleanupOutput } from '../helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const indexEntryPoint = join(process.cwd(), 'packages/cli/lib/index.js'); + +describe('diff', () => { + test('should list what breaks per endpoint and fail on a major change', async () => { + const testPath = join(__dirname, 'openapi-breaking-changes'); + const args = getParams(indexEntryPoint, ['diff', 'base.yaml', 'revision.yaml']); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('should fail --check-version when info.version is bumped less than the changes require', async () => { + const testPath = join(__dirname, 'openapi-breaking-changes'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + '--check-version', + '--fail-on=none', + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, 'snapshot-check-version.txt') + ); + }); + + test('should pass a revision that only adds to the API and bumps info.version', async () => { + const testPath = join(__dirname, 'openapi-compatible-changes'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + '--check-version', + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('should fail on the impact --fail-on names', async () => { + const testPath = join(__dirname, 'openapi-compatible-changes'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + '--fail-on=minor', + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, 'snapshot-fail-on-minor.txt') + ); + }); + + test.each(['markdown', 'html'])( + 'should write a %s report to the file --output names', + async (format) => { + const testPath = join(__dirname, 'markdown-and-html'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + `--format=${format}`, + `--output=report.${format}`, + ]); + + getCommandOutput(args, { testPath }); + const report = readFileSync(join(testPath, `report.${format}`), 'utf-8'); + rmSync(join(testPath, `report.${format}`)); + await expect(report).toMatchFileSnapshot(join(testPath, `snapshot-${format}.txt`)); + } + ); + + test.each(['json', 'next-version'])( + 'should print the %s format for a script to read', + async (format) => { + const testPath = join(__dirname, 'json-and-next-version'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + `--format=${format}`, + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, `snapshot-${format}.txt`) + ); + } + ); + + test('should annotate each breaking change on GitHub', async () => { + const testPath = join(__dirname, 'github-actions'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + '--format=github-actions', + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('should refuse to write github-actions annotations to a file', async () => { + const testPath = join(__dirname, 'github-actions'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + '--format=github-actions', + '--output=annotations.txt', + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot-output.txt')); + }); + + test('should judge an AsyncAPI 3 change per channel, operation and server', async () => { + const testPath = join(__dirname, 'asyncapi3'); + const args = getParams(indexEntryPoint, ['diff', 'base.yaml', 'revision.yaml']); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('should not report a move from OpenAPI 3.0 to 3.1 that describes the same API', async () => { + const testPath = join(__dirname, 'openapi-3-0-to-3-1'); + const args = getParams(indexEntryPoint, ['diff', 'base.yaml', 'revision.yaml']); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('should compare two APIs by their aliases and judge them with the rules of redocly.yaml', async () => { + const testPath = join(__dirname, 'configuration'); + const args = getParams(indexEntryPoint, ['diff', 'cafe@v1', 'cafe@v2']); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('should leave out the rules --skip-rule names', async () => { + const testPath = join(__dirname, 'configuration'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'cafe@v1', + 'cafe@v2', + '--skip-rule=operation-removed', + '--skip-rule=property-removed', + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, 'snapshot-skip-rule.txt') + ); + }); + + test('should use the config file --config names', async () => { + const testPath = join(__dirname, 'config-file'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + '--config=relaxed.yaml', + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('should judge a change with a diff rule from a plugin', async () => { + const testPath = join(__dirname, 'plugin'); + const args = getParams(indexEntryPoint, ['diff', 'base.yaml', 'revision.yaml']); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); +}); diff --git a/tests/e2e/diff/github-actions/base.yaml b/tests/e2e/diff/github-actions/base.yaml new file mode 100644 index 0000000000..d9fc441b18 --- /dev/null +++ b/tests/e2e/diff/github-actions/base.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.2.0 } +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + responses: + '200': { description: OK } + /orders/{orderId}: + delete: + responses: + '204': { description: Cancelled } diff --git a/tests/e2e/diff/github-actions/revision.yaml b/tests/e2e/diff/github-actions/revision.yaml new file mode 100644 index 0000000000..68e0ea18b1 --- /dev/null +++ b/tests/e2e/diff/github-actions/revision.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.3.0 } +paths: + /menu: + get: + summary: List the whole menu + parameters: + - { name: limit, in: query, required: true, schema: { type: integer } } + responses: + '200': { description: OK } + /specials: + get: + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/github-actions/snapshot-output.txt b/tests/e2e/diff/github-actions/snapshot-output.txt new file mode 100644 index 0000000000..0fabf18317 --- /dev/null +++ b/tests/e2e/diff/github-actions/snapshot-output.txt @@ -0,0 +1,3 @@ + +The github-actions format prints to stdout only. To write a report to a file, use one of these formats: stylish, json, markdown, html, next-version. + diff --git a/tests/e2e/diff/github-actions/snapshot.txt b/tests/e2e/diff/github-actions/snapshot.txt new file mode 100644 index 0000000000..dc3db812fe --- /dev/null +++ b/tests/e2e/diff/github-actions/snapshot.txt @@ -0,0 +1,7 @@ +::error title=parameter-became-required,file=revision.yaml,line=8,col=47,endLine=8,endColumn=51::`limit` query parameter became required. +::error title=path-removed,file=base.yaml,line=12,col=5,endLine=14,endColumn=42::Path `/orders/{orderId}` was removed. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 2 major changes. diff --git a/tests/e2e/diff/helpers.ts b/tests/e2e/diff/helpers.ts deleted file mode 100644 index 4b32f76f4b..0000000000 --- a/tests/e2e/diff/helpers.ts +++ /dev/null @@ -1,36 +0,0 @@ -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: { major: number; minor: number; patch: number }; - bump?: 'patch' | 'minor' | 'major'; - changes: { - key: string; - property?: string; - kind: string; - impact: string; - verdicts: { ruleId: string; impact: 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/json-and-next-version/base.yaml b/tests/e2e/diff/json-and-next-version/base.yaml new file mode 100644 index 0000000000..d9fc441b18 --- /dev/null +++ b/tests/e2e/diff/json-and-next-version/base.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.2.0 } +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + responses: + '200': { description: OK } + /orders/{orderId}: + delete: + responses: + '204': { description: Cancelled } diff --git a/tests/e2e/diff/json-and-next-version/revision.yaml b/tests/e2e/diff/json-and-next-version/revision.yaml new file mode 100644 index 0000000000..68e0ea18b1 --- /dev/null +++ b/tests/e2e/diff/json-and-next-version/revision.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.3.0 } +paths: + /menu: + get: + summary: List the whole menu + parameters: + - { name: limit, in: query, required: true, schema: { type: integer } } + responses: + '200': { description: OK } + /specials: + get: + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/json-and-next-version/snapshot-json.txt b/tests/e2e/diff/json-and-next-version/snapshot-json.txt new file mode 100644 index 0000000000..6ad1170cc9 --- /dev/null +++ b/tests/e2e/diff/json-and-next-version/snapshot-json.txt @@ -0,0 +1,161 @@ +{ + "reportVersion": "1", + "files": { + "base": "base.yaml", + "revision": "revision.yaml" + }, + "specVersions": { + "base": "oas3_1", + "revision": "oas3_1" + }, + "infoVersions": { + "base": "1.2.0", + "revision": "1.3.0" + }, + "summary": { + "major": 2, + "minor": 1, + "patch": 2 + }, + "bump": "major", + "changes": [ + { + "key": "#/info", + "typeName": "Info", + "kind": "modified", + "impact": "patch", + "verdicts": [], + "property": "version", + "base": { + "file": "base.yaml", + "pointer": "#/info/version", + "line": 2, + "col": 31, + "value": "1.2.0" + }, + "revision": { + "file": "revision.yaml", + "pointer": "#/info/version", + "line": 2, + "col": 31, + "value": "1.3.0" + } + }, + { + "key": "#/paths/~1menu/get", + "typeName": "Operation", + "kind": "modified", + "impact": "patch", + "verdicts": [], + "property": "summary", + "base": { + "file": "base.yaml", + "pointer": "#/paths/~1menu/get/summary", + "line": 6, + "col": 16, + "value": "List the menu" + }, + "revision": { + "file": "revision.yaml", + "pointer": "#/paths/~1menu/get/summary", + "line": 6, + "col": 16, + "value": "List the whole menu" + } + }, + { + "key": "#/paths/~1menu/get/parameters/{query:limit}", + "typeName": "Parameter", + "kind": "modified", + "impact": "major", + "verdicts": [ + { + "ruleId": "parameter-became-required", + "impact": "major", + "message": "`limit` query parameter became required.", + "location": { + "file": "revision.yaml", + "pointer": "#/paths/~1menu/get/parameters/0/required", + "line": 8, + "col": 47 + } + } + ], + "property": "required", + "base": { + "file": "base.yaml", + "pointer": "#/paths/~1menu/get/parameters/0", + "line": 8, + "col": 11 + }, + "revision": { + "file": "revision.yaml", + "pointer": "#/paths/~1menu/get/parameters/0/required", + "line": 8, + "col": 47, + "value": true + } + }, + { + "key": "#/paths/~1orders~1{0}", + "typeName": "PathItem", + "kind": "removed", + "impact": "major", + "verdicts": [ + { + "ruleId": "path-removed", + "impact": "major", + "message": "Path `/orders/{orderId}` was removed.", + "location": { + "file": "base.yaml", + "pointer": "#/paths/~1orders~1{orderId}", + "line": 12, + "col": 5 + } + } + ], + "base": { + "file": "base.yaml", + "pointer": "#/paths/~1orders~1{orderId}", + "line": 12, + "col": 5, + "value": { + "delete": { + "responses": { + "204": { + "description": "Cancelled" + } + } + } + } + } + }, + { + "key": "#/paths/~1specials", + "typeName": "PathItem", + "kind": "added", + "impact": "minor", + "verdicts": [], + "revision": { + "file": "revision.yaml", + "pointer": "#/paths/~1specials", + "line": 12, + "col": 5, + "value": { + "get": { + "responses": { + "200": { + "description": "OK" + } + } + } + } + } + } + ] +} + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 2 major changes. diff --git a/tests/e2e/diff/oas3-parameter-refs-reordered/snapshot.txt b/tests/e2e/diff/json-and-next-version/snapshot-next-version.txt similarity index 56% rename from tests/e2e/diff/oas3-parameter-refs-reordered/snapshot.txt rename to tests/e2e/diff/json-and-next-version/snapshot-next-version.txt index 65589573a0..d48c5b1845 100644 --- a/tests/e2e/diff/oas3-parameter-refs-reordered/snapshot.txt +++ b/tests/e2e/diff/json-and-next-version/snapshot-next-version.txt @@ -1,6 +1,6 @@ -0 major, 0 minor, 0 patch. +2.0.0 base.yaml vs revision.yaml: diff processed in ms -✅ Diff passed. +❌ Diff failed with 2 major changes. diff --git a/tests/e2e/diff/markdown-and-html/base.yaml b/tests/e2e/diff/markdown-and-html/base.yaml new file mode 100644 index 0000000000..d9fc441b18 --- /dev/null +++ b/tests/e2e/diff/markdown-and-html/base.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.2.0 } +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + responses: + '200': { description: OK } + /orders/{orderId}: + delete: + responses: + '204': { description: Cancelled } diff --git a/tests/e2e/diff/markdown-and-html/revision.yaml b/tests/e2e/diff/markdown-and-html/revision.yaml new file mode 100644 index 0000000000..68e0ea18b1 --- /dev/null +++ b/tests/e2e/diff/markdown-and-html/revision.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.3.0 } +paths: + /menu: + get: + summary: List the whole menu + parameters: + - { name: limit, in: query, required: true, schema: { type: integer } } + responses: + '200': { description: OK } + /specials: + get: + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/markdown-and-html/snapshot-html.txt b/tests/e2e/diff/markdown-and-html/snapshot-html.txt new file mode 100644 index 0000000000..e5e7d507ba --- /dev/null +++ b/tests/e2e/diff/markdown-and-html/snapshot-html.txt @@ -0,0 +1,158 @@ + + + + +API diff + + + +

API diff: base.yaml → revision.yaml

+

+ 2 major + 1 minor + 2 patch + requires a major bump + OpenAPI 3.1 +

+ +
+ + patch + modified + #/info · version + + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/info/version",
+    "line": 2,
+    "col": 31,
+    "value": "1.2.0"
+  },
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/info/version",
+    "line": 2,
+    "col": 31,
+    "value": "1.3.0"
+  }
+}
+
+ +
+ + patch + modified + #/paths/~1menu/get · summary + + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1menu/get/summary",
+    "line": 6,
+    "col": 16,
+    "value": "List the menu"
+  },
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/paths/~1menu/get/summary",
+    "line": 6,
+    "col": 16,
+    "value": "List the whole menu"
+  }
+}
+
+ +
+ + major + modified + #/paths/~1menu/get/parameters/{query:limit} · required + `limit` query parameter became required. parameter-became-required + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1menu/get/parameters/0",
+    "line": 8,
+    "col": 11
+  },
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/paths/~1menu/get/parameters/0/required",
+    "line": 8,
+    "col": 47,
+    "value": true
+  }
+}
+
+ +
+ + major + removed + #/paths/~1orders~1{0} + Path `/orders/{orderId}` was removed. path-removed + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1orders~1{orderId}",
+    "line": 12,
+    "col": 5,
+    "value": {
+      "delete": {
+        "responses": {
+          "204": {
+            "description": "Cancelled"
+          }
+        }
+      }
+    }
+  }
+}
+
+ +
+ + minor + added + #/paths/~1specials + + +
{
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/paths/~1specials",
+    "line": 12,
+    "col": 5,
+    "value": {
+      "get": {
+        "responses": {
+          "200": {
+            "description": "OK"
+          }
+        }
+      }
+    }
+  }
+}
+
+ + \ No newline at end of file diff --git a/tests/e2e/diff/markdown-and-html/snapshot-markdown.txt b/tests/e2e/diff/markdown-and-html/snapshot-markdown.txt new file mode 100644 index 0000000000..b4287526fc --- /dev/null +++ b/tests/e2e/diff/markdown-and-html/snapshot-markdown.txt @@ -0,0 +1,11 @@ +## API diff: base.yaml → revision.yaml + +**2** major · **1** minor · **2** patch · requires a **major** bump + +| Impact | Change | Pointer | Location | Details | +| --- | --- | --- | --- | --- | +| ⚪ patch | modified | `#/info · version` | revision.yaml:2:31 | | +| ⚪ patch | modified | `#/paths/~1menu/get · summary` | revision.yaml:6:16 | | +| 🔴 major | modified | `#/paths/~1menu/get/parameters/{query:limit} · required` | revision.yaml:8:47 | \`limit\` query parameter became required. `parameter-became-required` | +| 🔴 major | removed | `#/paths/~1orders~1{0}` | base.yaml:12:5 | Path \`/orders/{orderId}\` was removed. `path-removed` | +| 🟢 minor | added | `#/paths/~1specials` | revision.yaml:12:5 | | \ No newline at end of file diff --git a/tests/e2e/diff/oas3-additional-properties-changed/base.yaml b/tests/e2e/diff/oas3-additional-properties-changed/base.yaml deleted file mode 100644 index 2da090dc58..0000000000 --- a/tests/e2e/diff/oas3-additional-properties-changed/base.yaml +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index aba2088e72..0000000000 --- a/tests/e2e/diff/oas3-additional-properties-changed/revision.yaml +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 09761e0d82..0000000000 --- a/tests/e2e/diff/oas3-additional-properties-changed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -POST /pets - ✖ major modified requestBody/content/application/json/schema · additionalProperties - `additionalProperties` changed from 'true' to 'false'. (additional-properties-changed) - at revision.yaml:13:37 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-allof-ref-replaced/base.yaml b/tests/e2e/diff/oas3-allof-ref-replaced/base.yaml deleted file mode 100644 index 4f1d7b178d..0000000000 --- a/tests/e2e/diff/oas3-allof-ref-replaced/base.yaml +++ /dev/null @@ -1,25 +0,0 @@ -openapi: 3.1.0 -info: - title: allof-ref-replaced - version: '1.0' -paths: - /pets: - get: - responses: - '200': - description: OK - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/Named' -components: - schemas: - Named: - type: object - properties: - name: { type: string } - Tagged: - type: object - properties: - tag: { type: string } diff --git a/tests/e2e/diff/oas3-allof-ref-replaced/revision.yaml b/tests/e2e/diff/oas3-allof-ref-replaced/revision.yaml deleted file mode 100644 index 4a2ce561b2..0000000000 --- a/tests/e2e/diff/oas3-allof-ref-replaced/revision.yaml +++ /dev/null @@ -1,25 +0,0 @@ -openapi: 3.1.0 -info: - title: allof-ref-replaced - version: '1.0' -paths: - /pets: - get: - responses: - '200': - description: OK - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/Tagged' -components: - schemas: - Named: - type: object - properties: - name: { type: string } - Tagged: - type: object - properties: - tag: { type: string } diff --git a/tests/e2e/diff/oas3-allof-ref-replaced/snapshot.txt b/tests/e2e/diff/oas3-allof-ref-replaced/snapshot.txt deleted file mode 100644 index 8767d17755..0000000000 --- a/tests/e2e/diff/oas3-allof-ref-replaced/snapshot.txt +++ /dev/null @@ -1,13 +0,0 @@ -GET /pets - ✖ major removed responses/200/content/application/json/schema/allOf/{#/components/schemas/Named} - `allOf` subschema was removed. (schema-combinator-changed) - at base.yaml:15:21 - ✔ minor added responses/200/content/application/json/schema/allOf/{#/components/schemas/Tagged} - at revision.yaml:15:21 - -1 major, 1 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-breaking-changes/base.yaml b/tests/e2e/diff/oas3-breaking-changes/base.yaml deleted file mode 100644 index 2187217b99..0000000000 --- a/tests/e2e/diff/oas3-breaking-changes/base.yaml +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index cf3b37628b..0000000000 --- a/tests/e2e/diff/oas3-breaking-changes/revision.yaml +++ /dev/null @@ -1,28 +0,0 @@ -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-enum-values-added-to-request/base.yaml b/tests/e2e/diff/oas3-enum-values-added-to-request/base.yaml deleted file mode 100644 index c78a921748..0000000000 --- a/tests/e2e/diff/oas3-enum-values-added-to-request/base.yaml +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 6195758a03..0000000000 --- a/tests/e2e/diff/oas3-enum-values-added-to-request/revision.yaml +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 99bd97e447..0000000000 --- a/tests/e2e/diff/oas3-enum-values-added-to-request/snapshot.txt +++ /dev/null @@ -1,10 +0,0 @@ -POST /pets - · patch modified requestBody/content/application/json/schema · enum - at revision.yaml:13:21 - -0 major, 0 minor, 1 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -✅ Diff passed. diff --git a/tests/e2e/diff/oas3-enum-values-added/base.yaml b/tests/e2e/diff/oas3-enum-values-added/base.yaml deleted file mode 100644 index 8f25eb3baa..0000000000 --- a/tests/e2e/diff/oas3-enum-values-added/base.yaml +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index fff8009d68..0000000000 --- a/tests/e2e/diff/oas3-enum-values-added/revision.yaml +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 7b7862a686..0000000000 --- a/tests/e2e/diff/oas3-enum-values-added/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major modified responses/200/content/application/json/schema · enum - Enum gained values: 'sold'. (enum-values-added) - at revision.yaml:15:23 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-enum-values-removed/base.yaml b/tests/e2e/diff/oas3-enum-values-removed/base.yaml deleted file mode 100644 index 15fd71566a..0000000000 --- a/tests/e2e/diff/oas3-enum-values-removed/base.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 4d00a7e145..0000000000 --- a/tests/e2e/diff/oas3-enum-values-removed/revision.yaml +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index dc88ccf0e1..0000000000 --- a/tests/e2e/diff/oas3-enum-values-removed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major modified parameters/{query:sort}/schema · enum - Enum lost values: 'desc'. (enum-values-removed) - at revision.yaml:14:15 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-media-type-removed/base.yaml b/tests/e2e/diff/oas3-media-type-removed/base.yaml deleted file mode 100644 index 4c12fe0dd1..0000000000 --- a/tests/e2e/diff/oas3-media-type-removed/base.yaml +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 11d150cc8c..0000000000 --- a/tests/e2e/diff/oas3-media-type-removed/revision.yaml +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index f1e696eaf6..0000000000 --- a/tests/e2e/diff/oas3-media-type-removed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major removed responses/200/content/application/xml - Media type `application/xml` was removed. (media-type-removed) - at base.yaml:15:15 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-nullability-changed/base.yaml b/tests/e2e/diff/oas3-nullability-changed/base.yaml deleted file mode 100644 index adaeb2f2b6..0000000000 --- a/tests/e2e/diff/oas3-nullability-changed/base.yaml +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 2cedf3ad32..0000000000 --- a/tests/e2e/diff/oas3-nullability-changed/revision.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 13f4f9a954..0000000000 --- a/tests/e2e/diff/oas3-nullability-changed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -POST /pets - ✖ major modified requestBody/content/application/json/schema/properties/name · type - Type of `name` narrowed from 'string | null' to 'string'. (schema-type-changed) - at revision.yaml:15:25 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major 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 deleted file mode 100644 index b4719daabf..0000000000 --- a/tests/e2e/diff/oas3-nullable-equivalence-across-versions/base.yaml +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 1af0bee554..0000000000 --- a/tests/e2e/diff/oas3-nullable-equivalence-across-versions/revision.yaml +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 65a0247333..0000000000 --- a/tests/e2e/diff/oas3-nullable-equivalence-across-versions/snapshot.txt +++ /dev/null @@ -1,16 +0,0 @@ -document - · patch modified openapi - at revision.yaml:1:10 - -POST /pets - · patch modified requestBody/content/application/json/schema/properties/name · nullable - at revision.yaml:15:19 - · patch modified requestBody/content/application/json/schema/properties/name · type - at revision.yaml:16:21 - -0 major, 0 minor, 3 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -✅ Diff passed. diff --git a/tests/e2e/diff/oas3-numeric-range-changed/base.yaml b/tests/e2e/diff/oas3-numeric-range-changed/base.yaml deleted file mode 100644 index 61532db626..0000000000 --- a/tests/e2e/diff/oas3-numeric-range-changed/base.yaml +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 0aba616219..0000000000 --- a/tests/e2e/diff/oas3-numeric-range-changed/revision.yaml +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 8ba0af368c..0000000000 --- a/tests/e2e/diff/oas3-numeric-range-changed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -POST /pets - ✖ major modified requestBody/content/application/json/schema/properties/age · minimum - `minimum` of `age` changed from '0' to '10'. (numeric-range-changed) - at revision.yaml:16:28 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-operation-removed/base.yaml b/tests/e2e/diff/oas3-operation-removed/base.yaml deleted file mode 100644 index 3f103f844f..0000000000 --- a/tests/e2e/diff/oas3-operation-removed/base.yaml +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 5d6b1398ef..0000000000 --- a/tests/e2e/diff/oas3-operation-removed/revision.yaml +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 1f90f346c8..0000000000 --- a/tests/e2e/diff/oas3-operation-removed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -DELETE /pets - ✖ major removed - Operation `DELETE /pets` was removed. (operation-removed) - at base.yaml:12:7 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-parameter-added-optional/base.yaml b/tests/e2e/diff/oas3-parameter-added-optional/base.yaml deleted file mode 100644 index 3020af9337..0000000000 --- a/tests/e2e/diff/oas3-parameter-added-optional/base.yaml +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 81a806ae81..0000000000 --- a/tests/e2e/diff/oas3-parameter-added-optional/revision.yaml +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index f8fc9a32b4..0000000000 --- a/tests/e2e/diff/oas3-parameter-added-optional/snapshot.txt +++ /dev/null @@ -1,10 +0,0 @@ -GET /pets - ✔ minor added parameters - at revision.yaml:9:9 - -0 major, 1 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -✅ Diff passed. diff --git a/tests/e2e/diff/oas3-parameter-added-required/revision.yaml b/tests/e2e/diff/oas3-parameter-added-required/revision.yaml deleted file mode 100644 index 6e2b165c4c..0000000000 --- a/tests/e2e/diff/oas3-parameter-added-required/revision.yaml +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index e718277e97..0000000000 --- a/tests/e2e/diff/oas3-parameter-added-required/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major added parameters - Required parameters were added. (parameter-added-required) - at revision.yaml:9:9 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-parameter-became-required/base.yaml b/tests/e2e/diff/oas3-parameter-became-required/base.yaml deleted file mode 100644 index 07037e92bd..0000000000 --- a/tests/e2e/diff/oas3-parameter-became-required/base.yaml +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 3ed33a78f9..0000000000 --- a/tests/e2e/diff/oas3-parameter-became-required/revision.yaml +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 7df06bf9bc..0000000000 --- a/tests/e2e/diff/oas3-parameter-became-required/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major modified parameters/{query:limit} · required - `limit` query parameter became required. (parameter-became-required) - at revision.yaml:11:21 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-parameter-ref-replaced/base.yaml b/tests/e2e/diff/oas3-parameter-ref-replaced/base.yaml deleted file mode 100644 index e5f53d4764..0000000000 --- a/tests/e2e/diff/oas3-parameter-ref-replaced/base.yaml +++ /dev/null @@ -1,23 +0,0 @@ -openapi: 3.1.0 -info: - title: parameter-ref-replaced - version: '1.0' -paths: - /pets: - get: - parameters: - - $ref: '#/components/parameters/Limit' - responses: - '200': - description: OK -components: - parameters: - Limit: - name: limit - in: query - schema: { type: integer } - Owner: - name: owner - in: query - required: true - schema: { type: string } diff --git a/tests/e2e/diff/oas3-parameter-ref-replaced/revision.yaml b/tests/e2e/diff/oas3-parameter-ref-replaced/revision.yaml deleted file mode 100644 index bf742be803..0000000000 --- a/tests/e2e/diff/oas3-parameter-ref-replaced/revision.yaml +++ /dev/null @@ -1,23 +0,0 @@ -openapi: 3.1.0 -info: - title: parameter-ref-replaced - version: '1.0' -paths: - /pets: - get: - parameters: - - $ref: '#/components/parameters/Owner' - responses: - '200': - description: OK -components: - parameters: - Limit: - name: limit - in: query - schema: { type: integer } - Owner: - name: owner - in: query - required: true - schema: { type: string } diff --git a/tests/e2e/diff/oas3-parameter-ref-replaced/snapshot.txt b/tests/e2e/diff/oas3-parameter-ref-replaced/snapshot.txt deleted file mode 100644 index 7e583419e7..0000000000 --- a/tests/e2e/diff/oas3-parameter-ref-replaced/snapshot.txt +++ /dev/null @@ -1,14 +0,0 @@ -GET /pets - ✖ major removed parameters/{query:limit} - `limit` query parameter was removed. (parameter-removed) - at base.yaml:9:11 - ✖ major added parameters/{query:owner} - Required `owner` query parameter was added. (parameter-added-required) - at revision.yaml:9:11 - -2 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 2 major changes. diff --git a/tests/e2e/diff/oas3-parameter-refs-reordered/base.yaml b/tests/e2e/diff/oas3-parameter-refs-reordered/base.yaml deleted file mode 100644 index 5b0fecc515..0000000000 --- a/tests/e2e/diff/oas3-parameter-refs-reordered/base.yaml +++ /dev/null @@ -1,24 +0,0 @@ -openapi: 3.1.0 -info: - title: parameter-refs-reordered - version: '1.0' -paths: - /pets: - get: - parameters: - - $ref: '#/components/parameters/Limit' - - $ref: '#/components/parameters/Owner' - responses: - '200': - description: OK -components: - parameters: - Limit: - name: limit - in: query - schema: { type: integer } - Owner: - name: owner - in: query - required: true - schema: { type: string } diff --git a/tests/e2e/diff/oas3-parameter-refs-reordered/revision.yaml b/tests/e2e/diff/oas3-parameter-refs-reordered/revision.yaml deleted file mode 100644 index 6f5f36f227..0000000000 --- a/tests/e2e/diff/oas3-parameter-refs-reordered/revision.yaml +++ /dev/null @@ -1,24 +0,0 @@ -openapi: 3.1.0 -info: - title: parameter-refs-reordered - version: '1.0' -paths: - /pets: - get: - parameters: - - $ref: '#/components/parameters/Owner' - - $ref: '#/components/parameters/Limit' - responses: - '200': - description: OK -components: - parameters: - Limit: - name: limit - in: query - schema: { type: integer } - Owner: - name: owner - in: query - required: true - schema: { type: string } diff --git a/tests/e2e/diff/oas3-parameter-removed-last/base.yaml b/tests/e2e/diff/oas3-parameter-removed-last/base.yaml deleted file mode 100644 index eeefa6ebd8..0000000000 --- a/tests/e2e/diff/oas3-parameter-removed-last/base.yaml +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index aaa022934e..0000000000 --- a/tests/e2e/diff/oas3-parameter-removed-last/revision.yaml +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 619534550b..0000000000 --- a/tests/e2e/diff/oas3-parameter-removed-last/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major removed parameters - All parameters were removed. (parameter-removed) - at base.yaml:9:9 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-parameter-removed/base.yaml b/tests/e2e/diff/oas3-parameter-removed/base.yaml deleted file mode 100644 index de55637e04..0000000000 --- a/tests/e2e/diff/oas3-parameter-removed/base.yaml +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 8f38486660..0000000000 --- a/tests/e2e/diff/oas3-parameter-removed/revision.yaml +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index edb5ddbfed..0000000000 --- a/tests/e2e/diff/oas3-parameter-removed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major removed parameters/{query:limit} - `limit` query parameter was removed. (parameter-removed) - at base.yaml:9:11 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-parameter-serialization-changed/base.yaml b/tests/e2e/diff/oas3-parameter-serialization-changed/base.yaml deleted file mode 100644 index 481c022a8e..0000000000 --- a/tests/e2e/diff/oas3-parameter-serialization-changed/base.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 261635dc07..0000000000 --- a/tests/e2e/diff/oas3-parameter-serialization-changed/revision.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index b20ff23265..0000000000 --- a/tests/e2e/diff/oas3-parameter-serialization-changed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major modified parameters/{query:tags} · style - `style` of `tags` query parameter changed from 'form' to 'spaceDelimited'. (parameter-serialization-changed) - at revision.yaml:11:18 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-path-parameter-renamed/base.yaml b/tests/e2e/diff/oas3-path-parameter-renamed/base.yaml deleted file mode 100644 index b8ed4e1246..0000000000 --- a/tests/e2e/diff/oas3-path-parameter-renamed/base.yaml +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 8d380b1ba7..0000000000 --- a/tests/e2e/diff/oas3-path-parameter-renamed/revision.yaml +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 661ced879c..0000000000 --- a/tests/e2e/diff/oas3-path-parameter-renamed/snapshot.txt +++ /dev/null @@ -1,14 +0,0 @@ -/pets/{petId} - · patch modified key - at revision.yaml:7:5 - -GET /pets/{petId} - · patch modified parameters/{path:0} · name - at revision.yaml:9:17 - -0 major, 0 minor, 2 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -✅ Diff passed. diff --git a/tests/e2e/diff/oas3-path-removed/base.yaml b/tests/e2e/diff/oas3-path-removed/base.yaml deleted file mode 100644 index a07f9e0859..0000000000 --- a/tests/e2e/diff/oas3-path-removed/base.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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/snapshot.txt b/tests/e2e/diff/oas3-path-removed/snapshot.txt deleted file mode 100644 index 4fc2c4f7d6..0000000000 --- a/tests/e2e/diff/oas3-path-removed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -/pets/{petId} - ✖ major removed - Path `/pets/{petId}` was removed. (path-removed) - at base.yaml:11:5 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-property-removed/base.yaml b/tests/e2e/diff/oas3-property-removed/base.yaml deleted file mode 100644 index 166dda32ca..0000000000 --- a/tests/e2e/diff/oas3-property-removed/base.yaml +++ /dev/null @@ -1,19 +0,0 @@ -openapi: 3.1.0 -info: - title: property-removed - 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/revision.yaml b/tests/e2e/diff/oas3-property-removed/revision.yaml deleted file mode 100644 index c4484bc1c8..0000000000 --- a/tests/e2e/diff/oas3-property-removed/revision.yaml +++ /dev/null @@ -1,17 +0,0 @@ -openapi: 3.1.0 -info: - title: property-removed - 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/snapshot.txt b/tests/e2e/diff/oas3-property-removed/snapshot.txt deleted file mode 100644 index 51293630f2..0000000000 --- a/tests/e2e/diff/oas3-property-removed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major removed responses/200/content/application/json/schema/properties/tag - Property `tag` was removed. (property-removed) - at base.yaml:19:21 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-ref-target-changed/base.yaml b/tests/e2e/diff/oas3-ref-target-changed/base.yaml deleted file mode 100644 index a6cedb66c7..0000000000 --- a/tests/e2e/diff/oas3-ref-target-changed/base.yaml +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 3c8d3c5f8e..0000000000 --- a/tests/e2e/diff/oas3-ref-target-changed/revision.yaml +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 8eb61fb37a..0000000000 --- a/tests/e2e/diff/oas3-ref-target-changed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major modified responses/200/content/application/json/schema · $ref - 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:23 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major 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 deleted file mode 100644 index 00e85c0e6a..0000000000 --- a/tests/e2e/diff/oas3-request-body-became-required/base.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index e671eb370e..0000000000 --- a/tests/e2e/diff/oas3-request-body-became-required/revision.yaml +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 091a997861..0000000000 --- a/tests/e2e/diff/oas3-request-body-became-required/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -POST /pets - ✖ major modified requestBody · required - Request body became required. (request-body-became-required) - at revision.yaml:9:19 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-request-body-removed/base.yaml b/tests/e2e/diff/oas3-request-body-removed/base.yaml deleted file mode 100644 index ca860ce0d4..0000000000 --- a/tests/e2e/diff/oas3-request-body-removed/base.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index ada55feb27..0000000000 --- a/tests/e2e/diff/oas3-request-body-removed/revision.yaml +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index fc0be6af8f..0000000000 --- a/tests/e2e/diff/oas3-request-body-removed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -POST /pets - ✖ major removed requestBody - Request body was removed. (request-body-removed) - at base.yaml:9:9 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-required-properties-added/base.yaml b/tests/e2e/diff/oas3-required-properties-added/base.yaml deleted file mode 100644 index 37577815ff..0000000000 --- a/tests/e2e/diff/oas3-required-properties-added/base.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 1e8a03a1af..0000000000 --- a/tests/e2e/diff/oas3-required-properties-added/revision.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 91dcddee94..0000000000 --- a/tests/e2e/diff/oas3-required-properties-added/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -POST /pets - ✖ major modified requestBody/content/application/json/schema · required - Properties became required: `name`. (required-properties-added) - at revision.yaml:13:25 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-required-properties-removed/base.yaml b/tests/e2e/diff/oas3-required-properties-removed/base.yaml deleted file mode 100644 index ab23719a6c..0000000000 --- a/tests/e2e/diff/oas3-required-properties-removed/base.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 65baf80f5c..0000000000 --- a/tests/e2e/diff/oas3-required-properties-removed/revision.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 506ce00145..0000000000 --- a/tests/e2e/diff/oas3-required-properties-removed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major modified responses/200/content/application/json/schema · required - Properties are no longer required: `name`. (required-properties-removed) - at revision.yaml:15:27 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-response-header-removed/base.yaml b/tests/e2e/diff/oas3-response-header-removed/base.yaml deleted file mode 100644 index 3140ddd050..0000000000 --- a/tests/e2e/diff/oas3-response-header-removed/base.yaml +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 44a3476a70..0000000000 --- a/tests/e2e/diff/oas3-response-header-removed/revision.yaml +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index e758b7e0df..0000000000 --- a/tests/e2e/diff/oas3-response-header-removed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major removed responses/200/headers - All headers of response `200` were removed. (response-header-removed) - at base.yaml:12:13 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-response-removed/base.yaml b/tests/e2e/diff/oas3-response-removed/base.yaml deleted file mode 100644 index 141482e5b6..0000000000 --- a/tests/e2e/diff/oas3-response-removed/base.yaml +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index f23234e656..0000000000 --- a/tests/e2e/diff/oas3-response-removed/revision.yaml +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 492f6e2ad7..0000000000 --- a/tests/e2e/diff/oas3-response-removed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major removed responses/404 - Response `404` was removed. (response-removed) - at base.yaml:10:16 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-schema-combinator-changed/base.yaml b/tests/e2e/diff/oas3-schema-combinator-changed/base.yaml deleted file mode 100644 index 36a186a9a9..0000000000 --- a/tests/e2e/diff/oas3-schema-combinator-changed/base.yaml +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 83abd72a60..0000000000 --- a/tests/e2e/diff/oas3-schema-combinator-changed/revision.yaml +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 2887cd38a6..0000000000 --- a/tests/e2e/diff/oas3-schema-combinator-changed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -POST /pets - ✖ major removed requestBody/content/application/json/schema/oneOf/1 - `oneOf` subschema was removed. (schema-combinator-changed) - at base.yaml:14:19 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-schema-format-changed/base.yaml b/tests/e2e/diff/oas3-schema-format-changed/base.yaml deleted file mode 100644 index 4684285afa..0000000000 --- a/tests/e2e/diff/oas3-schema-format-changed/base.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 66899dba44..0000000000 --- a/tests/e2e/diff/oas3-schema-format-changed/revision.yaml +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 3e60a68651..0000000000 --- a/tests/e2e/diff/oas3-schema-format-changed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -POST /pets - ✖ major modified requestBody/content/application/json/schema/properties/id · format - `format` of `id` was added with value 'uuid'. (schema-format-changed) - at revision.yaml:16:27 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major 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 deleted file mode 100644 index ad7df78e04..0000000000 --- a/tests/e2e/diff/oas3-schema-type-widened-in-request/base.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index b2a87f4e0f..0000000000 --- a/tests/e2e/diff/oas3-schema-type-widened-in-request/revision.yaml +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 5068eb9397..0000000000 --- a/tests/e2e/diff/oas3-schema-type-widened-in-request/snapshot.txt +++ /dev/null @@ -1,10 +0,0 @@ -POST /pets - · patch modified requestBody/content/application/json/schema/properties/id · type - at revision.yaml:16:21 - -0 major, 0 minor, 1 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -✅ Diff passed. 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 deleted file mode 100644 index a4864fed53..0000000000 --- a/tests/e2e/diff/oas3-security-requirement-added-to-empty-list/base.yaml +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 2835f1989b..0000000000 --- a/tests/e2e/diff/oas3-security-requirement-added-to-empty-list/revision.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 9720d18396..0000000000 --- a/tests/e2e/diff/oas3-security-requirement-added-to-empty-list/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major added security/{apiKey} - Authentication became required. (security-requirement-added) - at revision.yaml:9:11 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-security-requirement-added/base.yaml b/tests/e2e/diff/oas3-security-requirement-added/base.yaml deleted file mode 100644 index 34665c03b2..0000000000 --- a/tests/e2e/diff/oas3-security-requirement-added/base.yaml +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 13eee83ffb..0000000000 --- a/tests/e2e/diff/oas3-security-requirement-added/revision.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 57da7e57f6..0000000000 --- a/tests/e2e/diff/oas3-security-requirement-added/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major added security - Authentication became required. (security-requirement-added) - at revision.yaml:9:9 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-security-scheme-changed/base.yaml b/tests/e2e/diff/oas3-security-scheme-changed/base.yaml deleted file mode 100644 index 7f277d9e1a..0000000000 --- a/tests/e2e/diff/oas3-security-scheme-changed/base.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index f8d4842e61..0000000000 --- a/tests/e2e/diff/oas3-security-scheme-changed/revision.yaml +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index a9f173af61..0000000000 --- a/tests/e2e/diff/oas3-security-scheme-changed/snapshot.txt +++ /dev/null @@ -1,17 +0,0 @@ -components - ✖ major modified securitySchemes/apiKey · type - `type` of security scheme `apiKey` changed from 'apiKey' to 'http'. (security-scheme-changed) - at revision.yaml:16:13 - · patch modified securitySchemes/apiKey · in - at revision.yaml:16:7 - · patch modified securitySchemes/apiKey · name - at revision.yaml:16:7 - · patch modified securitySchemes/apiKey · scheme - at revision.yaml:17:15 - -1 major, 0 minor, 3 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-security-scheme-removed/base.yaml b/tests/e2e/diff/oas3-security-scheme-removed/base.yaml deleted file mode 100644 index 228883f77e..0000000000 --- a/tests/e2e/diff/oas3-security-scheme-removed/base.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index e961d6d125..0000000000 --- a/tests/e2e/diff/oas3-security-scheme-removed/revision.yaml +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 10bb81faed..0000000000 --- a/tests/e2e/diff/oas3-security-scheme-removed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -components - ✖ major removed securitySchemes/basicAuth - Security scheme `basicAuth` was removed. (security-scheme-removed) - at base.yaml:17:7 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-security-scopes-added/base.yaml b/tests/e2e/diff/oas3-security-scopes-added/base.yaml deleted file mode 100644 index 2222b52c68..0000000000 --- a/tests/e2e/diff/oas3-security-scopes-added/base.yaml +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index bb38c0e13f..0000000000 --- a/tests/e2e/diff/oas3-security-scopes-added/revision.yaml +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 6ed4b7f1a4..0000000000 --- a/tests/e2e/diff/oas3-security-scopes-added/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -GET /pets - ✖ major modified security/{oauth} · oauth - Security scheme `oauth` requires new scopes: 'pets:write'. (security-scopes-added) - at revision.yaml:10:13 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/oas3-string-length-changed/base.yaml b/tests/e2e/diff/oas3-string-length-changed/base.yaml deleted file mode 100644 index 06b43c8c71..0000000000 --- a/tests/e2e/diff/oas3-string-length-changed/base.yaml +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 463e03674f..0000000000 --- a/tests/e2e/diff/oas3-string-length-changed/revision.yaml +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 26ff6f62ea..0000000000 --- a/tests/e2e/diff/oas3-string-length-changed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -POST /pets - ✖ major modified requestBody/content/application/json/schema/properties/name · maxLength - `maxLength` of `name` changed from '100' to '10'. (string-length-changed) - at revision.yaml:16:30 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major 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 deleted file mode 100644 index 065c27d592..0000000000 --- a/tests/e2e/diff/oas3-webhook-payload-property-removed/base.yaml +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 93c17528e0..0000000000 --- a/tests/e2e/diff/oas3-webhook-payload-property-removed/revision.yaml +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 8f90dffaf5..0000000000 --- a/tests/e2e/diff/oas3-webhook-payload-property-removed/snapshot.txt +++ /dev/null @@ -1,11 +0,0 @@ -POST newPet (webhook) - ✖ major removed requestBody/content/application/json/schema/properties/tag - Property `tag` was removed. (property-removed) - at base.yaml:18:19 - -1 major, 0 minor, 0 patch. - - -base.yaml vs revision.yaml: diff processed in ms - -❌ Diff failed with 1 major change. diff --git a/tests/e2e/diff/openapi-3-0-to-3-1/base.yaml b/tests/e2e/diff/openapi-3-0-to-3-1/base.yaml new file mode 100644 index 0000000000..232ecc3755 --- /dev/null +++ b/tests/e2e/diff/openapi-3-0-to-3-1/base.yaml @@ -0,0 +1,21 @@ +openapi: 3.0.3 +info: { title: Cafe, version: 1.0.0 } +paths: + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } +components: + schemas: + Order: + type: object + properties: + note: { type: string, nullable: true } diff --git a/tests/e2e/diff/openapi-3-0-to-3-1/revision.yaml b/tests/e2e/diff/openapi-3-0-to-3-1/revision.yaml new file mode 100644 index 0000000000..500571b66e --- /dev/null +++ b/tests/e2e/diff/openapi-3-0-to-3-1/revision.yaml @@ -0,0 +1,21 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.0.0 } +paths: + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } +components: + schemas: + Order: + type: object + properties: + note: { type: [string, 'null'] } diff --git a/tests/e2e/diff/openapi-3-0-to-3-1/snapshot.txt b/tests/e2e/diff/openapi-3-0-to-3-1/snapshot.txt new file mode 100644 index 0000000000..494bf49e27 --- /dev/null +++ b/tests/e2e/diff/openapi-3-0-to-3-1/snapshot.txt @@ -0,0 +1,16 @@ +document + · patch modified openapi + at revision.yaml:1:10 + +components + · patch modified schemas/Order/properties/note · nullable + at revision.yaml:21:15 + · patch modified schemas/Order/properties/note · type + at revision.yaml:21:23 + +0 major, 0 minor, 3 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +✅ Diff passed. diff --git a/tests/e2e/diff/openapi-breaking-changes/base.yaml b/tests/e2e/diff/openapi-breaking-changes/base.yaml new file mode 100644 index 0000000000..4f1c8df49b --- /dev/null +++ b/tests/e2e/diff/openapi-breaking-changes/base.yaml @@ -0,0 +1,65 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.2.0 } +servers: + - url: https://api.cafe.test +security: + - apiKey: [] +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + - { name: search, in: query, schema: { type: string } } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } } + /menu/{id}: + get: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': { description: OK } + delete: + responses: + '204': { description: Deleted } + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + headers: + Location: { schema: { type: string } } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: + { type: object, properties: { orderId: { type: string }, eta: { type: string } } } + responses: + '200': { description: OK } +components: + securitySchemes: + apiKey: { type: apiKey, in: header, name: X-Key } + schemas: + MenuItem: + type: object + properties: + name: { type: string } + price: { type: number } + status: { type: string, enum: [available, sold-out] } + Order: + type: object + required: [items] + properties: + items: { type: array, items: { type: string } } + note: { type: string, maxLength: 200 } diff --git a/tests/e2e/diff/openapi-breaking-changes/revision.yaml b/tests/e2e/diff/openapi-breaking-changes/revision.yaml new file mode 100644 index 0000000000..24ebf27912 --- /dev/null +++ b/tests/e2e/diff/openapi-breaking-changes/revision.yaml @@ -0,0 +1,62 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.3.0 } +servers: + - url: https://api.cafe.test +security: + - apiKey: [] +paths: + /menu: + get: + summary: List the whole menu + parameters: + - { name: limit, in: query, required: true, schema: { type: integer } } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } } + /menu/{menuItemId}: + get: + parameters: + - { name: menuItemId, in: path, required: true, schema: { type: string } } + responses: + '200': { description: OK } + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + /specials: + get: + responses: + '200': { description: OK } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: { type: object, properties: { orderId: { type: string } } } + responses: + '200': { description: OK } +components: + securitySchemes: + apiKey: { type: apiKey, in: header, name: X-Key } + schemas: + MenuItem: + type: object + properties: + name: { type: string } + status: { type: string, enum: [available, sold-out, seasonal] } + Order: + type: object + required: [items, pickupAt] + properties: + items: { type: array, items: { type: string } } + note: { type: string, maxLength: 100 } + pickupAt: { type: string, format: date-time } diff --git a/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt b/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt new file mode 100644 index 0000000000..1c959a11f5 --- /dev/null +++ b/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt @@ -0,0 +1,63 @@ +components + ✖ major removed schemas/MenuItem/properties/price + Property `price` of `MenuItem` was removed. (property-removed) + at base.yaml:58:16 + ✖ major modified schemas/MenuItem/properties/status · enum + Enum of `status` gained values: 'seasonal'. (enum-values-added) + at revision.yaml:55:39 + ✖ major modified schemas/Order · required + Properties of `Order` became required: `pickupAt`. (required-properties-added) + at revision.yaml:58:17 + ✖ major modified schemas/Order/properties/note · maxLength + `maxLength` of `note` changed from '200' to '100'. (string-length-changed) + at revision.yaml:61:42 + ✔ minor added schemas/Order/properties/pickupAt + at revision.yaml:62:19 + +info + · patch modified version + at revision.yaml:2:31 + +GET /menu + ✖ major modified parameters/{query:limit} · required + `limit` query parameter became required. (parameter-became-required) + at revision.yaml:12:47 + ✖ major removed parameters/{query:search} + `search` query parameter was removed. (parameter-removed) + at base.yaml:13:11 + · patch modified summary + at revision.yaml:10:16 + +/menu/{menuItemId} + · patch modified key + at revision.yaml:20:5 + +DELETE /menu/{menuItemId} + ✖ major removed + Operation `DELETE /menu/{menuItemId}` was removed. (operation-removed) + at base.yaml:27:7 + +GET /menu/{menuItemId} + · patch modified parameters/{path:0} · name + at revision.yaml:22:19 + +POST /orders + ✖ major removed responses/201/headers + All headers of response `201` were removed. (response-header-removed) + at base.yaml:39:13 + +/specials + ✔ minor added + at revision.yaml:35:5 + +POST orderReady (webhook) + ✖ major removed requestBody/content/application/json/schema/properties/eta + Property `eta` was removed. (property-removed) + at base.yaml:47:77 + +9 major, 2 minor, 4 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ info.version went 1.2.0 → 1.3.0, but these changes require a major bump (2.0.0). diff --git a/tests/e2e/diff/openapi-breaking-changes/snapshot.txt b/tests/e2e/diff/openapi-breaking-changes/snapshot.txt new file mode 100644 index 0000000000..812db44333 --- /dev/null +++ b/tests/e2e/diff/openapi-breaking-changes/snapshot.txt @@ -0,0 +1,63 @@ +components + ✖ major removed schemas/MenuItem/properties/price + Property `price` of `MenuItem` was removed. (property-removed) + at base.yaml:58:16 + ✖ major modified schemas/MenuItem/properties/status · enum + Enum of `status` gained values: 'seasonal'. (enum-values-added) + at revision.yaml:55:39 + ✖ major modified schemas/Order · required + Properties of `Order` became required: `pickupAt`. (required-properties-added) + at revision.yaml:58:17 + ✖ major modified schemas/Order/properties/note · maxLength + `maxLength` of `note` changed from '200' to '100'. (string-length-changed) + at revision.yaml:61:42 + ✔ minor added schemas/Order/properties/pickupAt + at revision.yaml:62:19 + +info + · patch modified version + at revision.yaml:2:31 + +GET /menu + ✖ major modified parameters/{query:limit} · required + `limit` query parameter became required. (parameter-became-required) + at revision.yaml:12:47 + ✖ major removed parameters/{query:search} + `search` query parameter was removed. (parameter-removed) + at base.yaml:13:11 + · patch modified summary + at revision.yaml:10:16 + +/menu/{menuItemId} + · patch modified key + at revision.yaml:20:5 + +DELETE /menu/{menuItemId} + ✖ major removed + Operation `DELETE /menu/{menuItemId}` was removed. (operation-removed) + at base.yaml:27:7 + +GET /menu/{menuItemId} + · patch modified parameters/{path:0} · name + at revision.yaml:22:19 + +POST /orders + ✖ major removed responses/201/headers + All headers of response `201` were removed. (response-header-removed) + at base.yaml:39:13 + +/specials + ✔ minor added + at revision.yaml:35:5 + +POST orderReady (webhook) + ✖ major removed requestBody/content/application/json/schema/properties/eta + Property `eta` was removed. (property-removed) + at base.yaml:47:77 + +9 major, 2 minor, 4 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 9 major changes. diff --git a/tests/e2e/diff/openapi-compatible-changes/base.yaml b/tests/e2e/diff/openapi-compatible-changes/base.yaml new file mode 100644 index 0000000000..4f1c8df49b --- /dev/null +++ b/tests/e2e/diff/openapi-compatible-changes/base.yaml @@ -0,0 +1,65 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.2.0 } +servers: + - url: https://api.cafe.test +security: + - apiKey: [] +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + - { name: search, in: query, schema: { type: string } } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } } + /menu/{id}: + get: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': { description: OK } + delete: + responses: + '204': { description: Deleted } + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + headers: + Location: { schema: { type: string } } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: + { type: object, properties: { orderId: { type: string }, eta: { type: string } } } + responses: + '200': { description: OK } +components: + securitySchemes: + apiKey: { type: apiKey, in: header, name: X-Key } + schemas: + MenuItem: + type: object + properties: + name: { type: string } + price: { type: number } + status: { type: string, enum: [available, sold-out] } + Order: + type: object + required: [items] + properties: + items: { type: array, items: { type: string } } + note: { type: string, maxLength: 200 } diff --git a/tests/e2e/diff/openapi-compatible-changes/revision.yaml b/tests/e2e/diff/openapi-compatible-changes/revision.yaml new file mode 100644 index 0000000000..48d1c84f03 --- /dev/null +++ b/tests/e2e/diff/openapi-compatible-changes/revision.yaml @@ -0,0 +1,70 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.3.0 } +servers: + - url: https://api.cafe.test +security: + - apiKey: [] +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + - { name: search, in: query, schema: { type: string } } + - { name: category, in: query, schema: { type: string } } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } } + /menu/{id}: + get: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': { description: OK } + delete: + responses: + '204': { description: Deleted } + /specials: + get: + responses: + '200': { description: OK } + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + headers: + Location: { schema: { type: string } } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: + { type: object, properties: { orderId: { type: string }, eta: { type: string } } } + responses: + '200': { description: OK } +components: + securitySchemes: + apiKey: { type: apiKey, in: header, name: X-Key } + schemas: + MenuItem: + type: object + properties: + name: { type: string } + price: { type: number } + status: { type: string, enum: [available, sold-out] } + Order: + type: object + required: [items] + properties: + items: { type: array, items: { type: string } } + note: { type: string, maxLength: 200 } diff --git a/tests/e2e/diff/openapi-compatible-changes/snapshot-fail-on-minor.txt b/tests/e2e/diff/openapi-compatible-changes/snapshot-fail-on-minor.txt new file mode 100644 index 0000000000..bc633d0d12 --- /dev/null +++ b/tests/e2e/diff/openapi-compatible-changes/snapshot-fail-on-minor.txt @@ -0,0 +1,18 @@ +info + · patch modified version + at revision.yaml:2:31 + +GET /menu + ✔ minor added parameters/{query:category} + at revision.yaml:14:11 + +/specials + ✔ minor added + at revision.yaml:31:5 + +0 major, 2 minor, 1 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 2 minor changes. diff --git a/tests/e2e/diff/openapi-compatible-changes/snapshot.txt b/tests/e2e/diff/openapi-compatible-changes/snapshot.txt new file mode 100644 index 0000000000..dee03a0009 --- /dev/null +++ b/tests/e2e/diff/openapi-compatible-changes/snapshot.txt @@ -0,0 +1,18 @@ +info + · patch modified version + at revision.yaml:2:31 + +GET /menu + ✔ minor added parameters/{query:category} + at revision.yaml:14:11 + +/specials + ✔ minor added + at revision.yaml:31:5 + +0 major, 2 minor, 1 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +✅ Diff passed. diff --git a/tests/e2e/diff/oas3-parameter-added-required/base.yaml b/tests/e2e/diff/plugin/base.yaml similarity index 52% rename from tests/e2e/diff/oas3-parameter-added-required/base.yaml rename to tests/e2e/diff/plugin/base.yaml index 5f02acab87..db01368d95 100644 --- a/tests/e2e/diff/oas3-parameter-added-required/base.yaml +++ b/tests/e2e/diff/plugin/base.yaml @@ -1,9 +1,8 @@ openapi: 3.1.0 -info: - title: parameter-added-required - version: '1.0' +info: { title: Cafe, version: 1.0.0 } paths: - /pets: + /menu: get: + tags: [menu, public] responses: '200': { description: OK } diff --git a/tests/e2e/diff/plugin/plugins/cafe.cjs b/tests/e2e/diff/plugin/plugins/cafe.cjs new file mode 100644 index 0000000000..880895fc54 --- /dev/null +++ b/tests/e2e/diff/plugin/plugins/cafe.cjs @@ -0,0 +1,18 @@ +// Clients of the cafe route operations by their tags, so removing one is a breaking change. +function TagRemoved() { + return { + Operation(change, { report }) { + if (change.kind !== 'modified' || change.property !== 'tags') return; + const after = change.revision.value ?? []; + const removed = (change.base.value ?? []).filter((tag) => !after.includes(tag)); + if (removed.length) report({ message: `Tags removed: ${removed.join(', ')}.` }); + }, + }; +} + +module.exports = function cafePlugin() { + return { + id: 'cafe', + diff: { oas3: { 'tag-removed': TagRemoved } }, + }; +}; diff --git a/tests/e2e/diff/plugin/redocly.yaml b/tests/e2e/diff/plugin/redocly.yaml new file mode 100644 index 0000000000..ff7b3ea68c --- /dev/null +++ b/tests/e2e/diff/plugin/redocly.yaml @@ -0,0 +1,6 @@ +extends: + - diff-recommended +plugins: + - plugins/cafe.cjs +diff: + cafe/tag-removed: major diff --git a/tests/e2e/diff/oas3-path-removed/revision.yaml b/tests/e2e/diff/plugin/revision.yaml similarity index 55% rename from tests/e2e/diff/oas3-path-removed/revision.yaml rename to tests/e2e/diff/plugin/revision.yaml index b47c85f0e4..91b4b4bd5d 100644 --- a/tests/e2e/diff/oas3-path-removed/revision.yaml +++ b/tests/e2e/diff/plugin/revision.yaml @@ -1,9 +1,8 @@ openapi: 3.1.0 -info: - title: path-removed - version: '1.0' +info: { title: Cafe, version: 1.0.0 } paths: - /pets: + /menu: get: + tags: [menu] responses: '200': { description: OK } diff --git a/tests/e2e/diff/async3-server-removed/snapshot.txt b/tests/e2e/diff/plugin/snapshot.txt similarity index 52% rename from tests/e2e/diff/async3-server-removed/snapshot.txt rename to tests/e2e/diff/plugin/snapshot.txt index de55e77f81..13e52842f0 100644 --- a/tests/e2e/diff/async3-server-removed/snapshot.txt +++ b/tests/e2e/diff/plugin/snapshot.txt @@ -1,7 +1,7 @@ -server staging - ✖ major removed - Server `staging` was removed. (server-removed) - at base.yaml:10:5 +GET /menu + ✖ major modified tags + Tags removed: public. (cafe/tag-removed) + at revision.yaml:6:13 1 major, 0 minor, 0 patch. diff --git a/tests/e2e/diff/rules.test.ts b/tests/e2e/diff/rules.test.ts deleted file mode 100644 index 9a693844fc..0000000000 --- a/tests/e2e/diff/rules.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -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-ref-replaced', - ruleId: 'parameter-added-required', - describes: 'a referenced parameter is swapped for a required one', - }, - { - 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', - ruleId: 'property-removed', - 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', - 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-allof-ref-replaced', - ruleId: 'schema-combinator-changed', - describes: 'a referenced `allOf` member of a response is swapped for another', - }, - { - 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', - describes: 'a sent payload drops a property', - }, -]; - -/** - * Edits that must NOT be reported as a major. 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', - }, - { - fixture: 'oas3-parameter-refs-reordered', - describes: 'referenced parameters are listed in another order', - }, -]; - -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 majorChanges = runJsonDiff(fixture).changes.filter( - (change) => change.impact === 'major' - ); - expect(majorChanges.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.major).toBe(0); - - await expect(runDiff(fixture)).toMatchFileSnapshot( - join(fixturePath(fixture), 'snapshot.txt') - ); - }); - } -}); diff --git a/tests/e2e/diff/unresolved-ref/base.yaml b/tests/e2e/diff/unresolved-ref/base.yaml deleted file mode 100644 index 6f19cbef3a..0000000000 --- a/tests/e2e/diff/unresolved-ref/base.yaml +++ /dev/null @@ -1,19 +0,0 @@ -openapi: 3.1.0 -info: - title: Pets - version: '1.0' -paths: - /pets: - post: - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/Missing' - responses: - '201': - description: Created -components: - schemas: - Pet: - type: object diff --git a/tests/e2e/diff/unresolved-ref/revision.yaml b/tests/e2e/diff/unresolved-ref/revision.yaml deleted file mode 100644 index 6f19cbef3a..0000000000 --- a/tests/e2e/diff/unresolved-ref/revision.yaml +++ /dev/null @@ -1,19 +0,0 @@ -openapi: 3.1.0 -info: - title: Pets - version: '1.0' -paths: - /pets: - post: - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/Missing' - responses: - '201': - description: Created -components: - schemas: - Pet: - type: object From e146be09fb727f8adb7fca8d2c800a2194562153 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:12:31 +0300 Subject: [PATCH 50/57] fix(diff): throw the core error classes now that the CLI error helpers are gone --- packages/cli/src/commands/diff/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts index dd609b358c..3a6ef1db75 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -1,6 +1,8 @@ import { + AbortFlowError, bundle, diffDocuments, + HandledError, formatProblems, getTotals, logger, @@ -9,7 +11,6 @@ import { import { green } from 'colorette'; import { writeFileSync } from 'node:fs'; -import { AbortFlowError, exitWithError } from '../../utils/error.js'; import { getFallbackApisOrExit, printExecutionTime } from '../../utils/miscellaneous.js'; import type { CommandArgs } from '../../wrapper.js'; import { checkVersion } from './check-version.js'; @@ -20,7 +21,7 @@ import type { DiffArgv } from './types.js'; export async function handleDiff({ argv, config, collectSpecData }: CommandArgs) { if (argv.output && argv.format === 'github-actions') { - return exitWithError( + throw new HandledError( `The github-actions format prints to stdout only. To write a report to a file, use one of these formats: ${Object.keys(diffReportFormats).join(', ')}.` ); } From 185629f9ea0a0d3deeed9a8d9672de062220eb18 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:07:01 +0300 Subject: [PATCH 51/57] fix(diff): judge security, required parameters, bounds, readOnly and writeOnly, whole-map removals and untyped schemas like a client would, and merge overlapping rules --- .../__snapshots__/format.test.ts.snap | 10 +- packages/core/src/config/diff-recommended.ts | 6 +- .../__tests__/media-type-removed.test.ts | 40 ++- .../__tests__/numeric-range-changed.test.ts | 181 +++++++++- .../parameter-added-required.test.ts | 145 -------- .../parameter-became-required.test.ts | 122 +++++++ .../rules/__tests__/property-removed.test.ts | 93 +++++ .../__tests__/ref-target-changed.test.ts | 35 +- .../required-properties-added.test.ts | 53 +++ .../required-properties-removed.test.ts | 53 +++ .../__tests__/schema-type-changed.test.ts | 106 ++++++ .../security-requirement-added.test.ts | 129 ------- .../security-requirement-changed.test.ts | 326 ++++++++++++++++++ .../__tests__/security-scopes-added.test.ts | 73 ---- ...t.ts => string-constraint-changed.test.ts} | 10 +- packages/core/src/diff/rules/index.ts | 12 +- .../core/src/diff/rules/media-type-removed.ts | 4 + .../src/diff/rules/numeric-range-changed.ts | 102 +++++- .../diff/rules/parameter-added-required.ts | 24 -- .../diff/rules/parameter-became-required.ts | 25 +- .../core/src/diff/rules/property-removed.ts | 8 + .../core/src/diff/rules/ref-target-changed.ts | 10 +- .../diff/rules/required-properties-added.ts | 8 +- .../diff/rules/required-properties-removed.ts | 8 +- .../src/diff/rules/schema-type-changed.ts | 15 +- .../diff/rules/security-requirement-added.ts | 22 -- .../rules/security-requirement-changed.ts | 61 ++++ .../src/diff/rules/security-scopes-added.ts | 14 - .../diff/rules/string-constraint-changed.ts | 3 + .../src/diff/rules/string-length-changed.ts | 3 - packages/core/src/diff/rules/utils.ts | 50 +-- packages/core/src/diff/specs/oas3.ts | 9 + .../diff/configuration/snapshot-skip-rule.txt | 2 +- tests/e2e/diff/configuration/snapshot.txt | 2 +- .../snapshot-check-version.txt | 2 +- .../openapi-breaking-changes/snapshot.txt | 2 +- 36 files changed, 1276 insertions(+), 492 deletions(-) delete mode 100644 packages/core/src/diff/rules/__tests__/parameter-added-required.test.ts delete mode 100644 packages/core/src/diff/rules/__tests__/security-requirement-added.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/security-requirement-changed.test.ts delete mode 100644 packages/core/src/diff/rules/__tests__/security-scopes-added.test.ts rename packages/core/src/diff/rules/__tests__/{string-length-changed.test.ts => string-constraint-changed.test.ts} (93%) delete mode 100644 packages/core/src/diff/rules/parameter-added-required.ts delete mode 100644 packages/core/src/diff/rules/security-requirement-added.ts create mode 100644 packages/core/src/diff/rules/security-requirement-changed.ts delete mode 100644 packages/core/src/diff/rules/security-scopes-added.ts create mode 100644 packages/core/src/diff/rules/string-constraint-changed.ts delete mode 100644 packages/core/src/diff/rules/string-length-changed.ts diff --git a/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap b/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap index d34a9e426d..4394985af6 100644 --- a/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap +++ b/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap @@ -86,7 +86,7 @@ exports[`htmlDiff > should render a self-contained page 1`] = ` major modified #/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note · pattern - \`pattern\` of \`note\` changed from 'a' to 'a|b'. string-length-changed + \`pattern\` of \`note\` changed from 'a' to 'a|b'. string-constraint-changed
{
   "base": {
@@ -262,7 +262,7 @@ exports[`jsonDiff > should render the result with each change located in its fil
       "impact": "major",
       "verdicts": [
         {
-          "ruleId": "string-length-changed",
+          "ruleId": "string-constraint-changed",
           "impact": "major",
           "message": "\`pattern\` of \`note\` changed from 'a' to 'a|b'.",
           "location": {
@@ -383,7 +383,7 @@ exports[`markdownDiff > should render one table row per change 1`] = `
 | --- | --- | --- | --- | --- |
 | ⚪ patch | modified | \`#/paths/~1menu/get · summary\` | revision.yaml:6:16 |  |
 | 🔴 major | modified | \`#/paths/~1menu/get/parameters/{query:limit} · required\` | revision.yaml:8:47 | \\\`limit\\\` query parameter became required. \`parameter-became-required\` |
-| 🔴 major | modified | \`#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note · pattern\` | revision.yaml:15:68 | \\\`pattern\\\` of \\\`note\\\` changed from 'a' to 'a\\|b'. \`string-length-changed\` |
+| 🔴 major | modified | \`#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note · pattern\` | revision.yaml:15:68 | \\\`pattern\\\` of \\\`note\\\` changed from 'a' to 'a\\|b'. \`string-constraint-changed\` |
 | 🔴 major | removed | \`#/paths/~1orders/delete\` | base.yaml:18:7 | Operation \\\`DELETE /orders\\\` was removed. \`operation-removed\` |
 | 🔴 major | removed | \`#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta\` | base.yaml:26:42 | Property \\\`eta\\\` was removed. \`property-removed\` |
 | 🟢 minor | added | \`#/components\` | revision.yaml:26:3 |  |"
@@ -393,7 +393,7 @@ exports[`nextVersionDiff > should bump the base version as far as the changes re
 
 exports[`printGithubActions > should annotate each verdict of a breaking change as an error and skip the rest 1`] = `
 "::error title=parameter-became-required,file=revision.yaml,line=8,col=47,endLine=8,endColumn=51::\`limit\` query parameter became required.
-::error title=string-length-changed,file=revision.yaml,line=15,col=68,endLine=15,endColumn=73::\`pattern\` of \`note\` changed from 'a' to 'a|b'.
+::error title=string-constraint-changed,file=revision.yaml,line=15,col=68,endLine=15,endColumn=73::\`pattern\` of \`note\` changed from 'a' to 'a|b'.
 ::error title=operation-removed,file=base.yaml,line=18,col=7,endLine=19,endColumn=55::Operation \`DELETE /orders\` was removed.
 ::error title=property-removed,file=base.yaml,line=26,col=42,endLine=26,endColumn=58::Property \`eta\` was removed.
 "
@@ -428,7 +428,7 @@ exports[`stylishDiff > should group the changes per endpoint and webhook, worst
 
 POST /orders
   ✖ major  modified  requestBody/content/application/json/schema/properties/note · pattern
-      \`pattern\` of \`note\` changed from 'a' to 'a|b'. (string-length-changed)
+      \`pattern\` of \`note\` changed from 'a' to 'a|b'. (string-constraint-changed)
       at revision.yaml:15:68
 
 DELETE /orders
diff --git a/packages/core/src/config/diff-recommended.ts b/packages/core/src/config/diff-recommended.ts
index 74f3b75c14..7445010a0f 100644
--- a/packages/core/src/config/diff-recommended.ts
+++ b/packages/core/src/config/diff-recommended.ts
@@ -14,7 +14,6 @@ export const defaultDiffRules: Record = {
   'numeric-range-changed': 'major',
   'operation-action-changed': 'major',
   'operation-removed': 'major',
-  'parameter-added-required': 'major',
   'parameter-became-required': 'major',
   'parameter-removed': 'major',
   'parameter-serialization-changed': 'major',
@@ -30,12 +29,11 @@ export const defaultDiffRules: Record = {
   'schema-combinator-changed': 'major',
   'schema-format-changed': 'major',
   'schema-type-changed': 'major',
-  'security-requirement-added': 'major',
+  'security-requirement-changed': 'major',
   'security-scheme-changed': 'major',
   'security-scheme-removed': 'major',
-  'security-scopes-added': 'major',
   'server-removed': 'major',
-  'string-length-changed': 'major',
+  'string-constraint-changed': 'major',
 };
 
 const diffRecommended: RawGovernanceConfig<'built-in'> = {
diff --git a/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts b/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts
index 2e1736ac56..8468209e5c 100644
--- a/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts
+++ b/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts
@@ -5,7 +5,7 @@ import { createConfig } from '../../../config/index.js';
 import { makeDocumentFromString } from '../../../resolve.js';
 import { diffDocuments } from '../../index.js';
 
-const cafe = (content: string) => outdent`
+const cafe = (content?: string) => outdent`
   openapi: 3.1.0
   info: { title: Cafe, version: 1.0.0 }
   paths:
@@ -14,7 +14,7 @@ const cafe = (content: string) => outdent`
         responses:
           '200':
             description: OK
-            content: ${content}
+            ${content === undefined ? '' : `content: ${content}`}
 `;
 
 const json = 'application/json: { schema: { type: array } }';
@@ -54,4 +54,40 @@ describe('media-type-removed', () => {
       ]
     `);
   });
+
+  it('should report every media type leaving with the whole content map', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(cafe(`{ ${json} }`), 'base.yaml'),
+      revision: makeDocumentFromString(cafe(), 'revision.yaml'),
+      config: await createConfig({ diff: { 'media-type-removed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1menu/get/responses/200/content",
+            "value": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                },
+              },
+            },
+          },
+          "impact": "major",
+          "key": "#/paths/~1menu/get/responses/200/content",
+          "kind": "removed",
+          "verdicts": [
+            {
+              "impact": "major",
+              "location": "base.yaml#/paths/~1menu/get/responses/200/content",
+              "message": "All media types were removed.",
+              "ruleId": "media-type-removed",
+            },
+          ],
+        },
+      ]
+    `);
+  });
 });
diff --git a/packages/core/src/diff/rules/__tests__/numeric-range-changed.test.ts b/packages/core/src/diff/rules/__tests__/numeric-range-changed.test.ts
index 3c4be4bacc..4169d73b68 100644
--- a/packages/core/src/diff/rules/__tests__/numeric-range-changed.test.ts
+++ b/packages/core/src/diff/rules/__tests__/numeric-range-changed.test.ts
@@ -50,7 +50,7 @@ describe('numeric-range-changed', () => {
             {
               "impact": "major",
               "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum",
-              "message": "\`maximum\` changed from '20' to '10'.",
+              "message": "Upper bound changed from '<= 20' to '<= 10'.",
               "ruleId": "numeric-range-changed",
             },
           ],
@@ -115,7 +115,7 @@ describe('numeric-range-changed', () => {
             {
               "impact": "major",
               "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum",
-              "message": "\`minimum\` changed from '1' to '0'.",
+              "message": "Lower bound changed from '>= 1' to '>= 0'.",
               "ruleId": "numeric-range-changed",
             },
           ],
@@ -123,4 +123,181 @@ describe('numeric-range-changed', () => {
       ]
     `);
   });
+
+  it('should not report an OpenAPI 3.0 bound written the OpenAPI 3.1 way', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(
+        cafe('{ type: number, minimum: 0, exclusiveMinimum: true }').replace('3.1.0', '3.0.3'),
+        'base.yaml'
+      ),
+      revision: makeDocumentFromString(
+        cafe('{ type: number, exclusiveMinimum: 0 }'),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'numeric-range-changed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/openapi",
+            "value": "3.0.3",
+          },
+          "impact": "patch",
+          "key": "#/",
+          "kind": "modified",
+          "property": "openapi",
+          "revision": {
+            "location": "revision.yaml#/openapi",
+            "value": "3.1.0",
+          },
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum",
+            "value": 0,
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+          "kind": "modified",
+          "property": "minimum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+            "value": undefined,
+          },
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/exclusiveMinimum",
+            "value": true,
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+          "kind": "modified",
+          "property": "exclusiveMinimum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/exclusiveMinimum",
+            "value": 0,
+          },
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum",
+            "value": 0,
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+          "kind": "modified",
+          "property": "minimum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+            "value": undefined,
+          },
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/exclusiveMinimum",
+            "value": true,
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+          "kind": "modified",
+          "property": "exclusiveMinimum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/exclusiveMinimum",
+            "value": 0,
+          },
+          "verdicts": [],
+        },
+      ]
+    `);
+  });
+
+  it('should report a bound that became exclusive in a request', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(cafe('{ type: number, minimum: 0 }'), 'base.yaml'),
+      revision: makeDocumentFromString(
+        cafe('{ type: number, exclusiveMinimum: 0 }'),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'numeric-range-changed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum",
+            "value": 0,
+          },
+          "impact": "major",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+          "kind": "modified",
+          "property": "minimum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+            "value": undefined,
+          },
+          "verdicts": [
+            {
+              "impact": "major",
+              "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+              "message": "Lower bound changed from '>= 0' to '> 0'.",
+              "ruleId": "numeric-range-changed",
+            },
+          ],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+            "value": undefined,
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+          "kind": "modified",
+          "property": "exclusiveMinimum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/exclusiveMinimum",
+            "value": 0,
+          },
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum",
+            "value": 0,
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+          "kind": "modified",
+          "property": "minimum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+            "value": undefined,
+          },
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+            "value": undefined,
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+          "kind": "modified",
+          "property": "exclusiveMinimum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/exclusiveMinimum",
+            "value": 0,
+          },
+          "verdicts": [],
+        },
+      ]
+    `);
+  });
 });
diff --git a/packages/core/src/diff/rules/__tests__/parameter-added-required.test.ts b/packages/core/src/diff/rules/__tests__/parameter-added-required.test.ts
deleted file mode 100644
index 984b4caddd..0000000000
--- a/packages/core/src/diff/rules/__tests__/parameter-added-required.test.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import { outdent } from 'outdent';
-
-import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
-import { createConfig } from '../../../config/index.js';
-import { makeDocumentFromString } from '../../../resolve.js';
-import { diffDocuments } from '../../index.js';
-
-const cafe = (parameters: string) => outdent`
-  openapi: 3.1.0
-  info: { title: Cafe, version: 1.0.0 }
-  paths:
-    /menu:
-      get:
-        parameters: ${parameters}
-        responses:
-          '200': { description: OK }
-  components:
-    parameters:
-      Sort: { name: sort, in: query, schema: { type: string } }
-      Category: { name: category, in: query, required: true, schema: { type: string } }
-`;
-
-const search = '{ name: search, in: query, schema: { type: string } }';
-
-describe('parameter-added-required', () => {
-  it('should report a new required parameter, not a new optional one', async () => {
-    const result = diffDocuments({
-      base: makeDocumentFromString(cafe(`[${search}]`), 'base.yaml'),
-      revision: makeDocumentFromString(
-        cafe(
-          `[${search}, { name: limit, in: query, schema: { type: integer } }, { name: locale, in: header, required: true, schema: { type: string } }]`
-        ),
-        'revision.yaml'
-      ),
-      config: await createConfig({ diff: { 'parameter-added-required': 'major' } }),
-    });
-
-    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
-      [
-        {
-          "impact": "minor",
-          "key": "#/paths/~1menu/get/parameters/{query:limit}",
-          "kind": "added",
-          "revision": {
-            "location": "revision.yaml#/paths/~1menu/get/parameters/1",
-            "value": {
-              "in": "query",
-              "name": "limit",
-              "schema": {
-                "type": "integer",
-              },
-            },
-          },
-          "verdicts": [],
-        },
-        {
-          "impact": "major",
-          "key": "#/paths/~1menu/get/parameters/{header:locale}",
-          "kind": "added",
-          "revision": {
-            "location": "revision.yaml#/paths/~1menu/get/parameters/2",
-            "value": {
-              "in": "header",
-              "name": "locale",
-              "required": true,
-              "schema": {
-                "type": "string",
-              },
-            },
-          },
-          "verdicts": [
-            {
-              "impact": "major",
-              "location": "revision.yaml#/paths/~1menu/get/parameters/2",
-              "message": "Required \`locale\` header parameter was added.",
-              "ruleId": "parameter-added-required",
-            },
-          ],
-        },
-      ]
-    `);
-  });
-
-  it('should read whether a referenced parameter is required from what it points at', async () => {
-    const result = diffDocuments({
-      base: makeDocumentFromString(cafe("[{ $ref: '#/components/parameters/Sort' }]"), 'base.yaml'),
-      revision: makeDocumentFromString(
-        cafe(
-          "[{ $ref: '#/components/parameters/Sort' }, { $ref: '#/components/parameters/Category' }]"
-        ),
-        'revision.yaml'
-      ),
-      config: await createConfig({ diff: { 'parameter-added-required': 'major' } }),
-    });
-
-    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
-      [
-        {
-          "impact": "major",
-          "key": "#/paths/~1menu/get/parameters/{query:category}",
-          "kind": "added",
-          "revision": {
-            "location": "revision.yaml#/paths/~1menu/get/parameters/1",
-            "value": {
-              "in": "query",
-              "name": "category",
-              "required": true,
-              "schema": {
-                "type": "string",
-              },
-            },
-          },
-          "verdicts": [
-            {
-              "impact": "major",
-              "location": "revision.yaml#/paths/~1menu/get/parameters/1",
-              "message": "Required \`category\` query parameter was added.",
-              "ruleId": "parameter-added-required",
-            },
-          ],
-        },
-      ]
-    `);
-  });
-
-  it('should not report referenced parameters listed in another order', async () => {
-    const result = diffDocuments({
-      base: makeDocumentFromString(
-        cafe(
-          "[{ $ref: '#/components/parameters/Sort' }, { $ref: '#/components/parameters/Category' }]"
-        ),
-        'base.yaml'
-      ),
-      revision: makeDocumentFromString(
-        cafe(
-          "[{ $ref: '#/components/parameters/Category' }, { $ref: '#/components/parameters/Sort' }]"
-        ),
-        'revision.yaml'
-      ),
-      config: await createConfig({ diff: { 'parameter-added-required': 'major' } }),
-    });
-
-    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`[]`);
-  });
-});
diff --git a/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts b/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts
index c2cfb6f8d9..68de9e2bab 100644
--- a/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts
+++ b/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts
@@ -20,6 +20,8 @@ const cafe = (parameters: string) => outdent`
       Category: { name: category, in: query, required: true, schema: { type: string } }
 `;
 
+const search = '{ name: search, in: query, schema: { type: string } }';
+
 describe('parameter-became-required', () => {
   it('should report an optional parameter that became required, not one that became optional', async () => {
     const result = diffDocuments({
@@ -80,4 +82,124 @@ describe('parameter-became-required', () => {
       ]
     `);
   });
+
+  it('should report a new required parameter, not a new optional one', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(cafe(`[${search}]`), 'base.yaml'),
+      revision: makeDocumentFromString(
+        cafe(
+          `[${search}, { name: limit, in: query, schema: { type: integer } }, { name: locale, in: header, required: true, schema: { type: string } }]`
+        ),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'parameter-became-required': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "impact": "minor",
+          "key": "#/paths/~1menu/get/parameters/{query:limit}",
+          "kind": "added",
+          "revision": {
+            "location": "revision.yaml#/paths/~1menu/get/parameters/1",
+            "value": {
+              "in": "query",
+              "name": "limit",
+              "schema": {
+                "type": "integer",
+              },
+            },
+          },
+          "verdicts": [],
+        },
+        {
+          "impact": "major",
+          "key": "#/paths/~1menu/get/parameters/{header:locale}",
+          "kind": "added",
+          "revision": {
+            "location": "revision.yaml#/paths/~1menu/get/parameters/2",
+            "value": {
+              "in": "header",
+              "name": "locale",
+              "required": true,
+              "schema": {
+                "type": "string",
+              },
+            },
+          },
+          "verdicts": [
+            {
+              "impact": "major",
+              "location": "revision.yaml#/paths/~1menu/get/parameters/2",
+              "message": "Required \`locale\` header parameter was added.",
+              "ruleId": "parameter-became-required",
+            },
+          ],
+        },
+      ]
+    `);
+  });
+
+  it('should read whether a referenced parameter is required from what it points at', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(cafe("[{ $ref: '#/components/parameters/Sort' }]"), 'base.yaml'),
+      revision: makeDocumentFromString(
+        cafe(
+          "[{ $ref: '#/components/parameters/Sort' }, { $ref: '#/components/parameters/Category' }]"
+        ),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'parameter-became-required': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "impact": "major",
+          "key": "#/paths/~1menu/get/parameters/{query:category}",
+          "kind": "added",
+          "revision": {
+            "location": "revision.yaml#/paths/~1menu/get/parameters/1",
+            "value": {
+              "in": "query",
+              "name": "category",
+              "required": true,
+              "schema": {
+                "type": "string",
+              },
+            },
+          },
+          "verdicts": [
+            {
+              "impact": "major",
+              "location": "revision.yaml#/paths/~1menu/get/parameters/1",
+              "message": "Required \`category\` query parameter was added.",
+              "ruleId": "parameter-became-required",
+            },
+          ],
+        },
+      ]
+    `);
+  });
+
+  it('should not report referenced parameters listed in another order', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(
+        cafe(
+          "[{ $ref: '#/components/parameters/Sort' }, { $ref: '#/components/parameters/Category' }]"
+        ),
+        'base.yaml'
+      ),
+      revision: makeDocumentFromString(
+        cafe(
+          "[{ $ref: '#/components/parameters/Category' }, { $ref: '#/components/parameters/Sort' }]"
+        ),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'parameter-became-required': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`[]`);
+  });
 });
diff --git a/packages/core/src/diff/rules/__tests__/property-removed.test.ts b/packages/core/src/diff/rules/__tests__/property-removed.test.ts
index b11edbe2eb..6f1264a9b6 100644
--- a/packages/core/src/diff/rules/__tests__/property-removed.test.ts
+++ b/packages/core/src/diff/rules/__tests__/property-removed.test.ts
@@ -115,4 +115,97 @@ describe('property-removed', () => {
       ]
     `);
   });
+
+  it('should not report a writeOnly property, which a response never sends', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(
+        cafe('{ properties: { pin: { type: string, writeOnly: true } } }'),
+        'base.yaml'
+      ),
+      revision: makeDocumentFromString(cafe('{ properties: {} }'), 'revision.yaml'),
+      config: await createConfig({ diff: { 'property-removed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/pin",
+            "value": {
+              "type": "string",
+              "writeOnly": true,
+            },
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/pin",
+          "kind": "removed",
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/pin",
+            "value": {
+              "type": "string",
+              "writeOnly": true,
+            },
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/pin",
+          "kind": "removed",
+          "verdicts": [],
+        },
+      ]
+    `);
+  });
+
+  it('should report every property leaving with the whole properties map', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(
+        cafe('{ type: object, properties: { note: { type: string } } }'),
+        'base.yaml'
+      ),
+      revision: makeDocumentFromString(cafe('{ type: object }'), 'revision.yaml'),
+      config: await createConfig({ diff: { 'property-removed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties",
+            "value": {
+              "note": {
+                "type": "string",
+              },
+            },
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties",
+          "kind": "removed",
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties",
+            "value": {
+              "note": {
+                "type": "string",
+              },
+            },
+          },
+          "impact": "major",
+          "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties",
+          "kind": "removed",
+          "verdicts": [
+            {
+              "impact": "major",
+              "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties",
+              "message": "All properties were removed.",
+              "ruleId": "property-removed",
+            },
+          ],
+        },
+      ]
+    `);
+  });
 });
diff --git a/packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts b/packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts
index 1552f4da37..92c73585ba 100644
--- a/packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts
+++ b/packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts
@@ -22,11 +22,12 @@ const cafe = (itemSchema: string) => outdent`
   components:
     schemas:
       Beverage: { type: object }
-      Dessert: { type: object }
+      Dessert: { type: object, required: [flavour] }
+      Drink: { type: object }
 `;
 
 describe('ref-target-changed', () => {
-  it('should report a $ref that points at another schema', async () => {
+  it('should report a $ref that points at a schema that describes other data', async () => {
     const result = diffDocuments({
       base: makeDocumentFromString(cafe('Beverage'), 'base.yaml'),
       revision: makeDocumentFromString(cafe('Dessert'), 'revision.yaml'),
@@ -52,7 +53,7 @@ describe('ref-target-changed', () => {
             {
               "impact": "major",
               "location": "revision.yaml#/paths/~1menu~1{menuItemId}/get/responses/200/content/application~1json/schema/$ref",
-              "message": "Reference target changed from '#/components/schemas/Beverage' to '#/components/schemas/Dessert'. The diff cannot check that the two targets are equivalent.",
+              "message": "Reference target changed from '#/components/schemas/Beverage' to '#/components/schemas/Dessert', which describes other data.",
               "ruleId": "ref-target-changed",
             },
           ],
@@ -60,4 +61,32 @@ describe('ref-target-changed', () => {
       ]
     `);
   });
+
+  it('should not report a $ref that points at a renamed schema', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(cafe('Beverage'), 'base.yaml'),
+      revision: makeDocumentFromString(cafe('Drink'), 'revision.yaml'),
+      config: await createConfig({ diff: { 'ref-target-changed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1menu~1{menuItemId}/get/responses/200/content/application~1json/schema/$ref",
+            "value": "#/components/schemas/Beverage",
+          },
+          "impact": "patch",
+          "key": "#/paths/~1menu~1{0}/get/responses/200/content/application~1json/schema",
+          "kind": "modified",
+          "property": "$ref",
+          "revision": {
+            "location": "revision.yaml#/paths/~1menu~1{menuItemId}/get/responses/200/content/application~1json/schema/$ref",
+            "value": "#/components/schemas/Drink",
+          },
+          "verdicts": [],
+        },
+      ]
+    `);
+  });
 });
diff --git a/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts b/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts
index 484467bd56..36f35b078d 100644
--- a/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts
+++ b/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts
@@ -86,4 +86,57 @@ describe('required-properties-added', () => {
       ]
     `);
   });
+
+  it('should not report a readOnly property, which a request never sends', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(
+        cafe('{ properties: { orderId: { type: string, readOnly: true } } }'),
+        'base.yaml'
+      ),
+      revision: makeDocumentFromString(
+        cafe('{ required: [orderId], properties: { orderId: { type: string, readOnly: true } } }'),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'required-properties-added': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+            "value": undefined,
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+          "kind": "modified",
+          "property": "required",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required",
+            "value": [
+              "orderId",
+            ],
+          },
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+            "value": undefined,
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+          "kind": "modified",
+          "property": "required",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required",
+            "value": [
+              "orderId",
+            ],
+          },
+          "verdicts": [],
+        },
+      ]
+    `);
+  });
 });
diff --git a/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts b/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts
index b09de5174b..7a36db0b75 100644
--- a/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts
+++ b/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts
@@ -89,4 +89,57 @@ describe('required-properties-removed', () => {
       ]
     `);
   });
+
+  it('should not report a writeOnly property, which a response never sends', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(
+        cafe('{ required: [pin], properties: { pin: { type: string, writeOnly: true } } }'),
+        'base.yaml'
+      ),
+      revision: makeDocumentFromString(
+        cafe('{ properties: { pin: { type: string, writeOnly: true } } }'),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'required-properties-removed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required",
+            "value": [
+              "pin",
+            ],
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+          "kind": "modified",
+          "property": "required",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+            "value": undefined,
+          },
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required",
+            "value": [
+              "pin",
+            ],
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+          "kind": "modified",
+          "property": "required",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+            "value": undefined,
+          },
+          "verdicts": [],
+        },
+      ]
+    `);
+  });
 });
diff --git a/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts
index 77a9733806..b46c11a3ae 100644
--- a/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts
+++ b/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts
@@ -270,4 +270,110 @@ describe('schema-type-changed', () => {
       ]
     `);
   });
+
+  it('should report a type that a schema without one now requires in a request', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(cafe('3.1.0', '{ description: Price }'), 'base.yaml'),
+      revision: makeDocumentFromString(
+        cafe('3.1.0', '{ description: Price, type: number }'),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'schema-type-changed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+            "value": undefined,
+          },
+          "impact": "major",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+          "kind": "modified",
+          "property": "type",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type",
+            "value": "number",
+          },
+          "verdicts": [
+            {
+              "impact": "major",
+              "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type",
+              "message": "Type narrowed from 'any' to 'number'.",
+              "ruleId": "schema-type-changed",
+            },
+          ],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+            "value": undefined,
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+          "kind": "modified",
+          "property": "type",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type",
+            "value": "number",
+          },
+          "verdicts": [],
+        },
+      ]
+    `);
+  });
+
+  it('should report a type that a response no longer keeps to', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(
+        cafe('3.1.0', '{ description: Price, type: number }'),
+        'base.yaml'
+      ),
+      revision: makeDocumentFromString(cafe('3.1.0', '{ description: Price }'), 'revision.yaml'),
+      config: await createConfig({ diff: { 'schema-type-changed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type",
+            "value": "number",
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema",
+          "kind": "modified",
+          "property": "type",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema",
+            "value": undefined,
+          },
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type",
+            "value": "number",
+          },
+          "impact": "major",
+          "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema",
+          "kind": "modified",
+          "property": "type",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+            "value": undefined,
+          },
+          "verdicts": [
+            {
+              "impact": "major",
+              "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema",
+              "message": "Type widened from 'number' to 'any'.",
+              "ruleId": "schema-type-changed",
+            },
+          ],
+        },
+      ]
+    `);
+  });
 });
diff --git a/packages/core/src/diff/rules/__tests__/security-requirement-added.test.ts b/packages/core/src/diff/rules/__tests__/security-requirement-added.test.ts
deleted file mode 100644
index f2f423775e..0000000000
--- a/packages/core/src/diff/rules/__tests__/security-requirement-added.test.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { outdent } from 'outdent';
-
-import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
-import { createConfig } from '../../../config/index.js';
-import { makeDocumentFromString } from '../../../resolve.js';
-import { diffDocuments } from '../../index.js';
-
-const cafe = (operation: string) => outdent`
-  openapi: 3.1.0
-  info: { title: Cafe, version: 1.0.0 }
-  paths:
-    /orders:
-      get: ${operation}
-  components:
-    securitySchemes:
-      ApiKey: { type: apiKey, in: header, name: X-Api-Key }
-      OAuth:
-        type: oauth2
-        flows:
-          clientCredentials:
-            tokenUrl: https://api.cafe.example/token
-            scopes: { orders:read: Read orders, orders:write: Write orders }
-`;
-
-const ok = 'responses: { 200: { description: OK } }';
-
-describe('security-requirement-added', () => {
-  it('should report a security list that appears where there was none', async () => {
-    const result = diffDocuments({
-      base: makeDocumentFromString(cafe(`{ ${ok} }`), 'base.yaml'),
-      revision: makeDocumentFromString(
-        cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`),
-        'revision.yaml'
-      ),
-      config: await createConfig({ diff: { 'security-requirement-added': 'major' } }),
-    });
-
-    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
-      [
-        {
-          "impact": "major",
-          "key": "#/paths/~1orders/get/security",
-          "kind": "added",
-          "revision": {
-            "location": "revision.yaml#/paths/~1orders/get/security",
-            "value": [
-              {
-                "ApiKey": [],
-              },
-            ],
-          },
-          "verdicts": [
-            {
-              "impact": "major",
-              "location": "revision.yaml#/paths/~1orders/get/security",
-              "message": "Authentication became required.",
-              "ruleId": "security-requirement-added",
-            },
-          ],
-        },
-      ]
-    `);
-  });
-
-  it('should report the first requirement filling a list that asked for none', async () => {
-    const result = diffDocuments({
-      base: makeDocumentFromString(cafe(`{ ${ok}, security: [] }`), 'base.yaml'),
-      revision: makeDocumentFromString(
-        cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`),
-        'revision.yaml'
-      ),
-      config: await createConfig({ diff: { 'security-requirement-added': 'major' } }),
-    });
-
-    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
-      [
-        {
-          "impact": "major",
-          "key": "#/paths/~1orders/get/security/{ApiKey}",
-          "kind": "added",
-          "revision": {
-            "location": "revision.yaml#/paths/~1orders/get/security/0",
-            "value": {
-              "ApiKey": [],
-            },
-          },
-          "verdicts": [
-            {
-              "impact": "major",
-              "location": "revision.yaml#/paths/~1orders/get/security/0",
-              "message": "Authentication became required.",
-              "ruleId": "security-requirement-added",
-            },
-          ],
-        },
-      ]
-    `);
-  });
-
-  it('should not report another way to authenticate', async () => {
-    const result = diffDocuments({
-      base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'),
-      revision: makeDocumentFromString(
-        cafe(`{ ${ok}, security: [{ ApiKey: [] }, { OAuth: [orders:read] }] }`),
-        'revision.yaml'
-      ),
-      config: await createConfig({ diff: { 'security-requirement-added': 'major' } }),
-    });
-
-    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
-      [
-        {
-          "impact": "minor",
-          "key": "#/paths/~1orders/get/security/{OAuth}",
-          "kind": "added",
-          "revision": {
-            "location": "revision.yaml#/paths/~1orders/get/security/1",
-            "value": {
-              "OAuth": [
-                "orders:read",
-              ],
-            },
-          },
-          "verdicts": [],
-        },
-      ]
-    `);
-  });
-});
diff --git a/packages/core/src/diff/rules/__tests__/security-requirement-changed.test.ts b/packages/core/src/diff/rules/__tests__/security-requirement-changed.test.ts
new file mode 100644
index 0000000000..74e0de2cec
--- /dev/null
+++ b/packages/core/src/diff/rules/__tests__/security-requirement-changed.test.ts
@@ -0,0 +1,326 @@
+import { outdent } from 'outdent';
+
+import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
+import { createConfig } from '../../../config/index.js';
+import { makeDocumentFromString } from '../../../resolve.js';
+import { diffDocuments } from '../../index.js';
+
+const cafe = (operation: string) => outdent`
+  openapi: 3.1.0
+  info: { title: Cafe, version: 1.0.0 }
+  paths:
+    /orders:
+      get: ${operation}
+  components:
+    securitySchemes:
+      ApiKey: { type: apiKey, in: header, name: X-Api-Key }
+      OAuth:
+        type: oauth2
+        flows:
+          clientCredentials:
+            tokenUrl: https://api.cafe.example/token
+            scopes: { orders:read: Read orders, orders:write: Write orders }
+`;
+
+const ok = 'responses: { 200: { description: OK } }';
+
+describe('security-requirement-changed', () => {
+  it('should report a security list that appears where there was none', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(cafe(`{ ${ok} }`), 'base.yaml'),
+      revision: makeDocumentFromString(
+        cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "impact": "major",
+          "key": "#/paths/~1orders/get/security",
+          "kind": "added",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/get/security",
+            "value": [
+              {
+                "ApiKey": [],
+              },
+            ],
+          },
+          "verdicts": [
+            {
+              "impact": "major",
+              "location": "revision.yaml#/paths/~1orders/get/security",
+              "message": "Authentication became required.",
+              "ruleId": "security-requirement-changed",
+            },
+          ],
+        },
+      ]
+    `);
+  });
+
+  it('should report the first requirement filling a list that asked for none', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(cafe(`{ ${ok}, security: [] }`), 'base.yaml'),
+      revision: makeDocumentFromString(
+        cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "impact": "major",
+          "key": "#/paths/~1orders/get/security/{ApiKey}",
+          "kind": "added",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/get/security/0",
+            "value": {
+              "ApiKey": [],
+            },
+          },
+          "verdicts": [
+            {
+              "impact": "major",
+              "location": "revision.yaml#/paths/~1orders/get/security/0",
+              "message": "Authentication became required.",
+              "ruleId": "security-requirement-changed",
+            },
+          ],
+        },
+      ]
+    `);
+  });
+
+  it('should not report another way to authenticate', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'),
+      revision: makeDocumentFromString(
+        cafe(`{ ${ok}, security: [{ ApiKey: [] }, { OAuth: [orders:read] }] }`),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "impact": "minor",
+          "key": "#/paths/~1orders/get/security/{OAuth}",
+          "kind": "added",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/get/security/1",
+            "value": {
+              "OAuth": [
+                "orders:read",
+              ],
+            },
+          },
+          "verdicts": [],
+        },
+      ]
+    `);
+  });
+
+  it('should report the scopes a requirement now asks for', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(
+        cafe(`{ ${ok}, security: [{ OAuth: [orders:read] }] }`),
+        'base.yaml'
+      ),
+      revision: makeDocumentFromString(
+        cafe(`{ ${ok}, security: [{ OAuth: [orders:read, orders:write] }] }`),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/get/security/0/OAuth",
+            "value": [
+              "orders:read",
+            ],
+          },
+          "impact": "major",
+          "key": "#/paths/~1orders/get/security/{OAuth}",
+          "kind": "modified",
+          "property": "OAuth",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth",
+            "value": [
+              "orders:read",
+              "orders:write",
+            ],
+          },
+          "verdicts": [
+            {
+              "impact": "major",
+              "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth",
+              "message": "Security scheme \`OAuth\` requires new scopes: 'orders:write'.",
+              "ruleId": "security-requirement-changed",
+            },
+          ],
+        },
+      ]
+    `);
+  });
+
+  it('should report a requirement that now asks for one more scheme', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'),
+      revision: makeDocumentFromString(
+        cafe(`{ ${ok}, security: [{ ApiKey: [], OAuth: [] }] }`),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/get/security/0",
+            "value": {
+              "ApiKey": [],
+            },
+          },
+          "impact": "major",
+          "key": "#/paths/~1orders/get/security/{ApiKey}",
+          "kind": "removed",
+          "verdicts": [
+            {
+              "impact": "major",
+              "location": "base.yaml#/paths/~1orders/get/security/0",
+              "message": "Security requirement \`ApiKey\` is no longer accepted.",
+              "ruleId": "security-requirement-changed",
+            },
+          ],
+        },
+        {
+          "impact": "minor",
+          "key": "#/paths/~1orders/get/security/{ApiKey+OAuth}",
+          "kind": "added",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/get/security/0",
+            "value": {
+              "ApiKey": [],
+              "OAuth": [],
+            },
+          },
+          "verdicts": [],
+        },
+      ]
+    `);
+  });
+
+  it('should report an alternative that clients can no longer use', async () => {
+    const result = diffDocuments({
+      base: makeDocumentFromString(
+        cafe(`{ ${ok}, security: [{ ApiKey: [] }, { OAuth: [] }] }`),
+        'base.yaml'
+      ),
+      revision: makeDocumentFromString(
+        cafe(`{ ${ok}, security: [{ OAuth: [] }] }`),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/get/security/0",
+            "value": {
+              "ApiKey": [],
+            },
+          },
+          "impact": "major",
+          "key": "#/paths/~1orders/get/security/{ApiKey}",
+          "kind": "removed",
+          "verdicts": [
+            {
+              "impact": "major",
+              "location": "base.yaml#/paths/~1orders/get/security/0",
+              "message": "Security requirement \`ApiKey\` is no longer accepted.",
+              "ruleId": "security-requirement-changed",
+            },
+          ],
+        },
+      ]
+    `);
+  });
+
+  it('should not report a requirement that another one still covers, or a list that asks for none', async () => {
+    const covered = diffDocuments({
+      base: makeDocumentFromString(
+        cafe(`{ ${ok}, security: [{ ApiKey: [], OAuth: [] }] }`),
+        'base.yaml'
+      ),
+      revision: makeDocumentFromString(
+        cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+    });
+    const optional = diffDocuments({
+      base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'),
+      revision: makeDocumentFromString(cafe(`{ ${ok}, security: [] }`), 'revision.yaml'),
+      config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(covered.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/get/security/0",
+            "value": {
+              "ApiKey": [],
+              "OAuth": [],
+            },
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/get/security/{ApiKey+OAuth}",
+          "kind": "removed",
+          "verdicts": [],
+        },
+        {
+          "impact": "minor",
+          "key": "#/paths/~1orders/get/security/{ApiKey}",
+          "kind": "added",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/get/security/0",
+            "value": {
+              "ApiKey": [],
+            },
+          },
+          "verdicts": [],
+        },
+      ]
+    `);
+    expect(replaceSourceWithRefInChanges(optional.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/get/security/0",
+            "value": {
+              "ApiKey": [],
+            },
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/get/security/{ApiKey}",
+          "kind": "removed",
+          "verdicts": [],
+        },
+      ]
+    `);
+  });
+});
diff --git a/packages/core/src/diff/rules/__tests__/security-scopes-added.test.ts b/packages/core/src/diff/rules/__tests__/security-scopes-added.test.ts
deleted file mode 100644
index 2c2e20a194..0000000000
--- a/packages/core/src/diff/rules/__tests__/security-scopes-added.test.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { outdent } from 'outdent';
-
-import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js';
-import { createConfig } from '../../../config/index.js';
-import { makeDocumentFromString } from '../../../resolve.js';
-import { diffDocuments } from '../../index.js';
-
-const cafe = (operation: string) => outdent`
-  openapi: 3.1.0
-  info: { title: Cafe, version: 1.0.0 }
-  paths:
-    /orders:
-      get: ${operation}
-  components:
-    securitySchemes:
-      ApiKey: { type: apiKey, in: header, name: X-Api-Key }
-      OAuth:
-        type: oauth2
-        flows:
-          clientCredentials:
-            tokenUrl: https://api.cafe.example/token
-            scopes: { orders:read: Read orders, orders:write: Write orders }
-`;
-
-const ok = 'responses: { 200: { description: OK } }';
-
-describe('security-scopes-added', () => {
-  it('should report the scopes a requirement now asks for', async () => {
-    const result = diffDocuments({
-      base: makeDocumentFromString(
-        cafe(`{ ${ok}, security: [{ OAuth: [orders:read] }] }`),
-        'base.yaml'
-      ),
-      revision: makeDocumentFromString(
-        cafe(`{ ${ok}, security: [{ OAuth: [orders:read, orders:write] }] }`),
-        'revision.yaml'
-      ),
-      config: await createConfig({ diff: { 'security-scopes-added': 'major' } }),
-    });
-
-    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
-      [
-        {
-          "base": {
-            "location": "base.yaml#/paths/~1orders/get/security/0/OAuth",
-            "value": [
-              "orders:read",
-            ],
-          },
-          "impact": "major",
-          "key": "#/paths/~1orders/get/security/{OAuth}",
-          "kind": "modified",
-          "property": "OAuth",
-          "revision": {
-            "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth",
-            "value": [
-              "orders:read",
-              "orders:write",
-            ],
-          },
-          "verdicts": [
-            {
-              "impact": "major",
-              "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth",
-              "message": "Security scheme \`OAuth\` requires new scopes: 'orders:write'.",
-              "ruleId": "security-scopes-added",
-            },
-          ],
-        },
-      ]
-    `);
-  });
-});
diff --git a/packages/core/src/diff/rules/__tests__/string-length-changed.test.ts b/packages/core/src/diff/rules/__tests__/string-constraint-changed.test.ts
similarity index 93%
rename from packages/core/src/diff/rules/__tests__/string-length-changed.test.ts
rename to packages/core/src/diff/rules/__tests__/string-constraint-changed.test.ts
index 02c3095bea..0ef285c0ab 100644
--- a/packages/core/src/diff/rules/__tests__/string-length-changed.test.ts
+++ b/packages/core/src/diff/rules/__tests__/string-constraint-changed.test.ts
@@ -23,12 +23,12 @@ const cafe = (note: string) => outdent`
                 schema: ${note}
 `;
 
-describe('string-length-changed', () => {
+describe('string-constraint-changed', () => {
   it('should report a length limit that makes a request accept less', async () => {
     const result = diffDocuments({
       base: makeDocumentFromString(cafe('{ type: string, maxLength: 200 }'), 'base.yaml'),
       revision: makeDocumentFromString(cafe('{ type: string, maxLength: 100 }'), 'revision.yaml'),
-      config: await createConfig({ diff: { 'string-length-changed': 'major' } }),
+      config: await createConfig({ diff: { 'string-constraint-changed': 'major' } }),
     });
 
     expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
@@ -51,7 +51,7 @@ describe('string-length-changed', () => {
               "impact": "major",
               "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength",
               "message": "\`maxLength\` changed from '200' to '100'.",
-              "ruleId": "string-length-changed",
+              "ruleId": "string-constraint-changed",
             },
           ],
         },
@@ -81,7 +81,7 @@ describe('string-length-changed', () => {
         cafe("{ type: string, pattern: '^[a-z ]+$' }"),
         'revision.yaml'
       ),
-      config: await createConfig({ diff: { 'string-length-changed': 'major' } }),
+      config: await createConfig({ diff: { 'string-constraint-changed': 'major' } }),
     });
 
     expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
@@ -104,7 +104,7 @@ describe('string-length-changed', () => {
               "impact": "major",
               "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern",
               "message": "\`pattern\` changed from '^[a-z]+$' to '^[a-z ]+$'.",
-              "ruleId": "string-length-changed",
+              "ruleId": "string-constraint-changed",
             },
           ],
         },
diff --git a/packages/core/src/diff/rules/index.ts b/packages/core/src/diff/rules/index.ts
index 068586bb08..03a5c6f1fb 100644
--- a/packages/core/src/diff/rules/index.ts
+++ b/packages/core/src/diff/rules/index.ts
@@ -10,7 +10,6 @@ import { MessageRemoved } from './message-removed.js';
 import { NumericRangeChanged } from './numeric-range-changed.js';
 import { OperationActionChanged } from './operation-action-changed.js';
 import { OperationRemoved } from './operation-removed.js';
-import { ParameterAddedRequired } from './parameter-added-required.js';
 import { ParameterBecameRequired } from './parameter-became-required.js';
 import { ParameterRemoved } from './parameter-removed.js';
 import { ParameterSerializationChanged } from './parameter-serialization-changed.js';
@@ -26,12 +25,11 @@ import { ResponseRemoved } from './response-removed.js';
 import { SchemaCombinatorChanged } from './schema-combinator-changed.js';
 import { SchemaFormatChanged } from './schema-format-changed.js';
 import { SchemaTypeChanged } from './schema-type-changed.js';
-import { SecurityRequirementAdded } from './security-requirement-added.js';
+import { SecurityRequirementChanged } from './security-requirement-changed.js';
 import { SecuritySchemeChanged } from './security-scheme-changed.js';
 import { SecuritySchemeRemoved } from './security-scheme-removed.js';
-import { SecurityScopesAdded } from './security-scopes-added.js';
 import { ServerRemoved } from './server-removed.js';
-import { StringLengthChanged } from './string-length-changed.js';
+import { StringConstraintChanged } from './string-constraint-changed.js';
 
 /**
  * Every rule over a `Schema` node, shared by both registries: an AsyncAPI payload is the same
@@ -45,7 +43,7 @@ const schemaRules = {
   'required-properties-removed': RequiredPropertiesRemoved,
   'property-removed': PropertyRemoved,
   'numeric-range-changed': NumericRangeChanged,
-  'string-length-changed': StringLengthChanged,
+  'string-constraint-changed': StringConstraintChanged,
   'schema-format-changed': SchemaFormatChanged,
   'additional-properties-changed': AdditionalPropertiesChanged,
 } satisfies Record;
@@ -54,7 +52,6 @@ export const oas3Rules = {
   'operation-removed': OperationRemoved,
   'path-removed': PathRemoved,
   'parameter-removed': ParameterRemoved,
-  'parameter-added-required': ParameterAddedRequired,
   'parameter-became-required': ParameterBecameRequired,
   'parameter-serialization-changed': ParameterSerializationChanged,
   'request-body-removed': RequestBodyRemoved,
@@ -62,8 +59,7 @@ export const oas3Rules = {
   'response-removed': ResponseRemoved,
   'response-header-removed': ResponseHeaderRemoved,
   'media-type-removed': MediaTypeRemoved,
-  'security-requirement-added': SecurityRequirementAdded,
-  'security-scopes-added': SecurityScopesAdded,
+  'security-requirement-changed': SecurityRequirementChanged,
   'security-scheme-changed': SecuritySchemeChanged,
   'security-scheme-removed': SecuritySchemeRemoved,
   'server-removed': ServerRemoved,
diff --git a/packages/core/src/diff/rules/media-type-removed.ts b/packages/core/src/diff/rules/media-type-removed.ts
index 2dbf48bcdd..6b86ece772 100644
--- a/packages/core/src/diff/rules/media-type-removed.ts
+++ b/packages/core/src/diff/rules/media-type-removed.ts
@@ -1,9 +1,13 @@
 import type { DiffRule } from '../types.js';
 import { nameOf } from './utils.js';
 
+// Removing every media type lands as one change on the map, removing one lands on the media type.
 export const MediaTypeRemoved: DiffRule = () => ({
   MediaType(change, { report }) {
     if (change.kind === 'removed')
       report({ message: `Media type \`${nameOf(change.node)}\` was removed.` });
   },
+  MediaTypesMap(change, { report }) {
+    if (change.kind === 'removed') report({ message: 'All media types were removed.' });
+  },
 });
diff --git a/packages/core/src/diff/rules/numeric-range-changed.ts b/packages/core/src/diff/rules/numeric-range-changed.ts
index 3499276fbc..651b54766a 100644
--- a/packages/core/src/diff/rules/numeric-range-changed.ts
+++ b/packages/core/src/diff/rules/numeric-range-changed.ts
@@ -1,9 +1,93 @@
-import { constraintRule } from './utils.js';
-
-export const NumericRangeChanged = constraintRule([
-  'minimum',
-  'maximum',
-  'exclusiveMinimum',
-  'exclusiveMaximum',
-  'multipleOf',
-]);
+import { fieldOf } from '../../node-tree/access.js';
+import type { NodeEntry } from '../../node-tree/types.js';
+import { dequal } from '../../utils/dequal.js';
+import type { DiffRule } from '../types.js';
+import { breakingDirection, describeChange, judgeConstraint, ofSchema } from './utils.js';
+
+type Bound = { value: number; exclusive: boolean };
+
+// `sign` makes a greater difference mean a tighter bound on both sides: a higher minimum and a
+// lower maximum each accept fewer values.
+const LOWER = {
+  name: 'Lower bound',
+  inclusive: 'minimum',
+  exclusive: 'exclusiveMinimum',
+  operator: '>',
+  sign: 1,
+};
+const UPPER = {
+  name: 'Upper bound',
+  inclusive: 'maximum',
+  exclusive: 'exclusiveMaximum',
+  operator: '<',
+  sign: -1,
+};
+type Side = typeof LOWER;
+
+/** Above zero when `bound` accepts fewer values than `other`, below zero when it accepts more. */
+function compareTightness(bound: Bound, other: Bound, side: Side): number {
+  if (bound.value !== other.value) return (bound.value - other.value) * side.sign;
+  return Number(bound.exclusive) - Number(other.exclusive);
+}
+
+// OpenAPI 3.0 makes `minimum` exclusive with `exclusiveMinimum: true`; OpenAPI 3.1 gives
+// `exclusiveMinimum` a value of its own, and the tighter of the two keywords is the bound.
+function boundOf(schema: NodeEntry | undefined, side: Side): Bound | undefined {
+  const inclusive = fieldOf(schema, side.inclusive);
+  const exclusive = fieldOf(schema, side.exclusive);
+  if (typeof exclusive === 'number') {
+    const exclusiveBound = { value: exclusive, exclusive: true };
+    if (typeof inclusive !== 'number') return exclusiveBound;
+    const inclusiveBound = { value: inclusive, exclusive: false };
+    return compareTightness(inclusiveBound, exclusiveBound, side) > 0
+      ? inclusiveBound
+      : exclusiveBound;
+  }
+  if (typeof inclusive === 'number') return { value: inclusive, exclusive: exclusive === true };
+  return undefined;
+}
+
+/** Above zero when the side accepts fewer values after the change, below zero when it accepts more. */
+function tighteningOf(before: Bound | undefined, after: Bound | undefined, side: Side): number {
+  if (!before) return 1; // a new bound
+  if (!after) return -1; // a removed bound
+  return compareTightness(after, before, side);
+}
+
+function describeBound(bound: Bound | undefined, side: Side): string | undefined {
+  return bound && `${side.operator}${bound.exclusive ? '' : '='} ${bound.value}`;
+}
+
+export const NumericRangeChanged: DiffRule = () => ({
+  Schema(change, context) {
+    if (change.kind !== 'modified') return;
+    if (change.property === 'multipleOf') return judgeConstraint(change, context);
+
+    const side = [LOWER, UPPER].find(
+      ({ inclusive, exclusive }) => change.property === inclusive || change.property === exclusive
+    );
+    if (!side) return;
+
+    // Both keywords of a side can change together, so the side is judged once: by its inclusive
+    // keyword when that one changed.
+    const { base, revision } = change.node;
+    const inclusiveChanged = !dequal(
+      fieldOf(base, side.inclusive),
+      fieldOf(revision, side.inclusive)
+    );
+    if (change.property === side.exclusive && inclusiveChanged) return;
+
+    const before = boundOf(base, side);
+    const after = boundOf(revision, side);
+    if (!before && !after) return;
+    const tightening = tighteningOf(before, after, side);
+    if (tightening === 0) return;
+
+    if (context.directions.includes(breakingDirection(tightening > 0))) {
+      const subject = `${side.name}${ofSchema(change.node)}`;
+      context.report({
+        message: describeChange(subject, describeBound(before, side), describeBound(after, side)),
+      });
+    }
+  },
+});
diff --git a/packages/core/src/diff/rules/parameter-added-required.ts b/packages/core/src/diff/rules/parameter-added-required.ts
deleted file mode 100644
index 7a57e064e4..0000000000
--- a/packages/core/src/diff/rules/parameter-added-required.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { isPlainObject } from '../../utils/is-plain-object.js';
-import type { DiffRule } from '../types.js';
-import { describeParameter } from './utils.js';
-
-function hasRequiredParameter(parameters: unknown[]): boolean {
-  return parameters.some((parameter) => isPlainObject(parameter) && parameter.required === true);
-}
-
-// The first parameter of an operation arrives with the whole `parameters` list, so that change
-// lands on the list rather than on a parameter.
-export const ParameterAddedRequired: DiffRule = () => ({
-  Parameter(change, { report, directions }) {
-    if (change.kind !== 'added' || !directions.includes('request')) return;
-    if (hasRequiredParameter([change.revision.value])) {
-      report({ message: `Required ${describeParameter(change.node)} was added.` });
-    }
-  },
-  ParameterList(change, { report, directions }) {
-    if (change.kind !== 'added' || !directions.includes('request')) return;
-    if (Array.isArray(change.revision.value) && hasRequiredParameter(change.revision.value)) {
-      report({ message: 'Required parameters were added.' });
-    }
-  },
-});
diff --git a/packages/core/src/diff/rules/parameter-became-required.ts b/packages/core/src/diff/rules/parameter-became-required.ts
index e74e85fba4..e61a0ef3f8 100644
--- a/packages/core/src/diff/rules/parameter-became-required.ts
+++ b/packages/core/src/diff/rules/parameter-became-required.ts
@@ -1,11 +1,32 @@
+import { isPlainObject } from '../../utils/is-plain-object.js';
 import type { DiffRule } from '../types.js';
 import { describeParameter } from './utils.js';
 
+function isRequired(parameter: unknown): boolean {
+  return isPlainObject(parameter) && parameter.required === true;
+}
+
+// A client must now send a parameter that it did not have to: a new required one, or one that
+// became required. The first parameter of an operation arrives with the whole `parameters` list,
+// so that change lands on the list rather than on a parameter.
 export const ParameterBecameRequired: DiffRule = () => ({
   Parameter(change, { report, directions }) {
-    if (change.kind !== 'modified' || change.property !== 'required') return;
-    if (directions.includes('request') && change.revision.value === true) {
+    if (!directions.includes('request')) return;
+    if (change.kind === 'added' && isRequired(change.revision.value)) {
+      report({ message: `Required ${describeParameter(change.node)} was added.` });
+    }
+    if (
+      change.kind === 'modified' &&
+      change.property === 'required' &&
+      change.revision.value === true
+    ) {
       report({ message: `${describeParameter(change.node)} became required.` });
     }
   },
+  ParameterList(change, { report, directions }) {
+    if (change.kind !== 'added' || !directions.includes('request')) return;
+    if (Array.isArray(change.revision.value) && change.revision.value.some(isRequired)) {
+      report({ message: 'Required parameters were added.' });
+    }
+  },
 });
diff --git a/packages/core/src/diff/rules/property-removed.ts b/packages/core/src/diff/rules/property-removed.ts
index 096eb85b7f..0455b08fb4 100644
--- a/packages/core/src/diff/rules/property-removed.ts
+++ b/packages/core/src/diff/rules/property-removed.ts
@@ -2,6 +2,7 @@ import type { DiffRule } from '../types.js';
 import { nameOf, ofSchema } from './utils.js';
 
 // A member of a `properties` map; an alternative of a `oneOf` is a schema too, but not a property.
+// Removing every property lands as one change on the `properties` map of the schema.
 export const PropertyRemoved: DiffRule = () => ({
   SchemaProperties: {
     Schema(change, { report, directions }) {
@@ -12,4 +13,11 @@ export const PropertyRemoved: DiffRule = () => ({
       }
     },
   },
+  Schema: {
+    SchemaProperties(change, { report, directions }) {
+      if (change.kind === 'removed' && directions.includes('response')) {
+        report({ message: `All properties${ofSchema(change.node.parent)} were removed.` });
+      }
+    },
+  },
 });
diff --git a/packages/core/src/diff/rules/ref-target-changed.ts b/packages/core/src/diff/rules/ref-target-changed.ts
index 785185406b..2e61e13264 100644
--- a/packages/core/src/diff/rules/ref-target-changed.ts
+++ b/packages/core/src/diff/rules/ref-target-changed.ts
@@ -1,18 +1,22 @@
+import { valueOf } from '../../node-tree/access.js';
+import { dequal } from '../../utils/dequal.js';
 import type { DiffRule } from '../types.js';
 
 function describeTarget(value: unknown): string {
   return typeof value === 'string' ? `'${value}'` : 'an inline definition';
 }
 
-// Key-aligned comparison cannot verify whether two different targets are
-// content-equivalent — the conservative verdict is breaking.
+// Two targets are compared at their own places, not with each other, so a new target that
+// describes other data cannot be told apart from a renamed one — unless both read the same.
 export const RefTargetChanged: DiffRule = () => ({
   any(change, { report }) {
     if (change.kind !== 'modified' || change.property !== '$ref') return;
+    const { base, revision } = change.node;
+    if (base && revision && dequal(valueOf(base), valueOf(revision))) return;
     report({
       message: `Reference target changed from ${describeTarget(change.base.value)} to ${describeTarget(
         change.revision.value
-      )}. The diff cannot check that the two targets are equivalent.`,
+      )}, which describes other data.`,
     });
   },
 });
diff --git a/packages/core/src/diff/rules/required-properties-added.ts b/packages/core/src/diff/rules/required-properties-added.ts
index 6d8502a837..480eebeaf7 100644
--- a/packages/core/src/diff/rules/required-properties-added.ts
+++ b/packages/core/src/diff/rules/required-properties-added.ts
@@ -1,11 +1,15 @@
 import type { DiffRule } from '../types.js';
-import { itemsOnlyIn, named, ofSchema } from './utils.js';
+import { itemsOnlyIn, named, ofSchema, propertiesMarked } from './utils.js';
 
 export const RequiredPropertiesAdded: DiffRule = () => ({
   Schema(change, { report, directions }) {
     if (change.kind !== 'modified' || change.property !== 'required') return;
     if (!directions.includes('request')) return;
-    const added = itemsOnlyIn(change.revision.value, change.base.value);
+    // A request does not send a `readOnly` property, so requiring it asks nothing of the client.
+    const readOnly = propertiesMarked(change.node, 'readOnly');
+    const added = itemsOnlyIn(change.revision.value, change.base.value).filter(
+      (name) => !readOnly.includes(String(name))
+    );
     if (added.length)
       report({ message: `Properties${ofSchema(change.node)} became required: ${named(added)}.` });
   },
diff --git a/packages/core/src/diff/rules/required-properties-removed.ts b/packages/core/src/diff/rules/required-properties-removed.ts
index 8b564e0ee0..95930931b4 100644
--- a/packages/core/src/diff/rules/required-properties-removed.ts
+++ b/packages/core/src/diff/rules/required-properties-removed.ts
@@ -1,11 +1,15 @@
 import type { DiffRule } from '../types.js';
-import { itemsOnlyIn, named, ofSchema } from './utils.js';
+import { itemsOnlyIn, named, ofSchema, propertiesMarked } from './utils.js';
 
 export const RequiredPropertiesRemoved: DiffRule = () => ({
   Schema(change, { report, directions }) {
     if (change.kind !== 'modified' || change.property !== 'required') return;
     if (!directions.includes('response')) return;
-    const removed = itemsOnlyIn(change.base.value, change.revision.value);
+    // A response does not send a `writeOnly` property, so no client reads it.
+    const writeOnly = propertiesMarked(change.node, 'writeOnly');
+    const removed = itemsOnlyIn(change.base.value, change.revision.value).filter(
+      (name) => !writeOnly.includes(String(name))
+    );
     if (removed.length) {
       report({
         message: `Properties${ofSchema(change.node)} are no longer required: ${named(removed)}.`,
diff --git a/packages/core/src/diff/rules/schema-type-changed.ts b/packages/core/src/diff/rules/schema-type-changed.ts
index 62618d346d..1c64548e7e 100644
--- a/packages/core/src/diff/rules/schema-type-changed.ts
+++ b/packages/core/src/diff/rules/schema-type-changed.ts
@@ -14,7 +14,9 @@ const WIDER_TYPE: Partial> = { integer: 'number' };
 function acceptedTypes(type: unknown, nullable?: unknown): string[] {
   const declared = Array.isArray(type) ? type : type === undefined ? [] : [type];
   const types = declared.filter((value): value is string => typeof value === 'string');
-  return nullable === true && !types.includes('null') ? [...types, 'null'] : types;
+  // Without a `type`, a schema already accepts `null` with every other type.
+  const addsNull = nullable === true && types.length > 0 && !types.includes('null');
+  return addsNull ? [...types, 'null'] : types;
 }
 
 /** The types of `types` that `accepted` does not take. */
@@ -36,14 +38,15 @@ export const SchemaTypeChanged: DiffRule = () => ({
 
     const before = acceptedTypes(fieldOf(base, 'type'), fieldOf(base, 'nullable'));
     const after = acceptedTypes(fieldOf(revision, 'type'), fieldOf(revision, 'nullable'));
-    // An absent `type` accepts anything, so there is nothing to narrow or widen.
-    if (!before.length || !after.length) return;
-    const described = `from '${before.join(' | ')}' to '${after.join(' | ')}'`;
+    // An absent `type` accepts every type: adding one narrows the schema, and removing one widens it.
+    const narrowed = after.length > 0 && (!before.length || typesNotIn(before, after).length > 0);
+    const widened = before.length > 0 && (!after.length || typesNotIn(after, before).length > 0);
+    const described = `from '${before.join(' | ') || 'any'}' to '${after.join(' | ') || 'any'}'`;
 
-    if (directions.includes('request') && typesNotIn(before, after).length) {
+    if (directions.includes('request') && narrowed) {
       report({ message: `Type${ofSchema(change.node)} narrowed ${described}.` });
     }
-    if (directions.includes('response') && typesNotIn(after, before).length) {
+    if (directions.includes('response') && widened) {
       report({ message: `Type${ofSchema(change.node)} widened ${described}.` });
     }
   },
diff --git a/packages/core/src/diff/rules/security-requirement-added.ts b/packages/core/src/diff/rules/security-requirement-added.ts
deleted file mode 100644
index 09bd220e7c..0000000000
--- a/packages/core/src/diff/rules/security-requirement-added.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { DiffRule } from '../types.js';
-
-// Security sits outside the request/response split, so the rule does not read the direction:
-// introducing authentication breaks every client either way.
-
-/**
- * A `security` list that appears where there was none lands on the list. `security: []`
- * states that no authentication is needed, so a first entry filling that list lands on the
- * entry; one more entry in a list that already had one only offers another way to
- * authenticate, which no existing client has to follow.
- */
-export const SecurityRequirementAdded: DiffRule = () => ({
-  SecurityRequirementList(change, { report }) {
-    if (change.kind === 'added') report({ message: 'Authentication became required.' });
-  },
-  SecurityRequirement(change, { report }) {
-    const list = change.node.parent?.base?.value;
-    if (change.kind === 'added' && Array.isArray(list) && list.length === 0) {
-      report({ message: 'Authentication became required.' });
-    }
-  },
-});
diff --git a/packages/core/src/diff/rules/security-requirement-changed.ts b/packages/core/src/diff/rules/security-requirement-changed.ts
new file mode 100644
index 0000000000..ba670cf946
--- /dev/null
+++ b/packages/core/src/diff/rules/security-requirement-changed.ts
@@ -0,0 +1,61 @@
+import { valueOf } from '../../node-tree/access.js';
+import { isPlainObject } from '../../utils/is-plain-object.js';
+import type { DiffRule } from '../types.js';
+import { itemsOnlyIn, quoted } from './utils.js';
+
+/** The schemes a client authenticates with, each with the scopes that its credentials hold. */
+type Credentials = Record;
+
+// A client passes a requirement when it has every scheme of it, with every scope that the scheme
+// asks for. An empty list, or a missing one, asks for no authentication.
+function passesOne(credentials: Credentials, requirements: unknown): boolean {
+  if (!Array.isArray(requirements) || requirements.length === 0) return true;
+  return requirements.some(
+    (requirement) =>
+      isPlainObject(requirement) &&
+      Object.entries(requirement).every(
+        ([scheme, scopes]) =>
+          scheme in credentials && itemsOnlyIn(scopes, credentials[scheme]).length === 0
+      )
+  );
+}
+
+// Security sits outside the request/response split, so the rule does not read the direction.
+// Each change asks one question: do the clients that authenticated before still pass one of the
+// requirements of the revision?
+export const SecurityRequirementChanged: DiffRule = () => ({
+  SecurityRequirementList(change, { report }) {
+    if (change.kind === 'added' && !passesOne({}, change.revision.value)) {
+      report({ message: 'Authentication became required.' });
+    }
+  },
+  SecurityRequirement(change, { report }) {
+    const list = change.node.parent;
+    const requirements = list?.revision && valueOf(list.revision);
+
+    if (change.kind === 'added') {
+      // Before, the list was empty and asked for no authentication; otherwise the new
+      // requirement only offers one more way to pass.
+      const before = list?.base && valueOf(list.base);
+      if (Array.isArray(before) && before.length === 0 && !passesOne({}, requirements)) {
+        report({ message: 'Authentication became required.' });
+      }
+      return;
+    }
+
+    const credentials = change.node.base && valueOf(change.node.base);
+    if (!isPlainObject(credentials) || passesOne(credentials, requirements)) return;
+
+    if (change.kind === 'removed') {
+      const schemes = Object.keys(credentials).join(' + ');
+      report({ message: `Security requirement \`${schemes}\` is no longer accepted.` });
+    } else {
+      // A modified requirement keeps its schemes: the value of each one is its list of scopes.
+      const added = itemsOnlyIn(change.revision.value, change.base.value);
+      if (!added.length) return;
+      report({
+        message: `Security scheme \`${change.property}\` requires new scopes: ${quoted(added)}.`,
+      });
+    }
+  },
+});
diff --git a/packages/core/src/diff/rules/security-scopes-added.ts b/packages/core/src/diff/rules/security-scopes-added.ts
deleted file mode 100644
index f2b9b46880..0000000000
--- a/packages/core/src/diff/rules/security-scopes-added.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import type { DiffRule } from '../types.js';
-import { itemsOnlyIn, quoted } from './utils.js';
-
-export const SecurityScopesAdded: DiffRule = () => ({
-  SecurityRequirement(change, { report }) {
-    // A requirement's properties are scheme names, and each value is its scope list.
-    if (change.kind !== 'modified') return;
-    const added = itemsOnlyIn(change.revision.value, change.base.value);
-    if (!added.length) return;
-    report({
-      message: `Security scheme \`${change.property}\` requires new scopes: ${quoted(added)}.`,
-    });
-  },
-});
diff --git a/packages/core/src/diff/rules/string-constraint-changed.ts b/packages/core/src/diff/rules/string-constraint-changed.ts
new file mode 100644
index 0000000000..0678fb82fd
--- /dev/null
+++ b/packages/core/src/diff/rules/string-constraint-changed.ts
@@ -0,0 +1,3 @@
+import { constraintRule } from './utils.js';
+
+export const StringConstraintChanged = constraintRule(['minLength', 'maxLength', 'pattern']);
diff --git a/packages/core/src/diff/rules/string-length-changed.ts b/packages/core/src/diff/rules/string-length-changed.ts
deleted file mode 100644
index 2ff481a278..0000000000
--- a/packages/core/src/diff/rules/string-length-changed.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { constraintRule } from './utils.js';
-
-export const StringLengthChanged = constraintRule(['minLength', 'maxLength', 'pattern']);
diff --git a/packages/core/src/diff/rules/utils.ts b/packages/core/src/diff/rules/utils.ts
index e095430377..6acb97d0f8 100644
--- a/packages/core/src/diff/rules/utils.ts
+++ b/packages/core/src/diff/rules/utils.ts
@@ -1,6 +1,6 @@
 import { fieldOf } from '../../node-tree/access.js';
 import { latestOf, typeOf } from '../diff-tree.js';
-import type { DiffNode, DiffRule, Direction } from '../types.js';
+import type { DiffNode, DiffRule, DiffVisit, Direction } from '../types.js';
 
 /** What a node is called in its document: its key, or the key of the node a `$ref` item points at. */
 export function nameOf(node: DiffNode): string {
@@ -15,6 +15,14 @@ export function ofSchema(node: DiffNode | null | undefined): string {
   return ` of \`${nameOf(node!)}\``;
 }
 
+/** The names of the properties of `schema` that are marked with `keyword`, such as `readOnly`. */
+export function propertiesMarked(schema: DiffNode, keyword: 'readOnly' | 'writeOnly'): string[] {
+  const properties = schema.children.find((child) => typeOf(child) === 'SchemaProperties');
+  return (properties?.children ?? [])
+    .filter((property) => fieldOf(latestOf(property), keyword) === true)
+    .map(nameOf);
+}
+
 /** A parameter as a message names it, such as ``\`limit\` query parameter``. */
 export function describeParameter(node: DiffNode): string {
   const location = fieldOf(latestOf(node), 'in');
@@ -50,20 +58,8 @@ export function breakingDirection(acceptsLess: boolean): Direction {
   return acceptsLess ? 'request' : 'response';
 }
 
-const LOWER_BOUNDS = new Set([
-  'minimum',
-  'exclusiveMinimum',
-  'minLength',
-  'minItems',
-  'minProperties',
-]);
-const UPPER_BOUNDS = new Set([
-  'maximum',
-  'exclusiveMaximum',
-  'maxLength',
-  'maxItems',
-  'maxProperties',
-]);
+const LOWER_BOUNDS = new Set(['minLength', 'minItems', 'minProperties']);
+const UPPER_BOUNDS = new Set(['maxLength', 'maxItems', 'maxProperties']);
 // Equivalence of these cannot be decided by comparing values, so any change to one is taken
 // as accepting less rather than guessed at.
 const OPAQUE = new Set(['pattern', 'format', 'multipleOf']);
@@ -80,9 +76,6 @@ export function acceptsLess(constraint: string, before: unknown, after: unknown)
     if (LOWER_BOUNDS.has(constraint)) return after > before;
     if (UPPER_BOUNDS.has(constraint)) return after < before;
   }
-  // `exclusiveMinimum`/`exclusiveMaximum` are booleans in OpenAPI 3.0.
-  if (typeof before === 'boolean' && typeof after === 'boolean') return after;
-
   return true;
 }
 
@@ -93,6 +86,17 @@ export function describeChange(subject: string, before: unknown, after: unknown)
   return `${subject} changed from '${before}' to '${after}'.`;
 }
 
+/** Reports a constraint that moved so that the schema accepts less in a request, or more in a response. */
+export const judgeConstraint: DiffVisit = (change, { report, directions }) => {
+  if (change.kind !== 'modified') return;
+  const before = change.base.value;
+  const after = change.revision.value;
+  if (directions.includes(breakingDirection(acceptsLess(change.property, before, after)))) {
+    const subject = `\`${change.property}\`${ofSchema(change.node)}`;
+    report({ message: describeChange(subject, before, after) });
+  }
+};
+
 /**
  * A rule over one group of constraints on a value. The groups stay separate rules so a report
  * can name the constraint that actually moved.
@@ -100,13 +104,9 @@ export function describeChange(subject: string, before: unknown, after: unknown)
 export function constraintRule(constraints: string[]): DiffRule {
   const watched = new Set(constraints);
   return () => ({
-    Schema(change, { report, directions }) {
-      if (change.kind !== 'modified' || !watched.has(change.property)) return;
-      const before = change.base.value;
-      const after = change.revision.value;
-      if (directions.includes(breakingDirection(acceptsLess(change.property, before, after)))) {
-        const subject = `\`${change.property}\`${ofSchema(change.node)}`;
-        report({ message: describeChange(subject, before, after) });
+    Schema(change, context) {
+      if (change.kind === 'modified' && watched.has(change.property)) {
+        judgeConstraint(change, context);
       }
     },
   });
diff --git a/packages/core/src/diff/specs/oas3.ts b/packages/core/src/diff/specs/oas3.ts
index 6decda88ce..1de0c5b3e1 100644
--- a/packages/core/src/diff/specs/oas3.ts
+++ b/packages/core/src/diff/specs/oas3.ts
@@ -20,8 +20,17 @@ export const oas3Directions: Directions = {
   Parameter: side('request'),
   Responses: side('response'),
   Response: side('response'),
+  Schema: accessSide,
 };
 
+// "readOnly" data only comes from the API and "writeOnly" data only goes to it (OpenAPI, Schema
+// Object), wherever the schema is used.
+function accessSide(schema: NodeEntry): Direction | undefined {
+  if (fieldOf(schema, 'readOnly') === true) return 'response';
+  if (fieldOf(schema, 'writeOnly') === true) return 'request';
+  return undefined;
+}
+
 /**
  * The data a node describes travels `direction` — the other way round under a callback or a
  * webhook, which the API sends to the consumer itself. A component is compared at its own
diff --git a/tests/e2e/diff/configuration/snapshot-skip-rule.txt b/tests/e2e/diff/configuration/snapshot-skip-rule.txt
index 12ca92eaad..67bdfe1ac3 100644
--- a/tests/e2e/diff/configuration/snapshot-skip-rule.txt
+++ b/tests/e2e/diff/configuration/snapshot-skip-rule.txt
@@ -3,7 +3,7 @@ components
       Properties of `Order` became required: `pickupAt`. (required-properties-added)
       at revision.yaml:58:17
   ✖ major  modified  schemas/Order/properties/note · maxLength
-      `maxLength` of `note` changed from '200' to '100'. (string-length-changed)
+      `maxLength` of `note` changed from '200' to '100'. (string-constraint-changed)
       at revision.yaml:61:42
   ✔ minor  added     schemas/Order/properties/pickupAt
       at revision.yaml:62:19
diff --git a/tests/e2e/diff/configuration/snapshot.txt b/tests/e2e/diff/configuration/snapshot.txt
index 66c99a018f..190f470330 100644
--- a/tests/e2e/diff/configuration/snapshot.txt
+++ b/tests/e2e/diff/configuration/snapshot.txt
@@ -6,7 +6,7 @@ components
       Properties of `Order` became required: `pickupAt`. (required-properties-added)
       at revision.yaml:58:17
   ✖ major  modified  schemas/Order/properties/note · maxLength
-      `maxLength` of `note` changed from '200' to '100'. (string-length-changed)
+      `maxLength` of `note` changed from '200' to '100'. (string-constraint-changed)
       at revision.yaml:61:42
   ✔ minor  added     schemas/Order/properties/pickupAt
       at revision.yaml:62:19
diff --git a/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt b/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt
index 1c959a11f5..d6c0d54cf7 100644
--- a/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt
+++ b/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt
@@ -9,7 +9,7 @@ components
       Properties of `Order` became required: `pickupAt`. (required-properties-added)
       at revision.yaml:58:17
   ✖ major  modified  schemas/Order/properties/note · maxLength
-      `maxLength` of `note` changed from '200' to '100'. (string-length-changed)
+      `maxLength` of `note` changed from '200' to '100'. (string-constraint-changed)
       at revision.yaml:61:42
   ✔ minor  added     schemas/Order/properties/pickupAt
       at revision.yaml:62:19
diff --git a/tests/e2e/diff/openapi-breaking-changes/snapshot.txt b/tests/e2e/diff/openapi-breaking-changes/snapshot.txt
index 812db44333..785692be77 100644
--- a/tests/e2e/diff/openapi-breaking-changes/snapshot.txt
+++ b/tests/e2e/diff/openapi-breaking-changes/snapshot.txt
@@ -9,7 +9,7 @@ components
       Properties of `Order` became required: `pickupAt`. (required-properties-added)
       at revision.yaml:58:17
   ✖ major  modified  schemas/Order/properties/note · maxLength
-      `maxLength` of `note` changed from '200' to '100'. (string-length-changed)
+      `maxLength` of `note` changed from '200' to '100'. (string-constraint-changed)
       at revision.yaml:61:42
   ✔ minor  added     schemas/Order/properties/pickupAt
       at revision.yaml:62:19

From 543fdce9d978ad705d34b536cbb5b1d39a048ff7 Mon Sep 17 00:00:00 2001
From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com>
Date: Fri, 25 Sep 2026 12:07:03 +0300
Subject: [PATCH 52/57] docs(diff): document the diff command, its rules, their
 configuration and diff rules in plugins

---
 docs/@v2/commands/diff.md                    | 304 ++++++++-----------
 docs/@v2/commands/index.md                   |   2 +-
 docs/@v2/configuration/index.md              |  13 +
 docs/@v2/configuration/reference/diff.md     |  71 +++++
 docs/@v2/custom-plugins/custom-diff-rules.md | 119 ++++++++
 docs/@v2/custom-plugins/index.md             |   2 +
 docs/@v2/rules.md                            |   1 +
 docs/@v2/rules/diff-rules.md                 | 133 ++++++++
 docs/@v2/v2.sidebars.yaml                    |   4 +
 9 files changed, 471 insertions(+), 178 deletions(-)
 create mode 100644 docs/@v2/configuration/reference/diff.md
 create mode 100644 docs/@v2/custom-plugins/custom-diff-rules.md
 create mode 100644 docs/@v2/rules/diff-rules.md

diff --git a/docs/@v2/commands/diff.md b/docs/@v2/commands/diff.md
index d0abb1fcaf..331c0bdfd9 100644
--- a/docs/@v2/commands/diff.md
+++ b/docs/@v2/commands/diff.md
@@ -2,209 +2,159 @@
 
 ## 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 and lists every change between them.
+It gives each change an impact: the semver part that a release with this change must bump.
+
+- `major`: the change can break a consumer, for example a removed operation.
+- `minor`: the change adds to the API, for example a new path.
+- `patch`: the change breaks nothing and adds nothing, for example a new description.
 
-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.
+Use the command in CI to stop a breaking change before a release, or to calculate the next version of your API.
+
+{% admonition type="warning" name="Experimental" %}
+This is an experimental feature.
+Its behavior, options, rule names, and output formats can change in future releases.
+{% /admonition %}
 
 ## 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
+redocly diff   [--fail-on=] [--check-version]
+redocly diff   [--format=] [--output=]
+redocly diff   [--config=] [--skip-rule=]...
 ```
 
 ## 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 %} +| Option | Type | Description | +| --------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| base | string | **REQUIRED.** Path, URL, or [alias](../configuration/reference/apis.md) of the older API description. | +| revision | string | **REQUIRED.** Path, URL, or alias of the newer API description. | +| --check-version | boolean | Fail when the `info.version` of the revision is not bumped as much as the changes require. See [Check the declared version](#check-the-declared-version). Default value is `false`. | +| --config | string | Specify path to the [configuration file](../configuration/index.md). | +| --fail-on | string | Exit with code `1` when a change of this impact or higher is found.
**Possible values:** `major`, `minor`, `patch`, `none`. Default value is `major`. | +| --format | string | Format for the output. See [Output formats](#output-formats).
**Possible values:** `stylish`, `json`, `markdown`, `html`, `github-actions`, `next-version`. Default value is `stylish`. | +| --help | boolean | Show help. | +| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | +| --output, -o | string | Write the report to a file instead of stdout. Every format supports it except `github-actions`. | +| --skip-rule | [string] | Turn off the [diff rules](../rules/diff-rules.md) with these names. | +| --version | boolean | Show version number. | + +## What the command reports + +The command reports what you added, removed, or changed in the API. +The [diff rules](../rules/diff-rules.md) give each change its impact. +A change that no rule reports is `minor` if you added something, and `patch` in all other cases. + +The command does not report these edits as changes: + +- a new order of parameters, servers, or tags +- a new name of a path parameter, for example `/menu/{id}` to `/menu/{menuItemId}` + +The diff rules support OpenAPI 3.x and AsyncAPI 3. +For other types of description, such as OpenAPI 2.0, the command reports the changes, but every change is `minor` or `patch`. +The command cannot compare two descriptions of different types, for example OpenAPI 2.0 and OpenAPI 3.1. -## 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. +## Output formats + +The command prints the report to stdout and all other messages to stderr. + +| Format | Output | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `stylish` | The changes grouped by endpoint, webhook, AsyncAPI channel, operation, or server, with the rule messages and a `file:line:col` link. | +| `json` | Every change with its impact, rule verdicts, values, and locations. Use this format when a script reads the result. | +| `markdown` | A table of the changes, for a pull request comment. | +| `html` | A page without external dependencies that you can open in a browser. | +| `github-actions` | An `error` annotation on the pull request for each rule verdict of a `major` change. Changes of other impacts get no annotation. | +| `next-version` | Only the next version, for example `2.0.0`. See [Calculate the next version](#calculate-the-next-version). | ## Examples -### Fail a CI pipeline on breaking changes +### Stop a breaking change in CI -By default the command exits with code `1` when it finds a breaking change. -Use it as a pull request check: +By default, the command exits with code `1` when it finds a `major` change: ```bash -redocly diff main-openapi.yaml pr-openapi.yaml -# exit code 1 when breaking changes are found +redocly diff main/openapi.yaml openapi.yaml ``` -### Generate an HTML report +To also fail on new features, set `--fail-on=minor`. +To get the report and never fail, set `--fail-on=none`. + +### Check the declared version -To write a report that you can share, use `--format=html` together with `--output`: +With `--check-version`, the command also fails when the `info.version` of the revision is lower than the version that the changes require: ```bash -redocly diff v1.yaml v2.yaml --format=html -o diff-report.html +redocly diff main/openapi.yaml openapi.yaml --check-version +``` + +For example, if the base declares `1.2.0` and the changes are `major`, the revision must declare `2.0.0` or higher: + +```text +❌ info.version went 1.2.0 → 1.3.0, but these changes require a major bump (2.0.0). ``` -### Annotate a pull request +- Below version `1.0.0`, semver lets a `minor` release break the API. + Thus, for a `major` change from `0.4.2`, the revision must declare `0.5.0` or higher. +- The command compares only the `major.minor.patch` numbers and ignores pre-release and build labels. +- A change to `info.version` itself does not require a bump. +- If the changes require a bump and `info.version` is not a semver string, the check fails. + +### Calculate the next version -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: +The `next-version` format prints the version of the base, bumped as the changes require: ```bash -redocly diff main-openapi.yaml pr-openapi.yaml --format=github-actions +NEXT_VERSION=$(redocly diff main/openapi.yaml openapi.yaml --format=next-version --fail-on=none) ``` -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`. +If no change requires a bump, the command prints the base version. +Set `--fail-on=none` so that a `major` change does not stop your script. + +### Comment on a pull request + +To attach a report to a pull request, write it to a file: + +```bash +redocly diff main/openapi.yaml openapi.yaml --format=markdown -o diff.md +``` + +To show each breaking change on the changed line, use the `github-actions` format in a GitHub Actions workflow: + +```bash +redocly diff main/openapi.yaml openapi.yaml --format=github-actions +``` + +### Compare two APIs by their aliases + +If you define both descriptions in the `apis` section of `redocly.yaml`, use their aliases: + +```yaml +apis: + cafe@v1: + root: v1/openapi.yaml + cafe@v2: + root: v2/openapi.yaml +``` + +```bash +redocly diff cafe@v1 cafe@v2 +``` + +### Change the impact of a rule + +To make a rule give another impact, or to turn it off, add a [`diff` section](../configuration/reference/diff.md) to `redocly.yaml`: + +```yaml +extends: + - diff-recommended +diff: + enum-values-added: minor +``` + +## Resources + +- The [diff rules](../rules/diff-rules.md) that judge the changes, and how to configure them. +- [Diff rules in plugins](../custom-plugins/custom-diff-rules.md) for the changes that the built-in rules do not judge. diff --git a/docs/@v2/commands/index.md b/docs/@v2/commands/index.md index 29283118d8..2c1d02eaa7 100644 --- a/docs/@v2/commands/index.md +++ b/docs/@v2/commands/index.md @@ -24,7 +24,7 @@ Docs linting commands: CI and delivery commands: - [`bundle`](bundle.md) Bundle an API description. -- [`diff`](diff.md) Compare two API descriptions and detect breaking changes [experimental feature]. +- [`diff`](diff.md) Compare two API descriptions and rate each change by the semver bump it requires [experimental feature]. - [`join`](join.md) Join API descriptions [experimental feature]. API testing and drift commands: diff --git a/docs/@v2/configuration/index.md b/docs/@v2/configuration/index.md index 5ba7d37a2f..0a559745ec 100644 --- a/docs/@v2/configuration/index.md +++ b/docs/@v2/configuration/index.md @@ -112,6 +112,19 @@ recheck: The `lint` command ignores the Recheck presets and the `recheck` block, and the `recheck` command ignores API rulesets. For more information, visit the [`recheck` block reference](./reference/recheck.md) and the [Markdown and prose linting](../recheck/index.md) section. +### Configure `diff` rules + +In the `diff` section, set the impact that each [diff rule](../rules/diff-rules.md) gives to a change: `major`, `minor`, `patch`, or `off`. +The [`diff` command](../commands/diff.md) uses these rules to compare two versions of an API. +See the [`diff` reference](./reference/diff.md) for all options. + +```yaml +extends: + - diff-recommended +diff: + enum-values-added: minor +``` + ### Configure OpenAPI features and documentation styles diff --git a/docs/@v2/configuration/reference/diff.md b/docs/@v2/configuration/reference/diff.md new file mode 100644 index 0000000000..882d03fb7e --- /dev/null +++ b/docs/@v2/configuration/reference/diff.md @@ -0,0 +1,71 @@ +# `diff` + +## Introduction + +The `diff` configuration section sets the impact that each [diff rule](../../rules/diff-rules.md) gives to a change. +The [`diff` command](../../commands/diff.md) uses these impacts to rate the changes between two API descriptions. +Configure the built-in diff rules and the [diff rules from plugins](../../custom-plugins/custom-diff-rules.md) in this section. + +Use the `diff` section only at the root of a configuration file. +An [API-specific section](./apis.md) does not support it. + +{% admonition type="warning" name="Experimental" %} +The `diff` section is an experimental feature. +It can change in future releases. +{% /admonition %} + +## Options + +{% table %} + +- Option +- Type +- Description + +--- + +- {rule name} +- string +- **REQUIRED**. The impact that the rule gives to a change. + Must be one of `major`, `minor`, `patch`, or `off`. + The key is a built-in rule, for example `enum-values-added`, or a rule from a plugin, for example `cafe/tag-removed`. + +{% /table %} + +To set an impact for one specification version only, use one of these sections with the same options: + +- `oas3_0Diff` +- `oas3_1Diff` +- `oas3_2Diff` +- `async3Diff` + +## Examples + +The following example starts from the `diff-recommended` ruleset, lowers the impact of one rule, and turns off another: + +```yaml +extends: + - diff-recommended +diff: + enum-values-added: minor + schema-format-changed: off +``` + +The following example changes the impact of a rule for OpenAPI 3.1 descriptions only: + +```yaml +extends: + - diff-recommended +oas3_1Diff: + schema-type-changed: minor +``` + +## Related options + +- [extends](./extends.md) adds the `diff-recommended` ruleset. +- [plugins](./plugins.md) adds diff rules from custom plugins. + +## Resources + +- See the list of [diff rules](../../rules/diff-rules.md). +- Read the [`diff` command](../../commands/diff.md) documentation. diff --git a/docs/@v2/custom-plugins/custom-diff-rules.md b/docs/@v2/custom-plugins/custom-diff-rules.md new file mode 100644 index 0000000000..86166443ff --- /dev/null +++ b/docs/@v2/custom-plugins/custom-diff-rules.md @@ -0,0 +1,119 @@ +# Diff rules in plugins + +A plugin can add [diff rules](../rules/diff-rules.md) for the changes that the built-in rules do not judge. +The [`diff` command](../commands/diff.md) runs a plugin rule in the same way as a built-in rule. + +{% admonition type="warning" name="Experimental" %} +Diff rules are an experimental feature. +The plugin interface for diff rules can change in future releases. +{% /admonition %} + +## Build the diff rule + +A diff rule is a function that returns a visitor, like a [lint rule](./custom-rules.md). +The difference is what the visitor gets: a lint rule visits nodes of one document, and a diff rule visits changes between two documents. +The command calls the visitor once for each change, with the key of the node type that changed. + +This example rule reports a tag that was removed from an operation. +It is in the `plugins/rules/tag-removed.js` file: + +```js +export default function TagRemoved() { + return { + Operation(change, { report }) { + if (change.kind !== 'modified' || change.property !== 'tags') return; + const tags = change.revision.value ?? []; + const removed = (change.base.value ?? []).filter((tag) => !tags.includes(tag)); + if (removed.length) { + report({ message: `Tags removed: ${removed.join(', ')}.` }); + } + }, + }; +} +``` + +Add the rule to the `diff` section of the plugin, under `oas3` or `async3`. +This is the `plugins/cafe.js` file: + +```js +import TagRemoved from './rules/tag-removed.js'; + +export default function cafePlugin() { + return { + id: 'cafe', + diff: { + oas3: { + 'tag-removed': TagRemoved, + }, + }, + }; +} +``` + +A plugin rule does not run until you give it an impact. +Add the plugin and the impact to `redocly.yaml`: + +```yaml +plugins: + - plugins/cafe.js +extends: + - diff-recommended +diff: + cafe/tag-removed: major +``` + +When you remove a tag from an operation, `redocly diff` reports the change as `major`: + +```text +GET /menu + ✖ major modified tags + Tags removed: public. (cafe/tag-removed) + at revision.yaml:6:13 +``` + +## The change object + +The visitor gets these fields of a change: + +| Field | Description | +| ---------- | ------------------------------------------------------------------------------------------------------------------------- | +| `kind` | `added`, `removed`, or `modified`. | +| `property` | The name of the value that changed on the node. Only a `modified` change has it. | +| `base` | The `value` and `location` of the node, or of the property, in the base. A change of kind `added` does not have it. | +| `revision` | The `value` and `location` of the node, or of the property, in the revision. A change of kind `removed` does not have it. | + +A change of kind `modified` contains one value that changed, such as `tags` or `required`. +A change of kind `added` or `removed` contains the whole node. + +## The context object + +| Property | Description | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `report` | The function that reports the change. It takes a `message`, and an optional `location` to point to another place than the change. | +| `directions` | A list of the directions of the data: `request`, `response`, both, or none. See [Requests and responses](../rules/diff-rules.md#requests-and-responses). | +| `specVersion` | The version of the specification, for example `oas3_1`. | + +Use `directions` when the same change breaks only one side of the API. +For example, this rule reports a new `enum` value only in a response: + +```js +export default function EnumValueAdded() { + return { + Schema(change, { report, directions }) { + if (change.kind !== 'modified' || change.property !== 'enum') return; + if (!directions.includes('response')) return; + report({ message: 'A response can send a new enum value.' }); + }, + }; +} +``` + +## Visitor keys + +- A key is the name of a node type, as in a lint rule. + To find the type of a node, use the [`inspect-node-types` command](../commands/inspect-node-types.md). +- A nested key limits the rule to the changes below another node type. + For example, `SchemaProperties: { Schema() {} }` visits the properties of a schema only. +- The `any` key visits every change. + You can use it only at the top level of the visitor. +- A diff visitor does not support `enter`, `leave`, or `skip`. diff --git a/docs/@v2/custom-plugins/index.md b/docs/@v2/custom-plugins/index.md index 2645acc25e..84a881956f 100644 --- a/docs/@v2/custom-plugins/index.md +++ b/docs/@v2/custom-plugins/index.md @@ -21,6 +21,8 @@ Custom plugins can extend the built-in functionality of Redocly in the following - **Write your own custom rules in JavaScript**. If your API standards include elements that can't be expressed by our existing rules, including configurable ones, you can code your own rules. See the [documentation on writing your own rules in custom plugins](./custom-rules.md) for more information. +- **Write your own diff rules in JavaScript**. If the `diff` command must find a breaking change that the built-in [diff rules](../rules/diff-rules.md) do not find, see [diff rules in plugins](./custom-diff-rules.md). + - **Define configuration in a custom plugin**. Redocly supports [resuable ruleset configuration](../guides/configure-rules.md#create-reusable-configuration) already, but defining configuration in a custom plugin is particularly useful when the configuration belongs alongside other plugin elements. See the [documentation on configuration in plugins](./custom-config.md) for more information. - **Extend existing standard definitions with additional type definitions**. Working with extensions to defined standards can be helped by [extending the supported types](./extended-types.md). diff --git a/docs/@v2/rules.md b/docs/@v2/rules.md index 0ffcf2dbbd..40d0cf569b 100644 --- a/docs/@v2/rules.md +++ b/docs/@v2/rules.md @@ -23,6 +23,7 @@ Rulesets are groups of rules that are applied together, and APIs can be checked - A [recommended](./rules/recommended.md) ruleset is our unabashedly opinionated recommendation of what we think a good API looks like. It's a great place to start, before adapting to your own context. - A [recommended-strict](./rules/recommended.md#recommended-strict-ruleset) ruleset is identical to the `recommended`, except it elevates all warnings to errors. It's the ideal option for those who don't want to miss anything. - A [minimal](./rules/minimal.md) ruleset is a good starting point for an existing API that doesn't currently conform to any standard. It has fewer rules that cause an error, with others either downgraded to a warning or turned off completely. +- The [diff-recommended](./rules/diff-rules.md#diff-recommended-ruleset) ruleset holds the rules that the `diff` command uses to find breaking changes between two versions of an API. Enable a ruleset by adding a block like this to the Redocly configuration file: diff --git a/docs/@v2/rules/diff-rules.md b/docs/@v2/rules/diff-rules.md new file mode 100644 index 0000000000..fd30bc59aa --- /dev/null +++ b/docs/@v2/rules/diff-rules.md @@ -0,0 +1,133 @@ +--- +slug: /docs/cli/rules/diff-rules +--- + +# Diff rules + +The [`diff` command](../commands/diff.md) uses diff rules to judge the changes between two API descriptions. +A rule looks at one kind of change, such as a removed operation. +If the change can break a consumer, the rule reports it with a message, and the change gets the impact that you configure for the rule. + +{% admonition type="warning" name="Experimental" %} +Diff rules are an experimental feature. +Their names, messages, and configuration can change in future releases. +{% /admonition %} + +## `diff-recommended` ruleset + +The `diff-recommended` ruleset turns on every diff rule with the `major` impact. +The `diff` command uses it when you have no `redocly.yaml` file. + +If you have a `redocly.yaml` file, only the rules that it lists run. +To start from the ruleset, extend it: + +```yaml +extends: + - recommended + - diff-recommended +``` + +## Configure diff rules + +Set the impact of a rule in the [`diff` section](../configuration/reference/diff.md) of `redocly.yaml`. +The value is one of `major`, `minor`, `patch`, or `off`: + +```yaml +extends: + - diff-recommended +diff: + enum-values-added: minor + schema-format-changed: off +``` + +To turn off rules for one run, use the `--skip-rule` option: + +```bash +redocly diff v1.yaml v2.yaml --skip-rule=enum-values-added +``` + +A rule from a [plugin](../custom-plugins/custom-diff-rules.md) has the plugin id in its name, for example `cafe/tag-removed`. + +## Rules + +Each rule applies to OpenAPI 3.x and to AsyncAPI 3, except the rules that name one of them. +An AsyncAPI 3 payload is a schema, so the schema rules judge it too. + +### Requests and responses + +The same change can break one side of an API and be safe for the other. +For example, a new `enum` value is safe in a request, but it can break a client that reads a response. +Thus, many rules look at the direction of the data: + +- In OpenAPI, parameters and request bodies are requests, and responses are responses. + Below `callbacks` and `webhooks`, the API sends the request, so the directions are the other way around. +- In AsyncAPI 3, an operation with `action: receive` gets requests, and an operation with `action: send` sends responses. + A `reply` travels in the other direction. +- In OpenAPI, a schema with `readOnly: true` is response data only, and a schema with `writeOnly: true` is request data only. +- A component has the directions of the places that use it. + If a request and a response use the same schema, the rules judge its changes for both. + +### Operations, paths, and channels + +| Rule | Reports | +| -------------------------- | ---------------------------------------------------------------------------------------- | +| `path-removed` | A removed path. OpenAPI only. | +| `operation-removed` | A removed operation. | +| `operation-action-changed` | An operation that now sends instead of receives, or the other way around. AsyncAPI only. | +| `channel-removed` | A removed channel, or the removal of all channels. AsyncAPI only. | +| `channel-address-changed` | A new `address` of a channel. AsyncAPI only. | +| `server-removed` | A removed server, or the removal of all servers of the document. | + +In OpenAPI, a path or an operation that loses its own `servers` uses the servers of the document. +Thus, `server-removed` does not report the removal of that `servers` list. + +### Parameters, requests, and responses + +These rules apply to OpenAPI only. + +| Rule | Reports | +| --------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `parameter-removed` | A removed request parameter, or the removal of all parameters of an operation. | +| `parameter-became-required` | A request parameter that clients must now send: a new required parameter, or one that is now required. | +| `parameter-serialization-changed` | A new `style`, `explode`, `allowReserved`, or `allowEmptyValue` value of a request parameter. | +| `request-body-removed` | A removed request body. | +| `request-body-became-required` | A request body that is now required. | +| `response-removed` | A removed response. | +| `response-header-removed` | A removed response header, or the removal of all headers of a response. | +| `media-type-removed` | A removed media type, for example `application/xml`, or the removal of all media types. | + +### Messages + +These rules apply to AsyncAPI only. + +| Rule | Reports | +| ------------------------------ | --------------------------------------------------------------- | +| `message-removed` | A removed message, or the removal of all messages of a channel. | +| `message-content-type-changed` | A new `contentType` of a message. | + +### Schemas + +| Rule | Reports | +| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `property-removed` | A removed property in a response, or the removal of all properties of a schema. | +| `required-properties-added` | New `required` properties in a request. | +| `required-properties-removed` | Properties that are no longer `required` in a response. | +| `enum-values-removed` | Removed `enum` values in a request. | +| `enum-values-added` | New `enum` values in a response. | +| `schema-type-changed` | A `type` that accepts less in a request, or more in a response. | +| `numeric-range-changed` | A change to `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, or `multipleOf`. | +| `string-constraint-changed` | A change to `minLength`, `maxLength`, or `pattern`. | +| `schema-format-changed` | A change to `format`. | +| `additional-properties-changed` | A change to `additionalProperties`. | +| `schema-combinator-changed` | A removed `oneOf` or `anyOf` subschema in a request, or a new one in a response. A new `allOf` subschema in a request, or a removed one in a response. OpenAPI only. | + +The `numeric-range-changed`, `string-constraint-changed`, `schema-format-changed`, and `additional-properties-changed` rules report a change that accepts fewer values in a request, or more values in a response. + +### Security and references + +| Rule | Reports | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `security-requirement-changed` | A `security` list that clients can no longer pass: authentication where there was none, a new scheme or new scopes in a requirement, or a removed alternative. OpenAPI only. | +| `security-scheme-changed` | A new `type`, `scheme`, `in`, `name`, `bearerFormat`, or `openIdConnectUrl` of a security scheme. OpenAPI only. | +| `security-scheme-removed` | A removed security scheme. OpenAPI only. | +| `ref-target-changed` | A `$ref` that points to another target, which describes other data. | diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index 95dffca202..0f134fca28 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -208,6 +208,7 @@ - page: rules/arazzo/workflow-dependsOn.md - page: rules/arazzo/workflowId-unique.md + - page: rules/diff-rules.md - page: rules/configure-rules.md label: Rule configuration - page: rules/configurable-rules.md @@ -248,6 +249,7 @@ - page: configuration/reference/apis.md - page: configuration/reference/client.md - page: configuration/reference/decorators.md + - page: configuration/reference/diff.md - page: configuration/reference/extends.md - page: configuration/reference/plugins.md - page: configuration/reference/preprocessors.md @@ -259,6 +261,8 @@ items: - label: Custom rules page: custom-plugins/custom-rules.md + - label: Custom diff rules + page: custom-plugins/custom-diff-rules.md - label: Custom decorators page: custom-plugins/custom-decorators.md - label: Custom config From 02fb8737f497e16c13316838cb966560c3c70bc6 Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:37:44 +0300 Subject: [PATCH 53/57] refactor(diff): rework the diff rules by one formula, simplify the node and diff trees, and trim the docs --- .changeset/purple-onions-shave.md | 2 - docs/@v2/commands/diff.md | 102 +- docs/@v2/configuration/reference/diff.md | 26 +- docs/@v2/custom-plugins/custom-diff-rules.md | 119 -- docs/@v2/custom-plugins/index.md | 2 - docs/@v2/rules/diff-rules.md | 162 ++- docs/@v2/v2.sidebars.yaml | 2 - .../__snapshots__/format.test.ts.snap | 44 +- .../diff/format/__tests__/format.test.ts | 40 +- packages/cli/src/commands/diff/format/json.ts | 6 +- .../cli/src/commands/diff/format/location.ts | 2 +- .../cli/src/commands/diff/format/stylish.ts | 5 +- packages/cli/src/commands/diff/index.ts | 28 +- packages/core/__tests__/utils.ts | 12 + packages/core/src/config/diff-recommended.ts | 8 +- packages/core/src/config/rules.ts | 25 + .../core/src/diff/__tests__/changes.test.ts | 48 +- .../core/src/diff/__tests__/diff-tree.test.ts | 430 ++++-- .../core/src/diff/__tests__/direction.test.ts | 276 ++-- .../core/src/diff/__tests__/impact.test.ts | 55 +- .../core/src/diff/__tests__/index.test.ts | 455 +++++- .../core/src/diff/__tests__/judge.test.ts | 128 +- .../src/diff/__tests__/pair-children.test.ts | 120 ++ packages/core/src/diff/__tests__/utils.ts | 70 +- packages/core/src/diff/changes.ts | 74 +- packages/core/src/diff/diff-node.ts | 48 + packages/core/src/diff/diff-tree.ts | 153 +- packages/core/src/diff/direction.ts | 121 +- packages/core/src/diff/impact.ts | 17 +- packages/core/src/diff/index.ts | 107 +- packages/core/src/diff/judge.ts | 93 +- packages/core/src/diff/pair-children.ts | 112 ++ .../additional-properties-changed.test.ts | 138 -- .../__tests__/channel-address-changed.test.ts | 113 +- .../rules/__tests__/channel-removed.test.ts | 37 +- .../rules/__tests__/enum-values-added.test.ts | 2 +- .../__tests__/enum-values-removed.test.ts | 77 +- .../__tests__/media-type-removed.test.ts | 95 +- .../message-content-type-changed.test.ts | 74 +- .../rules/__tests__/message-removed.test.ts | 50 +- .../__tests__/numeric-range-changed.test.ts | 303 ---- .../operation-action-changed.test.ts | 4 +- .../__tests__/operation-id-changed.test.ts | 184 +++ .../rules/__tests__/operation-removed.test.ts | 220 ++- .../parameter-became-required.test.ts | 109 +- .../rules/__tests__/parameter-removed.test.ts | 239 +++- .../parameter-serialization-changed.test.ts | 71 +- .../diff/rules/__tests__/path-removed.test.ts | 443 +++++- .../rules/__tests__/property-removed.test.ts | 127 +- .../__tests__/ref-target-changed.test.ts | 92 -- .../request-body-became-required.test.ts | 4 +- .../__tests__/request-body-removed.test.ts | 2 +- .../required-properties-added.test.ts | 148 +- .../required-properties-removed.test.ts | 85 +- .../__tests__/response-header-removed.test.ts | 108 +- .../rules/__tests__/response-removed.test.ts | 116 +- .../schema-combinator-changed.test.ts | 74 +- .../schema-constraint-changed.test.ts | 1272 +++++++++++++++++ .../__tests__/schema-format-changed.test.ts | 79 - .../__tests__/schema-type-changed.test.ts | 12 +- .../security-requirement-changed.test.ts | 119 +- .../__tests__/security-scheme-changed.test.ts | 127 +- .../__tests__/security-scheme-removed.test.ts | 52 - .../rules/__tests__/server-removed.test.ts | 194 ++- .../string-constraint-changed.test.ts | 129 -- .../rules/additional-properties-changed.ts | 3 - .../src/diff/rules/channel-address-changed.ts | 17 +- .../core/src/diff/rules/channel-removed.ts | 12 +- .../core/src/diff/rules/enum-values-added.ts | 16 +- .../src/diff/rules/enum-values-removed.ts | 16 +- packages/core/src/diff/rules/index.ts | 35 +- .../core/src/diff/rules/media-type-removed.ts | 22 +- .../rules/message-content-type-changed.ts | 17 +- .../core/src/diff/rules/message-removed.ts | 18 +- .../src/diff/rules/numeric-range-changed.ts | 93 -- .../diff/rules/operation-action-changed.ts | 17 +- .../src/diff/rules/operation-id-changed.ts | 24 + .../core/src/diff/rules/operation-removed.ts | 41 +- .../diff/rules/parameter-became-required.ts | 46 +- .../core/src/diff/rules/parameter-removed.ts | 38 +- .../rules/parameter-serialization-changed.ts | 24 +- packages/core/src/diff/rules/path-removed.ts | 59 +- .../core/src/diff/rules/property-removed.ts | 28 +- .../core/src/diff/rules/ref-target-changed.ts | 22 - .../rules/request-body-became-required.ts | 4 +- .../src/diff/rules/request-body-removed.ts | 4 +- .../diff/rules/required-properties-added.ts | 18 +- .../diff/rules/required-properties-removed.ts | 23 +- .../src/diff/rules/response-header-removed.ts | 38 +- .../core/src/diff/rules/response-removed.ts | 17 +- .../diff/rules/schema-combinator-changed.ts | 36 +- .../diff/rules/schema-constraint-changed.ts | 202 +++ .../src/diff/rules/schema-format-changed.ts | 3 - .../src/diff/rules/schema-type-changed.ts | 18 +- .../rules/security-requirement-changed.ts | 18 +- .../src/diff/rules/security-scheme-changed.ts | 19 +- .../src/diff/rules/security-scheme-removed.ts | 9 - .../core/src/diff/rules/server-removed.ts | 53 +- .../diff/rules/string-constraint-changed.ts | 3 - packages/core/src/diff/rules/utils.ts | 125 +- packages/core/src/diff/specs/async3.ts | 24 - packages/core/src/diff/specs/index.ts | 11 - packages/core/src/diff/specs/oas3.ts | 82 -- packages/core/src/diff/types.ts | 57 +- packages/core/src/index.ts | 2 +- .../src/node-tree/__tests__/access.test.ts | 82 ++ .../src/node-tree/__tests__/build.test.ts | 82 -- .../src/node-tree/__tests__/index.test.ts | 221 +++ .../core/src/node-tree/__tests__/utils.ts | 40 + packages/core/src/node-tree/access.ts | 33 +- packages/core/src/node-tree/build.ts | 73 - packages/core/src/node-tree/index.ts | 106 ++ packages/core/src/node-tree/types.ts | 12 +- .../src/utils/__tests__/is-scalar.test.ts | 12 +- .../src/utils/__tests__/levenshtein.test.ts | 24 + packages/core/src/utils/is-scalar.ts | 4 +- packages/core/src/utils/items-not-in.ts | 6 + packages/core/src/utils/levenshtein.ts | 30 + packages/core/src/utils/path-template.ts | 12 - tests/e2e/diff/asyncapi3/snapshot.txt | 6 +- tests/e2e/diff/config-file/snapshot.txt | 2 +- .../diff/configuration/snapshot-skip-rule.txt | 32 +- tests/e2e/diff/configuration/snapshot.txt | 34 +- tests/e2e/diff/diff.test.ts | 8 + .../json-and-next-version/snapshot-json.txt | 4 +- .../diff/markdown-and-html/snapshot-html.txt | 4 +- .../markdown-and-html/snapshot-markdown.txt | 4 +- tests/e2e/diff/multi-file/base/openapi.yaml | 11 + .../diff/multi-file/base/schemas/item.yaml | 4 + .../e2e/diff/multi-file/revision/openapi.yaml | 11 + .../multi-file/revision/schemas/item.yaml | 3 + tests/e2e/diff/multi-file/snapshot.txt | 14 + .../e2e/diff/openapi-3-0-to-3-1/snapshot.txt | 6 +- .../snapshot-check-version.txt | 36 +- .../openapi-breaking-changes/snapshot.txt | 36 +- .../snapshot-fail-on-minor.txt | 2 +- .../openapi-compatible-changes/snapshot.txt | 2 +- 137 files changed, 7259 insertions(+), 2802 deletions(-) delete mode 100644 docs/@v2/custom-plugins/custom-diff-rules.md create mode 100644 packages/core/src/diff/__tests__/pair-children.test.ts create mode 100644 packages/core/src/diff/diff-node.ts create mode 100644 packages/core/src/diff/pair-children.ts delete mode 100644 packages/core/src/diff/rules/__tests__/additional-properties-changed.test.ts delete mode 100644 packages/core/src/diff/rules/__tests__/numeric-range-changed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/operation-id-changed.test.ts delete mode 100644 packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts create mode 100644 packages/core/src/diff/rules/__tests__/schema-constraint-changed.test.ts delete mode 100644 packages/core/src/diff/rules/__tests__/schema-format-changed.test.ts delete mode 100644 packages/core/src/diff/rules/__tests__/security-scheme-removed.test.ts delete mode 100644 packages/core/src/diff/rules/__tests__/string-constraint-changed.test.ts delete mode 100644 packages/core/src/diff/rules/additional-properties-changed.ts delete mode 100644 packages/core/src/diff/rules/numeric-range-changed.ts create mode 100644 packages/core/src/diff/rules/operation-id-changed.ts delete mode 100644 packages/core/src/diff/rules/ref-target-changed.ts create mode 100644 packages/core/src/diff/rules/schema-constraint-changed.ts delete mode 100644 packages/core/src/diff/rules/schema-format-changed.ts delete mode 100644 packages/core/src/diff/rules/security-scheme-removed.ts delete mode 100644 packages/core/src/diff/rules/string-constraint-changed.ts delete mode 100644 packages/core/src/diff/specs/async3.ts delete mode 100644 packages/core/src/diff/specs/index.ts delete mode 100644 packages/core/src/diff/specs/oas3.ts create mode 100644 packages/core/src/node-tree/__tests__/access.test.ts delete mode 100644 packages/core/src/node-tree/__tests__/build.test.ts create mode 100644 packages/core/src/node-tree/__tests__/index.test.ts create mode 100644 packages/core/src/node-tree/__tests__/utils.ts delete mode 100644 packages/core/src/node-tree/build.ts create mode 100644 packages/core/src/node-tree/index.ts create mode 100644 packages/core/src/utils/__tests__/levenshtein.test.ts create mode 100644 packages/core/src/utils/items-not-in.ts create mode 100644 packages/core/src/utils/levenshtein.ts delete mode 100644 packages/core/src/utils/path-template.ts create mode 100644 tests/e2e/diff/multi-file/base/openapi.yaml create mode 100644 tests/e2e/diff/multi-file/base/schemas/item.yaml create mode 100644 tests/e2e/diff/multi-file/revision/openapi.yaml create mode 100644 tests/e2e/diff/multi-file/revision/schemas/item.yaml create mode 100644 tests/e2e/diff/multi-file/snapshot.txt diff --git a/.changeset/purple-onions-shave.md b/.changeset/purple-onions-shave.md index ce1594648b..0fe296edc6 100644 --- a/.changeset/purple-onions-shave.md +++ b/.changeset/purple-onions-shave.md @@ -6,5 +6,3 @@ Added an experimental `diff` command that compares two API descriptions, reports what was added, removed, and modified, and rates every change by the semver bump it requires (`patch`, `minor`, or `major`). `--fail-on` sets the impact that fails the run and `--check-version` verifies that `info.version` was bumped accordingly. Configure the impact per rule in `redocly.yaml` with `extends: [diff-recommended]` and a `diff` block, for example `diff: { operation-removed: minor }`. A `oas3_0Diff`, `oas3_1Diff`, `oas3_2Diff` or `async3Diff` block sets the impact for one specification, the way the per-specification rule blocks work for `lint`. `--skip-rule` turns a rule off for a single run. - -A plugin can add diff rules through a `diff` block with `oas3` or `async3` rules, the way it adds lint rules; its rules are named `/` and are off until the `diff` block gives them an impact. diff --git a/docs/@v2/commands/diff.md b/docs/@v2/commands/diff.md index 331c0bdfd9..eea4a78b40 100644 --- a/docs/@v2/commands/diff.md +++ b/docs/@v2/commands/diff.md @@ -2,14 +2,14 @@ ## Introduction -The `diff` command compares two API descriptions and lists every change between them. -It gives each change an impact: the semver part that a release with this change must bump. +The `diff` command compares two API descriptions and lists the changes between them. +The command gives each change an impact: the semver part that a release with this change must bump. -- `major`: the change can break a consumer, for example a removed operation. +- `major`: the change can break a client, for example a removed operation. - `minor`: the change adds to the API, for example a new path. -- `patch`: the change breaks nothing and adds nothing, for example a new description. +- `patch`: the change does not break or add anything, for example a new description. -Use the command in CI to stop a breaking change before a release, or to calculate the next version of your API. +Use the command in CI to stop breaking changes, or to get the next version of your API. {% admonition type="warning" name="Experimental" %} This is an experimental feature. @@ -31,91 +31,93 @@ redocly diff [--config=] [--skip-rule=]... | --------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | base | string | **REQUIRED.** Path, URL, or [alias](../configuration/reference/apis.md) of the older API description. | | revision | string | **REQUIRED.** Path, URL, or alias of the newer API description. | -| --check-version | boolean | Fail when the `info.version` of the revision is not bumped as much as the changes require. See [Check the declared version](#check-the-declared-version). Default value is `false`. | +| --check-version | boolean | Fail if `info.version` of the revision is lower than the version that the changes require. See [Check the version](#check-the-version). Default value is `false`. | | --config | string | Specify path to the [configuration file](../configuration/index.md). | -| --fail-on | string | Exit with code `1` when a change of this impact or higher is found.
**Possible values:** `major`, `minor`, `patch`, `none`. Default value is `major`. | +| --fail-on | string | Exit with code `1` if a change has this impact or a higher impact.
**Possible values:** `major`, `minor`, `patch`, `none`. Default value is `major`. | | --format | string | Format for the output. See [Output formats](#output-formats).
**Possible values:** `stylish`, `json`, `markdown`, `html`, `github-actions`, `next-version`. Default value is `stylish`. | | --help | boolean | Show help. | | --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | -| --output, -o | string | Write the report to a file instead of stdout. Every format supports it except `github-actions`. | -| --skip-rule | [string] | Turn off the [diff rules](../rules/diff-rules.md) with these names. | +| --output, -o | string | Write the report to this file. All formats support this option, except `github-actions`. | +| --skip-rule | [string] | Turn off these [diff rules](../rules/diff-rules.md) for this run. | | --version | boolean | Show version number. | -## What the command reports - -The command reports what you added, removed, or changed in the API. -The [diff rules](../rules/diff-rules.md) give each change its impact. -A change that no rule reports is `minor` if you added something, and `patch` in all other cases. - -The command does not report these edits as changes: - -- a new order of parameters, servers, or tags -- a new name of a path parameter, for example `/menu/{id}` to `/menu/{menuItemId}` - -The diff rules support OpenAPI 3.x and AsyncAPI 3. -For other types of description, such as OpenAPI 2.0, the command reports the changes, but every change is `minor` or `patch`. -The command cannot compare two descriptions of different types, for example OpenAPI 2.0 and OpenAPI 3.1. +## How the command compares descriptions + +- The [diff rules](../rules/diff-rules.md) find the changes that can break a client and set their impact. + A change that no rule reports is `minor` if you add something, and `patch` in all other cases. +- A new component is `patch`, because it has no effect until something uses it. +- The command follows `$ref`s, also into other files. + It shows a change in a component at the first place that uses the component, with the line of the change in its file. +- If you rename an item, such as a path, a property, or a parameter, and the new item is similar to the old item, the command shows one changed item. + It does not show a removed item and an added item. +- A new order of list items, such as parameters or `oneOf` subschemas, is not a change. +- The command applies the preprocessors from your configuration, but not the [decorators](../decorators.md). + To compare the descriptions that you publish, bundle them with your decorators first. +- The diff rules support OpenAPI 3.x and AsyncAPI 3. + For other description types, the command lists the changes, but all changes are `minor` or `patch`. +- The command cannot compare descriptions of different types, for example OpenAPI 2.0 and OpenAPI 3.1. ## Output formats The command prints the report to stdout and all other messages to stderr. -| Format | Output | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `stylish` | The changes grouped by endpoint, webhook, AsyncAPI channel, operation, or server, with the rule messages and a `file:line:col` link. | -| `json` | Every change with its impact, rule verdicts, values, and locations. Use this format when a script reads the result. | -| `markdown` | A table of the changes, for a pull request comment. | -| `html` | A page without external dependencies that you can open in a browser. | -| `github-actions` | An `error` annotation on the pull request for each rule verdict of a `major` change. Changes of other impacts get no annotation. | -| `next-version` | Only the next version, for example `2.0.0`. See [Calculate the next version](#calculate-the-next-version). | +| Format | Output | +| ---------------- | ----------------------------------------------------------------------------------------------------------- | +| `stylish` | The changes grouped by path, webhook, channel, operation, or server, with rule messages and file locations. | +| `json` | All data of each change. Use this format in scripts. | +| `markdown` | A table of the changes for a pull request comment. | +| `html` | A standalone page that you open in a browser. | +| `github-actions` | An error annotation for each rule message of a `major` change. | +| `next-version` | Only the next version, for example `2.0.0`. See [Get the next version](#get-the-next-version). | ## Examples -### Stop a breaking change in CI +### Stop breaking changes in CI -By default, the command exits with code `1` when it finds a `major` change: +By default, the command exits with code `1` if it finds a `major` change: ```bash redocly diff main/openapi.yaml openapi.yaml ``` -To also fail on new features, set `--fail-on=minor`. -To get the report and never fail, set `--fail-on=none`. +To also fail on new features, use `--fail-on=minor`. +To never fail, use `--fail-on=none`. -### Check the declared version +### Check the version -With `--check-version`, the command also fails when the `info.version` of the revision is lower than the version that the changes require: +With `--check-version`, the command also fails if `info.version` of the revision is lower than the version that the changes require: ```bash redocly diff main/openapi.yaml openapi.yaml --check-version ``` -For example, if the base declares `1.2.0` and the changes are `major`, the revision must declare `2.0.0` or higher: +For example, if the base version is `1.2.0` and a change is `major`, the revision version must be `2.0.0` or higher: ```text ❌ info.version went 1.2.0 → 1.3.0, but these changes require a major bump (2.0.0). ``` -- Below version `1.0.0`, semver lets a `minor` release break the API. - Thus, for a `major` change from `0.4.2`, the revision must declare `0.5.0` or higher. -- The command compares only the `major.minor.patch` numbers and ignores pre-release and build labels. -- A change to `info.version` itself does not require a bump. +- Below version `1.0.0`, semver allows a `minor` release to break the API. + Thus, a `major` change from `0.4.2` requires `0.5.0` or higher. +- The command compares only the `major.minor.patch` numbers. + It ignores pre-release and build labels. +- A change to `info.version` does not require a bump. - If the changes require a bump and `info.version` is not a semver string, the check fails. -### Calculate the next version +### Get the next version -The `next-version` format prints the version of the base, bumped as the changes require: +The `next-version` format prints the base version, bumped as the changes require: ```bash NEXT_VERSION=$(redocly diff main/openapi.yaml openapi.yaml --format=next-version --fail-on=none) ``` If no change requires a bump, the command prints the base version. -Set `--fail-on=none` so that a `major` change does not stop your script. +Use `--fail-on=none`, so that a `major` change does not stop your script. ### Comment on a pull request -To attach a report to a pull request, write it to a file: +Write the report to a file, and attach the file to a pull request comment: ```bash redocly diff main/openapi.yaml openapi.yaml --format=markdown -o diff.md @@ -129,7 +131,7 @@ redocly diff main/openapi.yaml openapi.yaml --format=github-actions ### Compare two APIs by their aliases -If you define both descriptions in the `apis` section of `redocly.yaml`, use their aliases: +If the `apis` section of `redocly.yaml` has both descriptions, use their aliases: ```yaml apis: @@ -145,7 +147,7 @@ redocly diff cafe@v1 cafe@v2 ### Change the impact of a rule -To make a rule give another impact, or to turn it off, add a [`diff` section](../configuration/reference/diff.md) to `redocly.yaml`: +To change the impact of a rule, or to turn it off, add a [`diff` section](../configuration/reference/diff.md) to `redocly.yaml`: ```yaml extends: @@ -156,5 +158,5 @@ diff: ## Resources -- The [diff rules](../rules/diff-rules.md) that judge the changes, and how to configure them. -- [Diff rules in plugins](../custom-plugins/custom-diff-rules.md) for the changes that the built-in rules do not judge. +- [Diff rules](../rules/diff-rules.md) +- [`diff` configuration](../configuration/reference/diff.md) diff --git a/docs/@v2/configuration/reference/diff.md b/docs/@v2/configuration/reference/diff.md index 882d03fb7e..fa0c4589fa 100644 --- a/docs/@v2/configuration/reference/diff.md +++ b/docs/@v2/configuration/reference/diff.md @@ -2,12 +2,11 @@ ## Introduction -The `diff` configuration section sets the impact that each [diff rule](../../rules/diff-rules.md) gives to a change. -The [`diff` command](../../commands/diff.md) uses these impacts to rate the changes between two API descriptions. -Configure the built-in diff rules and the [diff rules from plugins](../../custom-plugins/custom-diff-rules.md) in this section. +The `diff` section sets the impact of each [diff rule](../../rules/diff-rules.md). +The [`diff` command](../../commands/diff.md) uses these impacts. Use the `diff` section only at the root of a configuration file. -An [API-specific section](./apis.md) does not support it. +[API-specific sections](./apis.md) do not support it. {% admonition type="warning" name="Experimental" %} The `diff` section is an experimental feature. @@ -26,13 +25,13 @@ It can change in future releases. - {rule name} - string -- **REQUIRED**. The impact that the rule gives to a change. - Must be one of `major`, `minor`, `patch`, or `off`. - The key is a built-in rule, for example `enum-values-added`, or a rule from a plugin, for example `cafe/tag-removed`. +- **REQUIRED**. The impact of the changes that the rule reports. + Possible values: `major`, `minor`, `patch`, `off`. {% /table %} -To set an impact for one specification version only, use one of these sections with the same options: +To set an impact for one specification version, use one of these sections. +They have the same options. - `oas3_0Diff` - `oas3_1Diff` @@ -41,17 +40,17 @@ To set an impact for one specification version only, use one of these sections w ## Examples -The following example starts from the `diff-recommended` ruleset, lowers the impact of one rule, and turns off another: +This example adds the `diff-recommended` ruleset, sets a lower impact for one rule, and turns off another rule: ```yaml extends: - diff-recommended diff: enum-values-added: minor - schema-format-changed: off + schema-constraint-changed: off ``` -The following example changes the impact of a rule for OpenAPI 3.1 descriptions only: +This example changes the impact of a rule for OpenAPI 3.1 descriptions only: ```yaml extends: @@ -63,9 +62,8 @@ oas3_1Diff: ## Related options - [extends](./extends.md) adds the `diff-recommended` ruleset. -- [plugins](./plugins.md) adds diff rules from custom plugins. ## Resources -- See the list of [diff rules](../../rules/diff-rules.md). -- Read the [`diff` command](../../commands/diff.md) documentation. +- [Diff rules](../../rules/diff-rules.md) +- [`diff` command](../../commands/diff.md) diff --git a/docs/@v2/custom-plugins/custom-diff-rules.md b/docs/@v2/custom-plugins/custom-diff-rules.md deleted file mode 100644 index 86166443ff..0000000000 --- a/docs/@v2/custom-plugins/custom-diff-rules.md +++ /dev/null @@ -1,119 +0,0 @@ -# Diff rules in plugins - -A plugin can add [diff rules](../rules/diff-rules.md) for the changes that the built-in rules do not judge. -The [`diff` command](../commands/diff.md) runs a plugin rule in the same way as a built-in rule. - -{% admonition type="warning" name="Experimental" %} -Diff rules are an experimental feature. -The plugin interface for diff rules can change in future releases. -{% /admonition %} - -## Build the diff rule - -A diff rule is a function that returns a visitor, like a [lint rule](./custom-rules.md). -The difference is what the visitor gets: a lint rule visits nodes of one document, and a diff rule visits changes between two documents. -The command calls the visitor once for each change, with the key of the node type that changed. - -This example rule reports a tag that was removed from an operation. -It is in the `plugins/rules/tag-removed.js` file: - -```js -export default function TagRemoved() { - return { - Operation(change, { report }) { - if (change.kind !== 'modified' || change.property !== 'tags') return; - const tags = change.revision.value ?? []; - const removed = (change.base.value ?? []).filter((tag) => !tags.includes(tag)); - if (removed.length) { - report({ message: `Tags removed: ${removed.join(', ')}.` }); - } - }, - }; -} -``` - -Add the rule to the `diff` section of the plugin, under `oas3` or `async3`. -This is the `plugins/cafe.js` file: - -```js -import TagRemoved from './rules/tag-removed.js'; - -export default function cafePlugin() { - return { - id: 'cafe', - diff: { - oas3: { - 'tag-removed': TagRemoved, - }, - }, - }; -} -``` - -A plugin rule does not run until you give it an impact. -Add the plugin and the impact to `redocly.yaml`: - -```yaml -plugins: - - plugins/cafe.js -extends: - - diff-recommended -diff: - cafe/tag-removed: major -``` - -When you remove a tag from an operation, `redocly diff` reports the change as `major`: - -```text -GET /menu - ✖ major modified tags - Tags removed: public. (cafe/tag-removed) - at revision.yaml:6:13 -``` - -## The change object - -The visitor gets these fields of a change: - -| Field | Description | -| ---------- | ------------------------------------------------------------------------------------------------------------------------- | -| `kind` | `added`, `removed`, or `modified`. | -| `property` | The name of the value that changed on the node. Only a `modified` change has it. | -| `base` | The `value` and `location` of the node, or of the property, in the base. A change of kind `added` does not have it. | -| `revision` | The `value` and `location` of the node, or of the property, in the revision. A change of kind `removed` does not have it. | - -A change of kind `modified` contains one value that changed, such as `tags` or `required`. -A change of kind `added` or `removed` contains the whole node. - -## The context object - -| Property | Description | -| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `report` | The function that reports the change. It takes a `message`, and an optional `location` to point to another place than the change. | -| `directions` | A list of the directions of the data: `request`, `response`, both, or none. See [Requests and responses](../rules/diff-rules.md#requests-and-responses). | -| `specVersion` | The version of the specification, for example `oas3_1`. | - -Use `directions` when the same change breaks only one side of the API. -For example, this rule reports a new `enum` value only in a response: - -```js -export default function EnumValueAdded() { - return { - Schema(change, { report, directions }) { - if (change.kind !== 'modified' || change.property !== 'enum') return; - if (!directions.includes('response')) return; - report({ message: 'A response can send a new enum value.' }); - }, - }; -} -``` - -## Visitor keys - -- A key is the name of a node type, as in a lint rule. - To find the type of a node, use the [`inspect-node-types` command](../commands/inspect-node-types.md). -- A nested key limits the rule to the changes below another node type. - For example, `SchemaProperties: { Schema() {} }` visits the properties of a schema only. -- The `any` key visits every change. - You can use it only at the top level of the visitor. -- A diff visitor does not support `enter`, `leave`, or `skip`. diff --git a/docs/@v2/custom-plugins/index.md b/docs/@v2/custom-plugins/index.md index 84a881956f..2645acc25e 100644 --- a/docs/@v2/custom-plugins/index.md +++ b/docs/@v2/custom-plugins/index.md @@ -21,8 +21,6 @@ Custom plugins can extend the built-in functionality of Redocly in the following - **Write your own custom rules in JavaScript**. If your API standards include elements that can't be expressed by our existing rules, including configurable ones, you can code your own rules. See the [documentation on writing your own rules in custom plugins](./custom-rules.md) for more information. -- **Write your own diff rules in JavaScript**. If the `diff` command must find a breaking change that the built-in [diff rules](../rules/diff-rules.md) do not find, see [diff rules in plugins](./custom-diff-rules.md). - - **Define configuration in a custom plugin**. Redocly supports [resuable ruleset configuration](../guides/configure-rules.md#create-reusable-configuration) already, but defining configuration in a custom plugin is particularly useful when the configuration belongs alongside other plugin elements. See the [documentation on configuration in plugins](./custom-config.md) for more information. - **Extend existing standard definitions with additional type definitions**. Working with extensions to defined standards can be helped by [extending the supported types](./extended-types.md). diff --git a/docs/@v2/rules/diff-rules.md b/docs/@v2/rules/diff-rules.md index fd30bc59aa..e9b4d0642e 100644 --- a/docs/@v2/rules/diff-rules.md +++ b/docs/@v2/rules/diff-rules.md @@ -4,9 +4,9 @@ slug: /docs/cli/rules/diff-rules # Diff rules -The [`diff` command](../commands/diff.md) uses diff rules to judge the changes between two API descriptions. -A rule looks at one kind of change, such as a removed operation. -If the change can break a consumer, the rule reports it with a message, and the change gets the impact that you configure for the rule. +The [`diff` command](../commands/diff.md) uses diff rules to find the changes that can break a client. +Each rule finds one type of change, for example a removed operation. +When a rule finds a change, the rule shows a message, and the change gets the impact that you set for the rule. {% admonition type="warning" name="Experimental" %} Diff rules are an experimental feature. @@ -15,11 +15,11 @@ Their names, messages, and configuration can change in future releases. ## `diff-recommended` ruleset -The `diff-recommended` ruleset turns on every diff rule with the `major` impact. -The `diff` command uses it when you have no `redocly.yaml` file. +The `diff-recommended` ruleset turns on all diff rules with the `major` impact. +The `diff` command uses this ruleset if you do not have a `redocly.yaml` file. -If you have a `redocly.yaml` file, only the rules that it lists run. -To start from the ruleset, extend it: +If you have a `redocly.yaml` file, only the rules from that file run. +To use the ruleset, add it to `extends`: ```yaml extends: @@ -30,14 +30,14 @@ extends: ## Configure diff rules Set the impact of a rule in the [`diff` section](../configuration/reference/diff.md) of `redocly.yaml`. -The value is one of `major`, `minor`, `patch`, or `off`: +The impact is `major`, `minor`, `patch`, or `off`: ```yaml extends: - diff-recommended diff: enum-values-added: minor - schema-format-changed: off + schema-constraint-changed: off ``` To turn off rules for one run, use the `--skip-rule` option: @@ -46,88 +46,96 @@ To turn off rules for one run, use the `--skip-rule` option: redocly diff v1.yaml v2.yaml --skip-rule=enum-values-added ``` -A rule from a [plugin](../custom-plugins/custom-diff-rules.md) has the plugin id in its name, for example `cafe/tag-removed`. +## Requests and responses -## Rules +Many rules check the direction of the data, because a change can break one direction only. +For example, a new `enum` value is safe in a request, but it can break a client that reads a response. -Each rule applies to OpenAPI 3.x and to AsyncAPI 3, except the rules that name one of them. -An AsyncAPI 3 payload is a schema, so the schema rules judge it too. +- OpenAPI: parameters and request bodies are requests, and responses are responses. + In `callbacks` and `webhooks`, the API sends the request, so the directions are opposite. +- AsyncAPI 3: the messages of an operation with `action: receive` are requests, and the messages of an operation with `action: send` are responses. + A `reply` has the opposite direction. +- A schema with `readOnly: true` is response data, and a schema with `writeOnly: true` is request data. +- A component has the directions of all places that use it. + If a request and a response use the same schema, the rules check its changes in both directions. + A component that nothing uses has no direction. -### Requests and responses +## Rules -The same change can break one side of an API and be safe for the other. -For example, a new `enum` value is safe in a request, but it can break a client that reads a response. -Thus, many rules look at the direction of the data: +The rules apply to OpenAPI 3.x and AsyncAPI 3, except the rules that name one of them. -- In OpenAPI, parameters and request bodies are requests, and responses are responses. - Below `callbacks` and `webhooks`, the API sends the request, so the directions are the other way around. -- In AsyncAPI 3, an operation with `action: receive` gets requests, and an operation with `action: send` sends responses. - A `reply` travels in the other direction. -- In OpenAPI, a schema with `readOnly: true` is response data only, and a schema with `writeOnly: true` is request data only. -- A component has the directions of the places that use it. - If a request and a response use the same schema, the rules judge its changes for both. +### Paths, operations, and servers -### Operations, paths, and channels +| Rule | Reports | +| -------------------------- | -------------------------------------------------------------------------------------------- | +| `path-removed` | A removed or renamed path, or a removed webhook, callback, or callback URL. OpenAPI only. | +| `operation-removed` | A removed operation. | +| `operation-id-changed` | A changed or removed `operationId`. Generated clients use it as a method name. OpenAPI only. | +| `operation-action-changed` | A changed `action` of an operation. AsyncAPI only. | +| `server-removed` | A removed server, or a changed `url`, `host`, `pathname`, or `protocol` of a server. | -| Rule | Reports | -| -------------------------- | ---------------------------------------------------------------------------------------- | -| `path-removed` | A removed path. OpenAPI only. | -| `operation-removed` | A removed operation. | -| `operation-action-changed` | An operation that now sends instead of receives, or the other way around. AsyncAPI only. | -| `channel-removed` | A removed channel, or the removal of all channels. AsyncAPI only. | -| `channel-address-changed` | A new `address` of a channel. AsyncAPI only. | -| `server-removed` | A removed server, or the removal of all servers of the document. | +### Channels and messages -In OpenAPI, a path or an operation that loses its own `servers` uses the servers of the document. -Thus, `server-removed` does not report the removal of that `servers` list. +These rules apply to AsyncAPI only. + +| Rule | Reports | +| ------------------------------ | ------------------------------------- | +| `channel-removed` | A removed channel. | +| `channel-address-changed` | A changed `address` of a channel. | +| `message-removed` | A removed message. | +| `message-content-type-changed` | A changed `contentType` of a message. | -### Parameters, requests, and responses +### Parameters, request bodies, and responses These rules apply to OpenAPI only. -| Rule | Reports | -| --------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `parameter-removed` | A removed request parameter, or the removal of all parameters of an operation. | -| `parameter-became-required` | A request parameter that clients must now send: a new required parameter, or one that is now required. | -| `parameter-serialization-changed` | A new `style`, `explode`, `allowReserved`, or `allowEmptyValue` value of a request parameter. | -| `request-body-removed` | A removed request body. | -| `request-body-became-required` | A request body that is now required. | -| `response-removed` | A removed response. | -| `response-header-removed` | A removed response header, or the removal of all headers of a response. | -| `media-type-removed` | A removed media type, for example `application/xml`, or the removal of all media types. | +| Rule | Reports | +| --------------------------------- | ----------------------------------------------------------------------------------- | +| `parameter-removed` | A removed or renamed parameter, or a changed `in` of a parameter. | +| `parameter-became-required` | A new required parameter, or a parameter that became required. | +| `parameter-serialization-changed` | A changed `style`, `explode`, `allowReserved`, or `allowEmptyValue` of a parameter. | +| `request-body-removed` | A removed request body. | +| `request-body-became-required` | A request body that became required. | +| `response-removed` | A removed response, or a changed status code of a response. | +| `response-header-removed` | A removed or renamed response header. | +| `media-type-removed` | A removed or renamed media type. | -### Messages +### Schemas -These rules apply to AsyncAPI only. +| Rule | Reports | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `property-removed` | A removed or renamed property in a response. | +| `required-properties-added` | Properties that became required in a request. | +| `required-properties-removed` | Properties that are no longer required in a response. | +| `enum-values-removed` | Removed `enum` values in a request. | +| `enum-values-added` | New `enum` values in a response. | +| `schema-type-changed` | A changed `type` or `nullable` that accepts fewer types in a request, or more types in a response. | +| `schema-constraint-changed` | A changed constraint that accepts fewer values in a request, or more values in a response. | +| `schema-combinator-changed` | An added or removed subschema of `allOf`, `anyOf`, or `oneOf`, or an added or removed list of them, that accepts fewer values in a request, or more values in a response. OpenAPI only. | -| Rule | Reports | -| ------------------------------ | --------------------------------------------------------------- | -| `message-removed` | A removed message, or the removal of all messages of a channel. | -| `message-content-type-changed` | A new `contentType` of a message. | +`schema-constraint-changed` checks these keywords: -### Schemas +- Limits: `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `minLength`, `maxLength`, `minItems`, `maxItems`, `minProperties`, `maxProperties`. +- Values: `multipleOf`, `pattern`, `format`, `const`, `contentEncoding`, `contentMediaType`. + The rule cannot compare two values of these keywords, so it reports a changed value in both directions. +- Flags: `uniqueItems: true`, and `false` for `additionalProperties`, `items`, `unevaluatedItems`, or `unevaluatedProperties`. +- Subschemas: `not`, `then`, `else`, `contains`, `propertyNames`, `items`, `prefixItems`, `additionalProperties`, `unevaluatedItems`, `unevaluatedProperties`, `dependentSchemas`, `dependentRequired`. + +### Security + +These rules apply to OpenAPI only. + +| Rule | Reports | +| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `security-requirement-changed` | A `security` change that a client with the old credentials cannot pass: new authentication, a new scheme or scope, or a removed requirement. | +| `security-scheme-changed` | A removed security scheme, or a changed `type`, `scheme`, `in`, `name`, `bearerFormat`, or `openIdConnectUrl` of a security scheme. | + +## Changes that the rules do not report + +These changes do not break a client: -| Rule | Reports | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `property-removed` | A removed property in a response, or the removal of all properties of a schema. | -| `required-properties-added` | New `required` properties in a request. | -| `required-properties-removed` | Properties that are no longer `required` in a response. | -| `enum-values-removed` | Removed `enum` values in a request. | -| `enum-values-added` | New `enum` values in a response. | -| `schema-type-changed` | A `type` that accepts less in a request, or more in a response. | -| `numeric-range-changed` | A change to `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, or `multipleOf`. | -| `string-constraint-changed` | A change to `minLength`, `maxLength`, or `pattern`. | -| `schema-format-changed` | A change to `format`. | -| `additional-properties-changed` | A change to `additionalProperties`. | -| `schema-combinator-changed` | A removed `oneOf` or `anyOf` subschema in a request, or a new one in a response. A new `allOf` subschema in a request, or a removed one in a response. OpenAPI only. | - -The `numeric-range-changed`, `string-constraint-changed`, `schema-format-changed`, and `additional-properties-changed` rules report a change that accepts fewer values in a request, or more values in a response. - -### Security and references - -| Rule | Reports | -| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `security-requirement-changed` | A `security` list that clients can no longer pass: authentication where there was none, a new scheme or new scopes in a requirement, or a removed alternative. OpenAPI only. | -| `security-scheme-changed` | A new `type`, `scheme`, `in`, `name`, `bearerFormat`, or `openIdConnectUrl` of a security scheme. OpenAPI only. | -| `security-scheme-removed` | A removed security scheme. OpenAPI only. | -| `ref-target-changed` | A `$ref` that points to another target, which describes other data. | +- A renamed path variable, for example `/menu/{id}` to `/menu/{itemId}`, and the renamed path parameter. +- A renamed webhook. Its key is only a name for the documentation. +- A changed case of a header name. +- A removed `servers` list of a path or an operation. The path or operation then uses the servers of the document. +- A new `operationId` where there was none. diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index 0f134fca28..bfcdfeef93 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -261,8 +261,6 @@ items: - label: Custom rules page: custom-plugins/custom-rules.md - - label: Custom diff rules - page: custom-plugins/custom-diff-rules.md - label: Custom decorators page: custom-plugins/custom-decorators.md - label: Custom config diff --git a/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap b/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap index 4394985af6..4e8e367258 100644 --- a/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap +++ b/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap @@ -26,8 +26,8 @@ exports[`htmlDiff > should render a self-contained page 1`] = `

API diff: base.yaml → revision.yaml

4 major - 1 minor - 1 patch + 0 minor + 2 patch requires a major bump OpenAPI 3.1

@@ -61,7 +61,7 @@ exports[`htmlDiff > should render a self-contained page 1`] = ` major modified - #/paths/~1menu/get/parameters/{query:limit} · required + #/paths/~1menu/get/parameters/0 · required \`limit\` query parameter became required. parameter-became-required
{
@@ -86,7 +86,7 @@ exports[`htmlDiff > should render a self-contained page 1`] = `
         major
         modified
         #/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note · pattern
-        \`pattern\` of \`note\` changed from 'a' to 'a|b'. string-constraint-changed
+        \`pattern\` of \`note\` changed from 'a' to 'a|b'. schema-constraint-changed
       
{
   "base": {
@@ -151,9 +151,9 @@ exports[`htmlDiff > should render a self-contained page 1`] = `
 }
-
+
- minor + patch added #/components @@ -195,8 +195,8 @@ exports[`jsonDiff > should render the result with each change located in its fil }, "summary": { "major": 4, - "minor": 1, - "patch": 1 + "minor": 0, + "patch": 2 }, "bump": "major", "changes": [ @@ -223,7 +223,7 @@ exports[`jsonDiff > should render the result with each change located in its fil } }, { - "key": "#/paths/~1menu/get/parameters/{query:limit}", + "key": "#/paths/~1menu/get/parameters/0", "typeName": "Parameter", "kind": "modified", "impact": "major", @@ -262,7 +262,7 @@ exports[`jsonDiff > should render the result with each change located in its fil "impact": "major", "verdicts": [ { - "ruleId": "string-constraint-changed", + "ruleId": "schema-constraint-changed", "impact": "major", "message": "\`pattern\` of \`note\` changed from 'a' to 'a|b'.", "location": { @@ -354,7 +354,7 @@ exports[`jsonDiff > should render the result with each change located in its fil "key": "#/components", "typeName": "Components", "kind": "added", - "impact": "minor", + "impact": "patch", "verdicts": [], "revision": { "file": "revision.yaml", @@ -377,23 +377,23 @@ exports[`jsonDiff > should render the result with each change located in its fil exports[`markdownDiff > should render one table row per change 1`] = ` "## API diff: base.yaml → revision.yaml -**4** major · **1** minor · **1** patch · requires a **major** bump +**4** major · **0** minor · **2** patch · requires a **major** bump | Impact | Change | Pointer | Location | Details | | --- | --- | --- | --- | --- | | ⚪ patch | modified | \`#/paths/~1menu/get · summary\` | revision.yaml:6:16 | | -| 🔴 major | modified | \`#/paths/~1menu/get/parameters/{query:limit} · required\` | revision.yaml:8:47 | \\\`limit\\\` query parameter became required. \`parameter-became-required\` | -| 🔴 major | modified | \`#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note · pattern\` | revision.yaml:15:68 | \\\`pattern\\\` of \\\`note\\\` changed from 'a' to 'a\\|b'. \`string-constraint-changed\` | +| 🔴 major | modified | \`#/paths/~1menu/get/parameters/0 · required\` | revision.yaml:8:47 | \\\`limit\\\` query parameter became required. \`parameter-became-required\` | +| 🔴 major | modified | \`#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note · pattern\` | revision.yaml:15:68 | \\\`pattern\\\` of \\\`note\\\` changed from 'a' to 'a\\|b'. \`schema-constraint-changed\` | | 🔴 major | removed | \`#/paths/~1orders/delete\` | base.yaml:18:7 | Operation \\\`DELETE /orders\\\` was removed. \`operation-removed\` | | 🔴 major | removed | \`#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta\` | base.yaml:26:42 | Property \\\`eta\\\` was removed. \`property-removed\` | -| 🟢 minor | added | \`#/components\` | revision.yaml:26:3 | |" +| ⚪ patch | added | \`#/components\` | revision.yaml:26:3 | |" `; exports[`nextVersionDiff > should bump the base version as far as the changes require 1`] = `"2.0.0"`; exports[`printGithubActions > should annotate each verdict of a breaking change as an error and skip the rest 1`] = ` "::error title=parameter-became-required,file=revision.yaml,line=8,col=47,endLine=8,endColumn=51::\`limit\` query parameter became required. -::error title=string-constraint-changed,file=revision.yaml,line=15,col=68,endLine=15,endColumn=73::\`pattern\` of \`note\` changed from 'a' to 'a|b'. +::error title=schema-constraint-changed,file=revision.yaml,line=15,col=68,endLine=15,endColumn=73::\`pattern\` of \`note\` changed from 'a' to 'a|b'. ::error title=operation-removed,file=base.yaml,line=18,col=7,endLine=19,endColumn=55::Operation \`DELETE /orders\` was removed. ::error title=property-removed,file=base.yaml,line=26,col=42,endLine=26,endColumn=58::Property \`eta\` was removed. " @@ -407,12 +407,12 @@ exports[`stylishDiff > should group AsyncAPI changes per channel, operation and channel orderPlaced ✖ major modified address - Channel \`orderPlaced\` address changed from 'orders/placed' to 'orders/new'. (channel-address-changed) + \`address\` of channel \`orderPlaced\` changed from 'orders/placed' to 'orders/new'. (channel-address-changed) at revision.yaml:6:27 operation sendOrderReady ✖ major modified action - Operation \`sendOrderReady\` action changed from 'send' to 'receive'. (operation-action-changed) + \`action\` of operation \`sendOrderReady\` changed from 'send' to 'receive'. (operation-action-changed) at revision.yaml:8:29 3 major, 0 minor, 0 patch." @@ -420,7 +420,7 @@ operation sendOrderReady exports[`stylishDiff > should group the changes per endpoint and webhook, worst first, with their verdicts and location 1`] = ` "GET /menu - ✖ major modified parameters/{query:limit} · required + ✖ major modified parameters/0 · required \`limit\` query parameter became required. (parameter-became-required) at revision.yaml:8:47 · patch modified summary @@ -428,7 +428,7 @@ exports[`stylishDiff > should group the changes per endpoint and webhook, worst POST /orders ✖ major modified requestBody/content/application/json/schema/properties/note · pattern - \`pattern\` of \`note\` changed from 'a' to 'a|b'. (string-constraint-changed) + \`pattern\` of \`note\` changed from 'a' to 'a|b'. (schema-constraint-changed) at revision.yaml:15:68 DELETE /orders @@ -442,8 +442,8 @@ POST orderReady (webhook) at base.yaml:26:42 components - ✔ minor added + · patch added at revision.yaml:26:3 -4 major, 1 minor, 1 patch." +4 major, 0 minor, 2 patch." `; diff --git a/packages/cli/src/commands/diff/format/__tests__/format.test.ts b/packages/cli/src/commands/diff/format/__tests__/format.test.ts index 13cc7aa360..f7e066d113 100644 --- a/packages/cli/src/commands/diff/format/__tests__/format.test.ts +++ b/packages/cli/src/commands/diff/format/__tests__/format.test.ts @@ -2,6 +2,7 @@ import { createConfig, diffDocuments, makeDocumentFromString } from '@redocly/op import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { stripVTControlCharacters } from 'node:util'; +import { outdent } from 'outdent'; import { printGithubActions } from '../github-actions.js'; import { htmlDiff } from '../html.js'; @@ -21,13 +22,13 @@ const config = await createConfig({ extends: ['diff-recommended'] }); // Every shape a report has to survive: a removal found in the base document, changes found in // the revision, an addition, a change no rule judges, a webhook — and content that fights the // output format, so the escaping shows up in the snapshots. -const cafe = diffDocuments({ +const cafe = await diffDocuments({ base: documentOf('cafe', 'base'), revision: documentOf('cafe', 'revision'), config, }); -const kitchen = diffDocuments({ +const kitchen = await diffDocuments({ base: documentOf('kitchen', 'base'), revision: documentOf('kitchen', 'revision'), config, @@ -42,6 +43,41 @@ describe('stylishDiff', () => { it('should group AsyncAPI changes per channel, operation and server', () => { expect(stripVTControlCharacters(stylishDiff(kitchen))).toMatchSnapshot(); }); + + it('should head the changes of a path by its path when its $ref points to another path item', async () => { + const menu = (pathItem: string, side: 'base' | 'revision') => + makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: { $ref: '#/components/pathItems/${pathItem}' } + components: + pathItems: + Menu: + get: { responses: { '200': { description: OK } } } + delete: { responses: { '204': { description: Removed } } } + Dishes: + get: { responses: { '200': { description: OK } } } + `, + `${side}.yaml` + ); + + const result = await diffDocuments({ + base: menu('Menu', 'base'), + revision: menu('Dishes', 'revision'), + config, + }); + + expect(stripVTControlCharacters(stylishDiff(result))).toMatchInlineSnapshot(` + "DELETE /menu + ✖ major removed + Operation \`DELETE /menu\` was removed. (operation-removed) + at base.yaml:9:15 + + 1 major, 0 minor, 0 patch." + `); + }); }); describe('markdownDiff', () => { diff --git a/packages/cli/src/commands/diff/format/json.ts b/packages/cli/src/commands/diff/format/json.ts index 2114b433b0..a2a243890b 100644 --- a/packages/cli/src/commands/diff/format/json.ts +++ b/packages/cli/src/commands/diff/format/json.ts @@ -35,11 +35,15 @@ export function toJsonChange(change: JudgedChange) { }; } +// The problems met while preparing the documents are printed apart from the report. export function jsonDiff(result: DiffResult): string { const report = { reportVersion: '1', - ...result, files: { base: formatPath(result.files.base), revision: formatPath(result.files.revision) }, + specVersions: result.specVersions, + infoVersions: result.infoVersions, + summary: result.summary, + bump: result.bump, changes: result.changes.map(toJsonChange), }; return JSON.stringify(report, null, 2); diff --git a/packages/cli/src/commands/diff/format/location.ts b/packages/cli/src/commands/diff/format/location.ts index b4cae27f1a..a4dd01c2cf 100644 --- a/packages/cli/src/commands/diff/format/location.ts +++ b/packages/cli/src/commands/diff/format/location.ts @@ -2,7 +2,7 @@ import { getLineColLocation, type Location } from '@redocly/openapi-core'; import { formatPath } from '../../../utils/miscellaneous.js'; -// Nodes inlined by bundling do not exist in the root source AST; +// A node that a preprocessor or decorator added is not in the text of its file; // getLineColLocation falls back to 1:1 for such pointers. export function lineColOf(location: Location): { file: string; line: number; col: number } { const { start } = getLineColLocation(location); diff --git a/packages/cli/src/commands/diff/format/stylish.ts b/packages/cli/src/commands/diff/format/stylish.ts index 0d63752dbe..777d8fa437 100644 --- a/packages/cli/src/commands/diff/format/stylish.ts +++ b/packages/cli/src/commands/diff/format/stylish.ts @@ -2,6 +2,7 @@ import { displaySide, impactRank, impacts, + nameOf, parsePointer, typeOf, type Change, @@ -32,10 +33,6 @@ function segmentsOf(pointer: string): string[] { return segments; } -function nameOf(node: DiffNode): string { - return String((node.revision ?? node.base)!.key); -} - // The items that get a heading of their own, by the type of the map that holds them. const ITEM_HEADINGS: Partial string>> = { Paths: (path) => path, diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts index 3a6ef1db75..0485ff1815 100644 --- a/packages/cli/src/commands/diff/index.ts +++ b/packages/cli/src/commands/diff/index.ts @@ -1,6 +1,6 @@ import { AbortFlowError, - bundle, + BaseResolver, diffDocuments, HandledError, formatProblems, @@ -33,17 +33,23 @@ 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 [base, revision] = await Promise.all([ - bundle({ config, ref: basePath }), - bundle({ config, ref: revisionPath }), - ]); + // One resolver loads both documents and resolves their `$ref`s, as lint does. + const externalRefResolver = new BaseResolver(config.resolve); + const load = async (documentPath: string) => { + const document = await externalRefResolver.resolveDocument(null, documentPath, true); + if (document instanceof Error) throw document; + return document; + }; + const [base, revision] = await Promise.all([load(basePath), load(revisionPath)]); - const bundleProblems = [...base.problems, ...revision.problems]; + collectSpecData?.(revision); - if (bundleProblems.length) { - formatProblems(bundleProblems, { + const result = await diffDocuments({ base, revision, config, externalRefResolver }); + + if (result.problems.length) { + formatProblems(result.problems, { format: 'codeframe', - totals: getTotals(bundleProblems), + totals: getTotals(result.problems), command: 'bundle', }); logger.warn( @@ -51,10 +57,6 @@ export async function handleDiff({ argv, config, collectSpecData }: CommandArgs< ); } - collectSpecData?.(revision.bundle); - - const result = diffDocuments({ base: base.bundle, revision: revision.bundle, config }); - result.changes.sort(byKeyAndProperty); if (argv.format === 'github-actions') { diff --git a/packages/core/__tests__/utils.ts b/packages/core/__tests__/utils.ts index c20752f1c4..9310000f31 100644 --- a/packages/core/__tests__/utils.ts +++ b/packages/core/__tests__/utils.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import { + BaseResolver, type Change, type Document, type JudgedChange, @@ -17,6 +18,17 @@ export function parseYamlToDocument(body: string, absoluteRef: string = ''): Doc }; } +// A resolver that finds these files, by paths relative to the working directory, in its cache, so +// a test that needs several files writes none to disk. +export function resolverWithFiles(files: Record): BaseResolver { + const resolver = new BaseResolver(); + for (const [file, body] of Object.entries(files)) { + const absoluteRef = path.resolve(file); + resolver.cache.set(absoluteRef, Promise.resolve(parseYamlToDocument(body, absoluteRef))); + } + return resolver; +} + export function replaceSourceWithRefInChanges(changes: Array) { return changes.map(({ node: _node, ...change }) => { const mapped: Record = { ...change }; diff --git a/packages/core/src/config/diff-recommended.ts b/packages/core/src/config/diff-recommended.ts index 7445010a0f..8be9af7057 100644 --- a/packages/core/src/config/diff-recommended.ts +++ b/packages/core/src/config/diff-recommended.ts @@ -3,7 +3,6 @@ import type { Impact } from '../diff/types.js'; import { type RawGovernanceConfig } from './types.js'; export const defaultDiffRules: Record = { - 'additional-properties-changed': 'major', 'channel-address-changed': 'major', 'channel-removed': 'major', 'enum-values-added': 'major', @@ -11,15 +10,14 @@ export const defaultDiffRules: Record = { 'media-type-removed': 'major', 'message-content-type-changed': 'major', 'message-removed': 'major', - 'numeric-range-changed': 'major', 'operation-action-changed': 'major', + 'operation-id-changed': 'major', 'operation-removed': 'major', 'parameter-became-required': 'major', 'parameter-removed': 'major', 'parameter-serialization-changed': 'major', 'path-removed': 'major', 'property-removed': 'major', - 'ref-target-changed': 'major', 'request-body-became-required': 'major', 'request-body-removed': 'major', 'required-properties-added': 'major', @@ -27,13 +25,11 @@ export const defaultDiffRules: Record = { 'response-header-removed': 'major', 'response-removed': 'major', 'schema-combinator-changed': 'major', - 'schema-format-changed': 'major', + 'schema-constraint-changed': 'major', 'schema-type-changed': 'major', 'security-requirement-changed': 'major', 'security-scheme-changed': 'major', - 'security-scheme-removed': 'major', 'server-removed': 'major', - 'string-constraint-changed': 'major', }; const diffRecommended: RawGovernanceConfig<'built-in'> = { diff --git a/packages/core/src/config/rules.ts b/packages/core/src/config/rules.ts index 85ae65212b..ca87333f93 100644 --- a/packages/core/src/config/rules.ts +++ b/packages/core/src/config/rules.ts @@ -1,3 +1,4 @@ +import type { DiffRule, DiffVisitor, Impact } from '../diff/types.js'; import type { Arazzo1RuleSet, Async2RuleSet, @@ -73,3 +74,27 @@ export function initRules( .flatMap((visitor) => visitor) .filter(isDefined); } + +export type InitializedDiffRule = { + impact: Impact; + ruleId: string; + visitor: DiffVisitor; +}; + +export function initDiffRules( + rules: Record[], + config: Config, + specVersion: SpecVersion +): InitializedDiffRule[] { + return rules.flatMap((ruleset) => + Object.keys(ruleset).flatMap((ruleId) => { + const impact = config.getDiffImpact(ruleId, specVersion); + + if (impact === 'off') { + return []; + } + + return [{ impact, ruleId, visitor: ruleset[ruleId]() }]; + }) + ); +} diff --git a/packages/core/src/diff/__tests__/changes.test.ts b/packages/core/src/diff/__tests__/changes.test.ts index d94271c5ef..8fab0d2a69 100644 --- a/packages/core/src/diff/__tests__/changes.test.ts +++ b/packages/core/src/diff/__tests__/changes.test.ts @@ -1,8 +1,7 @@ import { outdent } from 'outdent'; import { replaceSourceWithRefInChanges } from '../../../__tests__/utils.js'; -import { collectChanges } from '../changes.js'; -import { buildDiffTree } from '../diff-tree.js'; +import { compareTrees } from '../diff-tree.js'; import { nodeTreeOf, treeOf } from './utils.js'; const cafe = (menuItem: string) => outdent` @@ -14,7 +13,7 @@ const cafe = (menuItem: string) => outdent` MenuItem: ${menuItem} `; -describe('collectChanges', () => { +describe('changesOf', () => { it('should report every property that differs, at its escaped pointer on each side', async () => { const base = await nodeTreeOf( cafe("{ type: integer, x-price/unit: cents, description: 'A drink' }") @@ -23,7 +22,7 @@ describe('collectChanges', () => { cafe("{ type: number, x-price/unit: euros, description: 'A drink' }") ); - const changes = collectChanges(buildDiffTree(base.root, revision.root, {}).root); + const changes = compareTrees(base, revision); expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(` [ @@ -61,7 +60,7 @@ describe('collectChanges', () => { const base = await nodeTreeOf(cafe('{ type: object }')); const revision = await nodeTreeOf(cafe('{ type: object, minProperties: 1 }')); - const changes = collectChanges(buildDiffTree(base.root, revision.root, {}).root); + const changes = compareTrees(base, revision); expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(` [ @@ -86,7 +85,7 @@ describe('collectChanges', () => { const base = await nodeTreeOf(cafe('{ properties: { price: { type: number, minimum: 0 } } }')); const revision = await nodeTreeOf(cafe('{ properties: {} }')); - const changes = collectChanges(buildDiffTree(base.root, revision.root, {}).root); + const changes = compareTrees(base, revision); expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(` [ @@ -120,7 +119,7 @@ describe('collectChanges', () => { #/components/examples/latte Schema `); - const changes = collectChanges(buildDiffTree(base.get('#/')!, revision.get('#/')!, {}).root); + const changes = compareTrees(base.get('#/')!, revision.get('#/')!); expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(` [ @@ -144,11 +143,11 @@ describe('collectChanges', () => { `); }); - it('should compare a $ref by where it points', async () => { + it('should compare a $ref that does not resolve by where it points', async () => { const base = await nodeTreeOf(cafe("{ $ref: '#/components/schemas/Beverage' }")); const revision = await nodeTreeOf(cafe("{ $ref: '#/components/schemas/Dessert' }")); - const changes = collectChanges(buildDiffTree(base.root, revision.root, {}).root); + const changes = compareTrees(base, revision); expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(` [ @@ -169,10 +168,37 @@ describe('collectChanges', () => { `); }); + it('should compare an empty array that is not a node of its own as a value', async () => { + const orders = (security: string) => + nodeTreeOf( + `openapi: 3.1.0\ninfo: { title: Cafe, version: 1.0.0 }\npaths: {}\nsecurity: ${security}` + ); + + const changes = compareTrees( + await orders('[{ ApiKey: [] }]'), + await orders('[{ ApiKey: [], OAuth: [] }]') + ); + + expect(changes).toMatchObject([ + { key: '#/security/0', kind: 'modified', property: 'OAuth', revision: { value: [] } }, + ]); + }); + + it('should report a list that the walker records as a node once, not as a value too', async () => { + const orders = (security: string) => + nodeTreeOf( + `openapi: 3.1.0\ninfo: { title: Cafe, version: 1.0.0 }\npaths: {}\nsecurity: ${security}` + ); + + const changes = compareTrees(await orders('[]'), await orders('[{ ApiKey: [] }]')); + + expect(changes).toMatchObject([{ key: '#/security/0', kind: 'added' }]); + }); + it('should report nothing when the documents are the same', async () => { - const { root } = await nodeTreeOf(cafe('{ type: string, enum: [coffee, tea] }')); + const root = await nodeTreeOf(cafe('{ type: string, enum: [coffee, tea] }')); - const changes = collectChanges(buildDiffTree(root, root, {}).root); + const changes = compareTrees(root, root); expect(changes).toEqual([]); }); diff --git a/packages/core/src/diff/__tests__/diff-tree.test.ts b/packages/core/src/diff/__tests__/diff-tree.test.ts index 80002851af..c918e1e613 100644 --- a/packages/core/src/diff/__tests__/diff-tree.test.ts +++ b/packages/core/src/diff/__tests__/diff-tree.test.ts @@ -1,7 +1,9 @@ +import * as path from 'node:path'; import { outdent } from 'outdent'; -import { buildDiffTree } from '../diff-tree.js'; -import { oas3Identities } from '../specs/oas3.js'; +import { replaceSourceWithRefInChanges, resolverWithFiles } from '../../../__tests__/utils.js'; +import { pointerOf } from '../diff-node.js'; +import { compareTrees } from '../diff-tree.js'; import { nodeTreeOf } from './utils.js'; const cafe = (body: string) => outdent` @@ -10,142 +12,362 @@ const cafe = (body: string) => outdent` ${body} `; -describe('buildDiffTree', () => { - it('should label a path by its shape and a path parameter by its place in the template', async () => { - const { root } = await nodeTreeOf( - cafe(outdent` - paths: - /orders/{orderId}/items/{itemId}: - get: - parameters: - - { name: itemId, in: path, required: true } - - { name: orderId, in: path, required: true } - - { name: fields, in: query } - responses: {} - `) +describe('compareTrees', () => { + const menu = (parameters: string) => + cafe(outdent` + paths: + /menu: + get: + parameters: ${parameters} + responses: {} + `); + const compare = async (base: string, revision: string) => + compareTrees(await nodeTreeOf(base), await nodeTreeOf(revision)); + + it('should compare each child with the child it pairs with', async () => { + const changes = await compare( + menu('[{ name: limit, in: query }, { name: offset, in: query }]'), + menu('[{ name: offset, in: query }, { name: limit, in: query, description: How many }]') ); - const { diffNodeOf } = buildDiffTree(root, root, oas3Identities); - const labels = [...new Set(diffNodeOf.values())].map((node) => node.label); - const parameterLabels = labels.filter((label) => label.includes('/parameters/')); + expect(changes).toMatchObject([ + { key: '#/paths/~1menu/get/parameters/1', kind: 'modified', property: 'description' }, + ]); + }); - expect(parameterLabels).toMatchInlineSnapshot(` - [ - "#/paths/~1orders~1{0}~1items~1{1}/get/parameters/{path:1}", - "#/paths/~1orders~1{0}~1items~1{1}/get/parameters/{path:0}", - "#/paths/~1orders~1{0}~1items~1{1}/get/parameters/{query:fields}", - ] - `); + it('should pair a map entry renamed to a similar key and report the key as changed', async () => { + const changes = await compare( + cafe('paths: { /order: { get: { responses: {} } } }'), + cafe('paths: { /orders: { get: { responses: {} } } }') + ); + + expect(changes).toMatchObject([ + { + key: '#/paths/~1orders', + kind: 'modified', + property: 'key', + base: { value: '/order' }, + revision: { value: '/orders' }, + }, + ]); }); - it('should keep a callback key as it is and label a path parameter there by its name', async () => { - const { root } = await nodeTreeOf( - cafe(outdent` - paths: - /orders: - post: - responses: {} - callbacks: - orderReady: - '{$request.body#/callbackUrl}': - post: - parameters: - - { name: orderId, in: path, required: true } - responses: {} - `) + it('should tell fixed fields from map entries by the node types', async () => { + const changes = await compare( + cafe( + 'paths: {}\ncomponents: { schemas: { Order: { unevaluatedProperties: { type: string } } } }' + ), + cafe('paths: {}\ncomponents: { schemas: { Order: { unevaluatedItems: { type: string } } } }') ); - const { diffNodeOf } = buildDiffTree(root, root, oas3Identities); - const labels = [...new Set(diffNodeOf.values())].map((node) => node.label); - const parameterLabels = labels.filter((label) => label.includes('/parameters/')); + expect(changes).toMatchObject([ + { key: '#/components/schemas/Order/unevaluatedProperties', kind: 'removed' }, + { key: '#/components/schemas/Order/unevaluatedItems', kind: 'added' }, + ]); + }); +}); - expect(parameterLabels).toMatchInlineSnapshot(` - [ - "#/paths/~1orders/post/callbacks/orderReady/{$request.body#~1callbackUrl}/post/parameters/{path:orderId}", - ] +describe('compareTrees through $refs', () => { + const menu = (schema: string, schemas: string) => + cafe(outdent` + paths: + /menu: + get: + responses: + '200': + description: OK + content: + application/json: + schema: ${schema} + components: + schemas: ${schemas} `); - }); + const item = (nameType: string) => + `{ type: object, properties: { name: { type: ${nameType} } } }`; + const itemRef = "{ $ref: '#/components/schemas/Item' }"; + const menuItemRef = "{ $ref: '#/components/schemas/MenuItem' }"; - it('should label servers by url, tags by name and security requirements by scheme names', async () => { - const { root } = await nodeTreeOf( - cafe(outdent` - servers: - - url: https://api.cafe.example - tags: - - name: Orders - security: - - OAuth: [orders:read] - ApiKey: [] - paths: {} - `) - ); - const { diffNodeOf } = buildDiffTree(root, root, oas3Identities); - const labels = [...new Set(diffNodeOf.values())].map((node) => node.label); + // Each side has a folder of its own, so both roots reference their files by the same paths. + async function nodeTreesOfFiles(sides: Record<'base' | 'revision', Record>) { + const load = (side: 'base' | 'revision') => { + const files = Object.entries(sides[side]).map(([file, body]) => [ + path.join(side, file), + body, + ]); + return nodeTreeOf( + sides[side]['openapi.yaml'], + path.resolve(side, 'openapi.yaml'), + resolverWithFiles(Object.fromEntries(files)) + ); + }; + return { base: await load('base'), revision: await load('revision') }; + } - const identifiedLabels = labels.filter((label) => /\/(servers|tags|security)\//.test(label)); + it('should compare $refs to different targets by what the targets describe', async () => { + const schemas = `{ Item: ${item('string')}, MenuItem: ${item('integer')} }`; + const base = await nodeTreeOf(menu(itemRef, schemas)); + const revision = await nodeTreeOf(menu(menuItemRef, schemas)); - expect(identifiedLabels).toMatchInlineSnapshot(` + expect(replaceSourceWithRefInChanges(compareTrees(base, revision))).toMatchInlineSnapshot(` [ - "#/servers/{https:~1~1api.cafe.example}", - "#/security/{ApiKey+OAuth}", - "#/tags/{Orders}", + { + "base": { + "location": "api.yaml#/components/schemas/Item/properties/name/type", + "value": "string", + }, + "key": "#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/name", + "kind": "modified", + "property": "type", + "revision": { + "location": "api.yaml#/components/schemas/MenuItem/properties/name/type", + "value": "integer", + }, + }, ] `); }); - it('should match a referenced list item by what it points at, wherever it is listed', async () => { - const menu = (parameters: string) => + it('should find no change where an inline schema moved into a component that reads the same', async () => { + const base = await nodeTreeOf(menu(item('string'), '{}')); + const revision = await nodeTreeOf(menu(itemRef, `{ Item: ${item('string')} }`)); + + expect(compareTrees(base, revision)).toMatchObject([ + { key: '#/components/schemas/Item', kind: 'added' }, + ]); + }); + + it('should compare a component that both documents reference under the place that references it', async () => { + const base = await nodeTreeOf(menu(itemRef, `{ Item: ${item('string')} }`)); + const revision = await nodeTreeOf(menu(itemRef, `{ Item: ${item('integer')} }`)); + + expect(compareTrees(base, revision).map((change) => change.key)).toEqual([ + '#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/name', + ]); + }); + + it('should compare a pair of targets once, under the first place that references both', async () => { + const orders = (ref: string) => cafe(outdent` paths: /menu: get: - parameters: ${parameters} + responses: + '200': { description: OK, content: { application/json: { schema: ${ref} } } } + post: + requestBody: { content: { application/json: { schema: ${ref} } } } responses: {} components: - schemas: - Sort: { name: sort, in: query } - Filter: { name: filter, in: query } + schemas: { Item: ${item('string')}, MenuItem: ${item('integer')} } `); + const base = await nodeTreeOf(orders(itemRef)); + const revision = await nodeTreeOf(orders(menuItemRef)); - const base = await nodeTreeOf( - menu("[{ $ref: '#/components/schemas/Sort' }, { $ref: '#/components/schemas/Filter' }]") - ); - const revision = await nodeTreeOf( - menu("[{ $ref: '#/components/schemas/Filter' }, { $ref: '#/components/schemas/Sort' }]") - ); - const { diffNodeOf } = buildDiffTree(base.root, revision.root, oas3Identities); - const nodes = new Map([...diffNodeOf.values()].map((node) => [node.label, node])); - const sort = nodes.get('#/paths/~1menu/get/parameters/{query:sort}')!; - const filter = nodes.get('#/paths/~1menu/get/parameters/{query:filter}')!; - - expect(sort.base?.key).toBe(0); - expect(sort.revision?.key).toBe(1); - expect(filter.base?.key).toBe(1); - expect(filter.revision?.key).toBe(0); + expect(compareTrees(base, revision).map((change) => change.key)).toEqual([ + '#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/name', + ]); }); - it('should match items that share an identity in document order and number the extra one', async () => { - const menu = (parameters: string) => + it('should note the other places that reach a pair it has compared', async () => { + const twoUses = (schemas: string) => cafe(outdent` paths: /menu: get: - parameters: ${parameters} + responses: + '200': { description: OK, content: { application/json: { schema: ${itemRef} } } } + post: + requestBody: { content: { application/json: { schema: ${itemRef} } } } responses: {} + components: + schemas: ${schemas} `); + const base = await nodeTreeOf(twoUses(`{ Item: ${item('string')} }`)); + const revision = await nodeTreeOf(twoUses(`{ Item: ${item('integer')} }`)); + + const [nameChanged] = compareTrees(base, revision); + const itemPair = nameChanged.node.parent!.parent!; - const base = await nodeTreeOf( - menu('[{ name: search, in: query }, { name: search, in: query, required: true }]') + expect(pointerOf(itemPair)).toBe( + '#/paths/~1menu/get/responses/200/content/application~1json/schema' ); - const revision = await nodeTreeOf(menu('[{ name: search, in: query }]')); - const { diffNodeOf } = buildDiffTree(base.root, revision.root, oas3Identities); - const nodes = new Map([...diffNodeOf.values()].map((node) => [node.label, node])); - const first = nodes.get('#/paths/~1menu/get/parameters/{query:search}')!; - const second = nodes.get('#/paths/~1menu/get/parameters/{query:search}#2')!; - - expect(first.base?.key).toBe(0); - expect(first.revision?.key).toBe(0); - expect(second.base?.key).toBe(1); - expect(second.revision).toBeUndefined(); + expect(itemPair.referencedBy.map(pointerOf)).toEqual([ + '#/paths/~1menu/post/requestBody/content/application~1json/schema', + '#/components/schemas/Item', + ]); + }); + + it('should end the comparison of components that reference themselves', async () => { + const related = (name: string, description: string) => + `{ description: ${description}, properties: { related: { $ref: '#/components/schemas/${name}' } } }`; + const schemas = `{ Item: ${related('Item', 'An item')}, MenuItem: ${related('MenuItem', 'A dish')} }`; + const base = await nodeTreeOf(menu(itemRef, schemas)); + const revision = await nodeTreeOf(menu(menuItemRef, schemas)); + + expect(compareTrees(base, revision)).toMatchObject([ + { + key: '#/paths/~1menu/get/responses/200/content/application~1json/schema', + kind: 'modified', + property: 'description', + }, + ]); + }); + + it('should locate a change in another file in the file of each side', async () => { + const files = (nameType: string) => ({ + 'openapi.yaml': menu('{ $ref: schemas/item.yaml }', '{}'), + 'schemas/item.yaml': item(nameType), + }); + const { base, revision } = await nodeTreesOfFiles({ + base: files('string'), + revision: files('integer'), + }); + + expect(compareTrees(base, revision)).toMatchObject([ + { + key: '#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/name', + kind: 'modified', + property: 'type', + base: { + location: { + source: { absoluteRef: path.resolve('base/schemas/item.yaml') }, + pointer: '#/properties/name/type', + }, + }, + revision: { + location: { + source: { absoluteRef: path.resolve('revision/schemas/item.yaml') }, + pointer: '#/properties/name/type', + }, + }, + }, + ]); + }); + + it('should match $refs to other files in a list by their files, in any order', async () => { + const files = (oneOf: string) => ({ + 'openapi.yaml': menu(`{ oneOf: ${oneOf} }`, '{}'), + 'coffee.yaml': '{ type: object, properties: { roast: { type: string } } }', + 'tea.yaml': '{ type: object, properties: { leaf: { type: integer } } }', + }); + const { base, revision } = await nodeTreesOfFiles({ + base: files('[{ $ref: coffee.yaml }, { $ref: tea.yaml }]'), + revision: files('[{ $ref: tea.yaml }, { $ref: coffee.yaml }]'), + }); + + expect(compareTrees(base, revision)).toEqual([]); + }); + + it('should compare the keys written next to a $ref at its place and its targets under $ref', async () => { + const order = (ref: string, extraType: string) => + menu( + `{ ${ref}, properties: { extra: { type: ${extraType} } } }`, + `{ Item: ${item('string')}, MenuItem: ${item('integer')} }` + ); + const base = await nodeTreeOf(order("$ref: '#/components/schemas/Item'", 'string')); + const revision = await nodeTreeOf(order("$ref: '#/components/schemas/MenuItem'", 'integer')); + + expect(compareTrees(base, revision).map((change) => change.key)).toEqual([ + '#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/extra', + '#/paths/~1menu/get/responses/200/content/application~1json/schema/$ref/properties/name', + ]); + }); + + it('should keep a $ref on its pair and locate its changes in the target', async () => { + const base = await nodeTreeOf(menu(itemRef, `{ Item: ${item('string')} }`)); + const revision = await nodeTreeOf(menu(itemRef, `{ Item: ${item('integer')} }`)); + + const [nameChanged] = compareTrees(base, revision); + const schemaPair = nameChanged.node.parent!.parent!; + + expect(schemaPair.revision).toMatchObject({ + value: { $ref: '#/components/schemas/Item' }, + resolved: { key: 'Item' }, + }); + expect(nameChanged).toMatchObject({ + key: '#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/name', + revision: { location: { pointer: '#/components/schemas/Item/properties/name/type' } }, + }); + }); + + it('should compare a description added next to a $ref at the place, not the target against it', async () => { + const schemas = `{ Item: ${item('string')} }`; + const base = await nodeTreeOf(menu(itemRef, schemas)); + const revision = await nodeTreeOf( + menu("{ $ref: '#/components/schemas/Item', description: An item }", schemas) + ); + + expect(compareTrees(base, revision)).toMatchObject([ + { + key: '#/paths/~1menu/get/responses/200/content/application~1json/schema', + kind: 'modified', + property: 'description', + base: { value: undefined }, + revision: { value: 'An item' }, + }, + ]); + }); + + it('should follow the target of a component written as a $ref with keys next to it, where a path first reaches it', async () => { + const files = (nameType: string) => ({ + 'openapi.yaml': menu( + "{ $ref: '#/components/schemas/A' }", + '{ A: { $ref: ./b.yaml, description: An item } }' + ), + 'b.yaml': item(nameType), + }); + const { base, revision } = await nodeTreesOfFiles({ + base: files('string'), + revision: files('integer'), + }); + + expect(compareTrees(base, revision)).toMatchObject([ + { + key: '#/paths/~1menu/get/responses/200/content/application~1json/schema/$ref/properties/name', + kind: 'modified', + property: 'type', + }, + ]); + }); + + it('should compare the one target of $refs with keys next to them under $ref of the place', async () => { + const schema = "{ $ref: '#/components/schemas/Item', description: A dish }"; + const base = await nodeTreeOf(menu(schema, `{ Item: ${item('string')} }`)); + const revision = await nodeTreeOf(menu(schema, `{ Item: ${item('integer')} }`)); + + expect(compareTrees(base, revision).map((change) => change.key)).toEqual([ + '#/paths/~1menu/get/responses/200/content/application~1json/schema/$ref/properties/name', + ]); + }); + + it('should report a renamed path by the keys of the places, not of their targets', async () => { + const menuItem = (template: string, pathItem: string) => + cafe(outdent` + paths: + /menu/{${template}}: { $ref: '#/components/pathItems/${pathItem}' } + components: + pathItems: + Item: { get: { responses: {} } } + MenuItem: { get: { responses: {} } } + `); + const base = await nodeTreeOf(menuItem('id', 'Item')); + const revision = await nodeTreeOf(menuItem('itemId', 'MenuItem')); + + expect(replaceSourceWithRefInChanges(compareTrees(base, revision))).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "api.yaml#/paths/~1menu~1{id}", + "value": "/menu/{id}", + }, + "key": "#/paths/~1menu~1{itemId}", + "kind": "modified", + "property": "key", + "revision": { + "location": "api.yaml#/paths/~1menu~1{itemId}", + "value": "/menu/{itemId}", + }, + }, + ] + `); }); }); diff --git a/packages/core/src/diff/__tests__/direction.test.ts b/packages/core/src/diff/__tests__/direction.test.ts index 3e34036abd..bbcd51d9c0 100644 --- a/packages/core/src/diff/__tests__/direction.test.ts +++ b/packages/core/src/diff/__tests__/direction.test.ts @@ -1,10 +1,9 @@ -import { async3Directions } from '../specs/async3.js'; -import { oas3Directions } from '../specs/oas3.js'; -import { directionsOfTree, treeOf } from './utils.js'; +import { directionsOf } from '../direction.js'; +import { diffTreeOf, withReferences } from './utils.js'; -describe('resolveDirections for OpenAPI 3', () => { +describe('directionsOf for OpenAPI 3', () => { it('should read the direction off the place a node sits in', () => { - const cafe = treeOf(` + const cafe = diffTreeOf(` #/ Root #/info Info #/paths Paths @@ -17,21 +16,15 @@ describe('resolveDirections for OpenAPI 3', () => { #/paths/~1orders/post Operation #/paths/~1orders/post/requestBody RequestBody `); - const statusFilter = cafe.get('#/paths/~1orders/get/parameters/0')!; - const orderList = cafe.get('#/paths/~1orders/get/responses/200')!; - const newOrder = cafe.get('#/paths/~1orders/post/requestBody')!; - const info = cafe.get('#/info')!; - const directionOf = directionsOfTree(cafe, [], oas3Directions); - - expect(directionOf(statusFilter)).toEqual(['request']); - expect(directionOf(orderList)).toEqual(['response']); - expect(directionOf(newOrder)).toEqual(['request']); - expect(directionOf(info)).toEqual([]); + expect(directionsOf(cafe.get('#/paths/~1orders/get/parameters/0')!)).toEqual(['request']); + expect(directionsOf(cafe.get('#/paths/~1orders/get/responses/200')!)).toEqual(['response']); + expect(directionsOf(cafe.get('#/paths/~1orders/post/requestBody')!)).toEqual(['request']); + expect(directionsOf(cafe.get('#/info')!)).toEqual([]); }); it('should turn the direction round under a callback or a webhook, which the API sends', () => { - const cafe = treeOf(` + const cafe = diffTreeOf(` #/ Root #/paths Paths #/paths/~1orders PathItem @@ -51,137 +44,192 @@ describe('resolveDirections for OpenAPI 3', () => { )!; const menuChangedAnswer = cafe.get('#/webhooks/menuChanged/post/responses')!; - const directionOf = directionsOfTree(cafe, [], oas3Directions); - - expect(directionOf(orderReadySent)).toEqual(['response']); - expect(directionOf(menuChangedAnswer)).toEqual(['request']); + expect(directionsOf(orderReadySent)).toEqual(['response']); + expect(directionsOf(menuChangedAnswer)).toEqual(['request']); }); - it('should give a component the directions of the places that reference it, through other components', () => { - const cafe = treeOf(` - #/ Root - #/paths Paths - #/paths/~1orders PathItem - #/paths/~1orders/get Operation - #/paths/~1orders/get/responses Responses - #/paths/~1orders/get/responses/200 Response - #/paths/~1orders/post Operation - #/paths/~1orders/post/requestBody RequestBody - #/paths/~1menu PathItem - #/paths/~1menu/get Operation - #/paths/~1menu/get/responses Responses - #/paths/~1menu/get/responses/200 Response - #/components Components - #/components/schemas NamedSchemas - #/components/schemas/Order Schema - #/components/schemas/Order/items Schema - #/components/schemas/OrderItem Schema - #/components/schemas/OrderItem/order Schema - #/components/schemas/MenuItem Schema - #/components/schemas/Dessert Schema - `); - const order = cafe.get('#/components/schemas/Order')!; - const orderItem = cafe.get('#/components/schemas/OrderItem')!; - const menuItem = cafe.get('#/components/schemas/MenuItem')!; - const dessert = cafe.get('#/components/schemas/Dessert')!; - - const directionOf = directionsOfTree( - cafe, + it('should give a component the directions of the places that reach it, through other components', () => { + const cafe = withReferences( + diffTreeOf(` + #/ Root + #/paths Paths + #/paths/~1orders PathItem + #/paths/~1orders/get Operation + #/paths/~1orders/get/responses Responses + #/paths/~1orders/get/responses/200 Response + #/paths/~1orders/post Operation + #/paths/~1orders/post/requestBody RequestBody + #/paths/~1menu PathItem + #/paths/~1menu/get Operation + #/paths/~1menu/get/responses Responses + #/paths/~1menu/get/responses/200 Response + #/components Components + #/components/schemas NamedSchemas + #/components/schemas/Order Schema + #/components/schemas/Order/items Schema + #/components/schemas/OrderItem Schema + #/components/schemas/OrderItem/order Schema + #/components/schemas/MenuItem Schema + #/components/schemas/Dessert Schema + `), [ ['#/paths/~1orders/get/responses/200', '#/components/schemas/Order'], ['#/paths/~1orders/post/requestBody', '#/components/schemas/Order'], ['#/components/schemas/Order/items', '#/components/schemas/OrderItem'], ['#/components/schemas/OrderItem/order', '#/components/schemas/Order'], ['#/paths/~1menu/get/responses/200', '#/components/schemas/MenuItem'], - ], - oas3Directions + ] ); - expect(directionOf(order)).toEqual(['request', 'response']); - expect(directionOf(orderItem)).toEqual(['request', 'response']); - expect(directionOf(menuItem)).toEqual(['response']); - expect(directionOf(dessert)).toEqual([]); + expect(directionsOf(cafe.get('#/components/schemas/Order')!)).toEqual(['request', 'response']); + expect(directionsOf(cafe.get('#/components/schemas/OrderItem')!)).toEqual([ + 'request', + 'response', + ]); + expect(directionsOf(cafe.get('#/components/schemas/MenuItem')!)).toEqual(['response']); + expect(directionsOf(cafe.get('#/components/schemas/Dessert')!)).toEqual([]); }); -}); -describe('resolveDirections for AsyncAPI 3', () => { - it('should judge what an operation receives as a request and what it sends as a response', () => { - const kitchen = treeOf(` + it('should read the direction off the place of a $ref when the component it points at says nothing', () => { + const cafe = withReferences( + diffTreeOf(` + #/ Root + #/paths Paths + #/paths/~1orders PathItem + #/paths/~1orders/post Operation + #/paths/~1orders/post/requestBody RequestBody + #/components Components + #/components/requestBodies NamedRequestBodies + #/components/requestBodies/NewOrder RequestBody + `), + [['#/paths/~1orders/post/requestBody', '#/components/requestBodies/NewOrder']] + ); + + expect(directionsOf(cafe.get('#/components/requestBodies/NewOrder')!)).toEqual(['request']); + }); + + it('should take readOnly as response data and writeOnly as request data wherever the schema is', () => { + const cafe = diffTreeOf(` #/ Root - #/channels NamedChannels - #/channels/orderPlaced Channel - #/channels/orderReady Channel - #/channels/menuChanged Channel - #/operations NamedOperations - #/operations/onOrderPlaced Operation action=receive - #/operations/onOrderPlaced/channel Channel - #/operations/sendOrderReady Operation action=send - #/operations/sendOrderReady/channel Channel + #/paths Paths + #/paths/~1orders PathItem + #/paths/~1orders/post Operation + #/paths/~1orders/post/requestBody RequestBody + #/paths/~1orders/post/requestBody/schema Schema + #/paths/~1orders/post/requestBody/schema/properties SchemaProperties + #/paths/~1orders/post/requestBody/schema/properties/id Schema readOnly=true + #/paths/~1orders/get Operation + #/paths/~1orders/get/responses Responses + #/paths/~1orders/get/responses/200 Response + #/paths/~1orders/get/responses/200/schema Schema + #/paths/~1orders/get/responses/200/schema/properties SchemaProperties + #/paths/~1orders/get/responses/200/schema/properties/secret Schema writeOnly=true `); - const orderPlaced = kitchen.get('#/channels/orderPlaced')!; - const orderReady = kitchen.get('#/channels/orderReady')!; - const menuChanged = kitchen.get('#/channels/menuChanged')!; - const directionOf = directionsOfTree( - kitchen, + expect(directionsOf(cafe.get('#/paths/~1orders/post/requestBody/schema')!)).toEqual([ + 'request', + ]); + expect( + directionsOf(cafe.get('#/paths/~1orders/post/requestBody/schema/properties/id')!) + ).toEqual(['response']); + expect(directionsOf(cafe.get('#/paths/~1orders/get/responses/200/schema')!)).toEqual([ + 'response', + ]); + expect( + directionsOf(cafe.get('#/paths/~1orders/get/responses/200/schema/properties/secret')!) + ).toEqual(['request']); + }); +}); + +describe('directionsOf for AsyncAPI 3', () => { + it('should judge what an operation receives as a request and what it sends as a response', () => { + const kitchen = withReferences( + diffTreeOf(` + #/ Root + #/channels NamedChannels + #/channels/orderPlaced Channel + #/channels/orderReady Channel + #/channels/menuChanged Channel + #/operations NamedOperations + #/operations/onOrderPlaced Operation action=receive + #/operations/onOrderPlaced/channel Channel + #/operations/sendOrderReady Operation action=send + #/operations/sendOrderReady/channel Channel + `), [ ['#/operations/onOrderPlaced/channel', '#/channels/orderPlaced'], ['#/operations/sendOrderReady/channel', '#/channels/orderReady'], - ], - async3Directions + ] ); - expect(directionOf(orderPlaced)).toEqual(['request']); - expect(directionOf(orderReady)).toEqual(['response']); - expect(directionOf(menuChanged)).toEqual([]); + expect(directionsOf(kitchen.get('#/channels/orderPlaced')!)).toEqual(['request']); + expect(directionsOf(kitchen.get('#/channels/orderReady')!)).toEqual(['response']); + expect(directionsOf(kitchen.get('#/channels/menuChanged')!)).toEqual([]); }); it('should turn the direction round for the channel a reply travels on', () => { - const kitchen = treeOf(` - #/ Root - #/channels NamedChannels - #/channels/orderConfirmed Channel - #/operations NamedOperations - #/operations/onOrderPlaced Operation action=receive - #/operations/onOrderPlaced/reply OperationReply - #/operations/onOrderPlaced/reply/channel Channel - `); - const orderConfirmed = kitchen.get('#/channels/orderConfirmed')!; + const kitchen = withReferences( + diffTreeOf(` + #/ Root + #/channels NamedChannels + #/channels/orderConfirmed Channel + #/operations NamedOperations + #/operations/onOrderPlaced Operation action=receive + #/operations/onOrderPlaced/reply OperationReply + #/operations/onOrderPlaced/reply/channel Channel + `), + [['#/operations/onOrderPlaced/reply/channel', '#/channels/orderConfirmed']] + ); - const directionOf = directionsOfTree( - kitchen, - [['#/operations/onOrderPlaced/reply/channel', '#/channels/orderConfirmed']], - async3Directions + expect(directionsOf(kitchen.get('#/channels/orderConfirmed')!)).toEqual(['response']); + }); + + it("should judge what the revision has in the revision's directions, and what only the base has in the base's", () => { + const kitchen = withReferences( + diffTreeOf(` + #/ Root + #/channels NamedChannels + #/channels/orderReady Channel + #/channels/orderReady/messages NamedMessages + #/channels/orderReady/messages/orderReady Message + #/operations NamedOperations + #/operations/sendOrderReady Operation action=send + #/operations/sendOrderReady/channel Channel + `), + [['#/operations/sendOrderReady/channel', '#/channels/orderReady']] ); + const sendOrderReady = kitchen.get('#/operations/sendOrderReady')!; + sendOrderReady.revision = { ...sendOrderReady.revision!, value: { action: 'receive' } }; + const orderReady = kitchen.get('#/channels/orderReady/messages/orderReady')!; - expect(directionOf(orderConfirmed)).toEqual(['response']); + expect(directionsOf({ ...orderReady, revision: undefined })).toEqual(['response']); + expect(directionsOf(orderReady)).toEqual(['request']); }); it('should give a message the directions of both its channel and itself', () => { - const kitchen = treeOf(` - #/ Root - #/channels NamedChannels - #/channels/orders Channel - #/channels/orders/messages NamedMessages - #/channels/orders/messages/orderPlaced Message - #/operations NamedOperations - #/operations/onOrder Operation action=receive - #/operations/onOrder/channel Channel - #/operations/sendOrderPlaced Operation action=send - #/operations/sendOrderPlaced/messages MessageList - #/operations/sendOrderPlaced/messages/0 Message - `); - const orderPlaced = kitchen.get('#/channels/orders/messages/orderPlaced')!; - - const directionOf = directionsOfTree( - kitchen, + const kitchen = withReferences( + diffTreeOf(` + #/ Root + #/channels NamedChannels + #/channels/orders Channel + #/channels/orders/messages NamedMessages + #/channels/orders/messages/orderPlaced Message + #/operations NamedOperations + #/operations/onOrder Operation action=receive + #/operations/onOrder/channel Channel + #/operations/sendOrderPlaced Operation action=send + #/operations/sendOrderPlaced/messages MessageList + #/operations/sendOrderPlaced/messages/0 Message + `), [ ['#/operations/onOrder/channel', '#/channels/orders'], ['#/operations/sendOrderPlaced/messages/0', '#/channels/orders/messages/orderPlaced'], - ], - async3Directions + ] ); - expect(directionOf(orderPlaced)).toEqual(['request', 'response']); + expect(directionsOf(kitchen.get('#/channels/orders/messages/orderPlaced')!)).toEqual([ + 'request', + 'response', + ]); }); }); diff --git a/packages/core/src/diff/__tests__/impact.test.ts b/packages/core/src/diff/__tests__/impact.test.ts index a69cfc8324..83b6866568 100644 --- a/packages/core/src/diff/__tests__/impact.test.ts +++ b/packages/core/src/diff/__tests__/impact.test.ts @@ -1,4 +1,25 @@ +import { pointerOf } from '../diff-node.js'; import { defaultImpact, highestImpact, impactRank } from '../impact.js'; +import type { Change, DiffNode } from '../types.js'; +import { diffTreeOf } from './utils.js'; + +const cafe = diffTreeOf(` + #/ Root + #/paths Paths + #/components Components + #/components/schemas NamedSchemas + #/components/schemas/MenuItem Schema + #/components/schemas/MenuItem/properties SchemaProperties +`); + +function addition(node: DiffNode): Change { + return { + key: pointerOf(node), + kind: 'added', + node, + revision: { location: node.revision!.location, value: {} }, + }; +} describe('impact', () => { it('should rank patch below minor below major', () => { @@ -12,8 +33,36 @@ describe('impact', () => { }); it('should take an unjudged addition for a minor and anything else for a patch', () => { - expect(defaultImpact('added')).toBe('minor'); - expect(defaultImpact('removed')).toBe('patch'); - expect(defaultImpact('modified')).toBe('patch'); + const paths = cafe.get('#/paths')!; + const location = paths.base!.location; + + expect(defaultImpact(addition(paths))).toBe('minor'); + expect( + defaultImpact({ + key: pointerOf(paths), + kind: 'removed', + node: paths, + base: { location, value: {} }, + }) + ).toBe('patch'); + expect( + defaultImpact({ + key: pointerOf(paths), + kind: 'modified', + property: 'x-internal', + node: paths, + base: { location, value: false }, + revision: { location, value: true }, + }) + ).toBe('patch'); + }); + + it('should take a new component, or a new map of them, for a patch until something uses it', () => { + expect(defaultImpact(addition(cafe.get('#/components')!))).toBe('patch'); + expect(defaultImpact(addition(cafe.get('#/components/schemas')!))).toBe('patch'); + expect(defaultImpact(addition(cafe.get('#/components/schemas/MenuItem')!))).toBe('patch'); + expect(defaultImpact(addition(cafe.get('#/components/schemas/MenuItem/properties')!))).toBe( + 'minor' + ); }); }); diff --git a/packages/core/src/diff/__tests__/index.test.ts b/packages/core/src/diff/__tests__/index.test.ts index 766f069125..08b61b27eb 100644 --- a/packages/core/src/diff/__tests__/index.test.ts +++ b/packages/core/src/diff/__tests__/index.test.ts @@ -1,9 +1,14 @@ import { outdent } from 'outdent'; -import { replaceSourceWithRefInChanges } from '../../../__tests__/utils.js'; -import { createConfig } from '../../config/index.js'; -import { makeDocumentFromString } from '../../resolve.js'; +import { + replaceSourceWithRef, + replaceSourceWithRefInChanges, + resolverWithFiles, +} from '../../../__tests__/utils.js'; +import { createConfig, type Config } from '../../config/index.js'; +import { makeDocumentFromString, type Document } from '../../resolve.js'; import { HandledError } from '../../utils/error.js'; +import type { Oas3Preprocessor } from '../../visitors.js'; import { diffDocuments } from '../index.js'; const orderStatus = (paths: string, statuses: string) => outdent` @@ -39,14 +44,14 @@ const menuItem = (template: string) => outdent` `; describe('diffDocuments', () => { - it('should judge a component in the direction a site added in the revision uses it', async () => { + it('should not judge a component by a place that only the revision has', async () => { const placeOrder = outdent` { /orders: { post: { requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/OrderStatus' } } } }, responses: { 201: { description: Created } } } } } `; - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(orderStatus('{}', '[placed, preparing, ready]'), 'base.yaml'), revision: makeDocumentFromString(orderStatus(placeOrder, '[placed, ready]'), 'revision.yaml'), config: await createConfig({ extends: ['diff-recommended'] }), @@ -90,7 +95,7 @@ describe('diffDocuments', () => { "ready", ], }, - "impact": "major", + "impact": "patch", "key": "#/components/schemas/OrderStatus", "kind": "modified", "property": "enum", @@ -101,21 +106,74 @@ describe('diffDocuments', () => { "ready", ], }, - "verdicts": [ - { - "impact": "major", - "location": "revision.yaml#/components/schemas/OrderStatus/enum", - "message": "Enum of \`OrderStatus\` lost values: 'preparing'.", - "ruleId": "enum-values-removed", - }, - ], + "verdicts": [], }, ] `); }); - it('should match reordered parameters by name and judge only what changed', async () => { - const result = diffDocuments({ + it('should judge a part only the base has by the base, not by what the revision marks above it', async () => { + const orders = (meta: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + get: + responses: + '200': + description: OK + content: + application/json: + schema: { type: object, properties: { meta: ${meta} } } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString( + orders('{ type: object, properties: { a: { type: string }, b: { type: string } } }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + orders('{ type: object, writeOnly: true, properties: { a: { type: string } } }'), + 'revision.yaml' + ), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(result.changes.find((change) => change.kind === 'removed')).toMatchObject({ + key: '#/paths/~1orders/get/responses/200/content/application~1json/schema/properties/meta/properties/b', + impact: 'major', + verdicts: [{ ruleId: 'property-removed' }], + }); + }); + + it('should judge a component under a $ref with keys next to it in the direction of its place', async () => { + const placeOrder = outdent` + { /orders: { post: { + requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/OrderStatus', description: The first status } } } }, + responses: { 201: { description: Created } } } } } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString( + orderStatus(placeOrder, '[placed, preparing, ready]'), + 'base.yaml' + ), + revision: makeDocumentFromString(orderStatus(placeOrder, '[placed, ready]'), 'revision.yaml'), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(result.changes).toMatchObject([ + { + kind: 'modified', + property: 'enum', + impact: 'major', + verdicts: [{ ruleId: 'enum-values-removed' }], + }, + ]); + }); + + it('should pair reordered parameters by their content and judge only what changed', async () => { + const result = await diffDocuments({ base: makeDocumentFromString( menu( '[{ name: limit, in: query, schema: { type: integer } }, { name: search, in: query }]' @@ -140,7 +198,7 @@ describe('diffDocuments', () => { "value": undefined, }, "impact": "major", - "key": "#/paths/~1menu/get/parameters/{query:limit}", + "key": "#/paths/~1menu/get/parameters/1", "kind": "modified", "property": "required", "revision": { @@ -162,7 +220,7 @@ describe('diffDocuments', () => { "value": "integer", }, "impact": "patch", - "key": "#/paths/~1menu/get/parameters/{query:limit}/schema", + "key": "#/paths/~1menu/get/parameters/1/schema", "kind": "modified", "property": "type", "revision": { @@ -193,7 +251,7 @@ describe('diffDocuments', () => { }); it('should report a renamed path parameter as a change of key and name, not a removal', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(menuItem('id'), 'base.yaml'), revision: makeDocumentFromString(menuItem('menuItemId'), 'revision.yaml'), config: await createConfig({ extends: ['diff-recommended'] }), @@ -207,7 +265,7 @@ describe('diffDocuments', () => { "value": "/menu/{id}", }, "impact": "patch", - "key": "#/paths/~1menu~1{0}", + "key": "#/paths/~1menu~1{menuItemId}", "kind": "modified", "property": "key", "revision": { @@ -222,7 +280,7 @@ describe('diffDocuments', () => { "value": "id", }, "impact": "patch", - "key": "#/paths/~1menu~1{0}/get/parameters/{path:0}", + "key": "#/paths/~1menu~1{menuItemId}/get/parameters/0", "kind": "modified", "property": "name", "revision": { @@ -236,46 +294,36 @@ describe('diffDocuments', () => { expect(result.bump).toBe('patch'); }); - it('should number a second path of the same shape, which the specification forbids', async () => { - const cafe = (paths: string) => outdent` + it('should find no change where an inline schema moved into a component that a $ref with a description points to', async () => { + const cafe = (schema: string, schemas: string) => outdent` openapi: 3.1.0 info: { title: Cafe, version: 1.0.0 } - paths: ${paths} + paths: + /menu: + get: + responses: + '200': + description: OK + content: + application/json: + schema: ${schema} + components: + schemas: ${schemas} `; - const item = (template: string) => - `'/menu/{${template}}': { get: { responses: { 200: { description: OK } } } }`; + const item = '{ type: object, properties: { name: { type: string } } }'; - const result = diffDocuments({ - base: makeDocumentFromString(cafe(`{ ${item('menuItemId')} }`), 'base.yaml'), + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(item, '{}'), 'base.yaml'), revision: makeDocumentFromString( - cafe(`{ ${item('menuItemId')}, ${item('itemId')} }`), + cafe("{ $ref: '#/components/schemas/Item', description: A dish }", `{ Item: ${item} }`), 'revision.yaml' ), config: await createConfig({ extends: ['diff-recommended'] }), }); - expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` - [ - { - "impact": "minor", - "key": "#/paths/~1menu~1{0}#2", - "kind": "added", - "revision": { - "location": "revision.yaml#/paths/~1menu~1{itemId}", - "value": { - "get": { - "responses": { - "200": { - "description": "OK", - }, - }, - }, - }, - }, - "verdicts": [], - }, - ] - `); + expect(result.changes.map(({ key, kind, impact }) => ({ key, kind, impact }))).toEqual([ + { key: '#/components/schemas/Item', kind: 'added', impact: 'patch' }, + ]); }); it('should leave the change of info.version out of the required bump', async () => { @@ -285,7 +333,7 @@ describe('diffDocuments', () => { paths: {} `; - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe('1.0.0'), 'base.yaml'), revision: makeDocumentFromString(cafe('2.0.0'), 'revision.yaml'), config: await createConfig({ extends: ['diff-recommended'] }), @@ -302,7 +350,7 @@ describe('diffDocuments', () => { paths: ${paths} `; - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( swagger('{ /menu: { get: { responses: { 200: { description: OK } } } } }'), 'base.yaml' @@ -335,6 +383,173 @@ describe('diffDocuments', () => { `); }); + it('should name a header added to a Swagger 2 response by its key in the headers map', async () => { + const swagger = (headers: string) => outdent` + swagger: '2.0' + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + responses: + '200': { description: OK, headers: ${headers} } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(swagger('{ X-Request-Id: { type: string } }'), 'base.yaml'), + revision: makeDocumentFromString( + swagger('{ X-Request-Id: { type: string }, X-Rate-Limit: { type: integer } }'), + 'revision.yaml' + ), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(result.changes).toMatchObject([ + { + key: '#/paths/~1menu/get/responses/200/headers/X-Rate-Limit', + kind: 'added', + node: { revision: { key: 'X-Rate-Limit' } }, + }, + ]); + }); + + it('should judge the request body of a webhook in a file of its own as data the API sends', async () => { + const orderReady = (properties: string) => outdent` + post: + requestBody: + content: + application/json: + schema: { type: object, properties: ${properties} } + responses: + '200': { description: OK } + `; + const root = outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + webhooks: + orderReady: { $ref: webhooks/order-ready.yaml } + `; + const resolver = resolverWithFiles({ + 'base/openapi.yaml': root, + 'base/webhooks/order-ready.yaml': orderReady( + '{ id: { type: string }, note: { type: string } }' + ), + 'revision/openapi.yaml': root, + 'revision/webhooks/order-ready.yaml': orderReady('{ id: { type: string } }'), + }); + const load = async (side: 'base' | 'revision') => + (await resolver.resolveDocument(null, `${side}/openapi.yaml`, true)) as Document; + + const result = await diffDocuments({ + base: await load('base'), + revision: await load('revision'), + config: await createConfig({ extends: ['diff-recommended'] }), + externalRefResolver: resolver, + }); + + expect(result.changes).toMatchObject([ + { + key: '#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/note', + kind: 'removed', + impact: 'major', + verdicts: [{ ruleId: 'property-removed' }], + }, + ]); + }); + + it('should judge the target of a chain of $refs with keys next to them in the direction of the path that uses it', async () => { + const item = (type: string) => outdent` + get: + responses: + '200': + description: OK + content: + application/json: + schema: { type: ${type} } + callbacks: + onEvent: + '{$request.body#/url}': { $ref: '#' } + `; + const root = outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: { $ref: ./menu.yaml } + `; + const menu = '{ $ref: ./item.yaml, summary: Menu }'; + const resolver = resolverWithFiles({ + 'base/openapi.yaml': root, + 'base/menu.yaml': menu, + 'base/item.yaml': item('string'), + 'revision/openapi.yaml': root, + 'revision/menu.yaml': menu, + 'revision/item.yaml': item('integer'), + }); + const load = async (side: 'base' | 'revision') => + (await resolver.resolveDocument(null, `${side}/openapi.yaml`, true)) as Document; + + const result = await diffDocuments({ + base: await load('base'), + revision: await load('revision'), + config: await createConfig({ extends: ['diff-recommended'] }), + externalRefResolver: resolver, + }); + + expect(result.changes).toMatchObject([ + { + key: '#/paths/~1menu/$ref/get/responses/200/content/application~1json/schema', + kind: 'modified', + property: 'type', + impact: 'major', + verdicts: [ + { ruleId: 'schema-type-changed', message: "Type widened from 'string' to 'integer'." }, + ], + }, + ]); + }); + + it("should judge a callback's $ref to a path's request body as a request", async () => { + const cafe = (required: string) => outdent` + openapi: 3.0.3 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + responses: { '200': { description: OK } } + callbacks: + done: + '{$request.body#/url}': + post: + requestBody: { $ref: '#/paths/~1receipts/post/requestBody' } + responses: { '200': { description: OK } } + /receipts: + post: + requestBody: + content: + application/json: + schema: + type: object + required: ${required} + properties: { id: { type: string }, total: { type: number } } + responses: { '200': { description: OK } } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('[id]'), 'base.yaml'), + revision: makeDocumentFromString(cafe('[id, total]'), 'revision.yaml'), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(result.changes).toMatchObject([ + { + key: '#/paths/~1orders/post/callbacks/done/{$request.body#~1url}/post/requestBody/content/application~1json/schema', + kind: 'modified', + property: 'required', + impact: 'major', + verdicts: [{ ruleId: 'required-properties-added' }], + }, + ]); + }); + it('should refuse to compare documents of different specifications', async () => { const swagger = outdent` swagger: '2.0' @@ -343,14 +558,43 @@ describe('diffDocuments', () => { `; const config = await createConfig({ extends: ['diff-recommended'] }); - const compare = () => + await expect( diffDocuments({ base: makeDocumentFromString(swagger, 'base.yaml'), revision: makeDocumentFromString(menu('[]'), 'revision.yaml'), config, - }); + }) + ).rejects.toThrow(HandledError); + }); - expect(compare).toThrow(HandledError); + it('should report a $ref that does not resolve as an error', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(menu('[]'), 'base.yaml'), + revision: makeDocumentFromString( + menu("[{ $ref: '#/components/parameters/Limit' }]"), + 'revision.yaml' + ), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(replaceSourceWithRef(result.problems)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/paths/~1menu/get/parameters/0", + "reportOnKey": false, + "source": "revision.yaml", + }, + ], + "message": "Can't resolve $ref", + "reference": "https://redocly.com/docs/cli/rules/oas/no-unresolved-refs", + "ruleId": "no-unresolved-refs", + "severity": "error", + "suggest": [], + }, + ] + `); }); }); @@ -369,7 +613,7 @@ describe('diffDocuments configuration', () => { it('should take the impacts of diff-recommended', async () => { const config = await createConfig({ extends: ['diff-recommended'] }); - const [cancelRemoved] = diffDocuments({ base, revision, config }).changes; + const [cancelRemoved] = (await diffDocuments({ base, revision, config })).changes; expect(cancelRemoved.impact).toBe('major'); }); @@ -380,7 +624,7 @@ describe('diffDocuments configuration', () => { diff: { 'operation-removed': 'minor' }, }); - const [cancelRemoved] = diffDocuments({ base, revision, config }).changes; + const [cancelRemoved] = (await diffDocuments({ base, revision, config })).changes; expect(cancelRemoved.impact).toBe('minor'); }); @@ -391,7 +635,7 @@ describe('diffDocuments configuration', () => { oas3_1Diff: { 'operation-removed': 'minor' }, }); - const [cancelRemoved] = diffDocuments({ base, revision, config }).changes; + const [cancelRemoved] = (await diffDocuments({ base, revision, config })).changes; expect(cancelRemoved.impact).toBe('minor'); }); @@ -402,7 +646,7 @@ describe('diffDocuments configuration', () => { diff: { 'operation-removed': 'off' }, }); - const [cancelRemoved] = diffDocuments({ base, revision, config }).changes; + const [cancelRemoved] = (await diffDocuments({ base, revision, config })).changes; expect(cancelRemoved.impact).toBe('patch'); }); @@ -410,8 +654,97 @@ describe('diffDocuments configuration', () => { it('should run only the listed rules when no preset is extended', async () => { const config = await createConfig({ diff: { 'path-removed': 'major' } }); - const [cancelRemoved] = diffDocuments({ base, revision, config }).changes; + const [cancelRemoved] = (await diffDocuments({ base, revision, config })).changes; expect(cancelRemoved.impact).toBe('patch'); }); + + it('should compare the descriptions as written, without the configured decorators', async () => { + const config = await createConfig({ + extends: ['diff-recommended'], + decorators: { 'remove-x-internal': 'on' }, + }); + const withKitchen = makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders/{orderId}: { ${get}, ${cancel} } + /kitchen: + x-internal: true + get: { responses: { 200: { description: OK } } } + `, + 'revision.yaml' + ); + + const result = await diffDocuments({ base, revision: withKitchen, config }); + + expect(result.changes.map(({ key, kind }) => ({ key, kind }))).toEqual([ + { key: '#/paths/~1kitchen', kind: 'added' }, + ]); + }); +}); + +describe('diffDocuments of a document that another file refers back into', () => { + const cafe = (costType: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: schemas/item.yaml } + components: + schemas: + Price: + type: object + properties: + amount: { type: number } + cost: { type: ${costType} } + `; + + // `schemas/item.yaml` uses `Price` from the root; the revision changes `Price.cost`. + async function diffCafes(config: Config) { + const item = "properties: { price: { $ref: '../openapi.yaml#/components/schemas/Price' } }"; + const resolver = resolverWithFiles({ + 'base/openapi.yaml': cafe('number'), + 'base/schemas/item.yaml': item, + 'revision/openapi.yaml': cafe('string'), + 'revision/schemas/item.yaml': item, + }); + const load = async (side: 'base' | 'revision') => + (await resolver.resolveDocument(null, `${side}/openapi.yaml`, true)) as Document; + + const result = await diffDocuments({ + base: await load('base'), + revision: await load('revision'), + config, + externalRefResolver: resolver, + }); + return result; + } + + it('should compare what the preprocessors left of it', async () => { + // It changes `Price` from the components map, which the `$ref` in `item.yaml` does not pass through. + const removeCost: Oas3Preprocessor = () => ({ + NamedSchemas: { + leave(schemas) { + delete schemas.Price?.properties?.cost; + }, + }, + }); + + const result = await diffCafes( + await createConfig({ + plugins: [{ id: 'cafe', preprocessors: { oas3: { 'remove-cost': removeCost } } }], + preprocessors: { 'cafe/remove-cost': 'on' }, + }) + ); + + expect(result.changes).toEqual([]); + }); }); diff --git a/packages/core/src/diff/__tests__/judge.test.ts b/packages/core/src/diff/__tests__/judge.test.ts index 44580a64d4..c7d976bd05 100644 --- a/packages/core/src/diff/__tests__/judge.test.ts +++ b/packages/core/src/diff/__tests__/judge.test.ts @@ -1,7 +1,15 @@ +import type { InitializedDiffRule } from '../../config/rules.js'; +import type { Location } from '../../ref-utils.js'; import { appliesTo, judgeChanges } from '../judge.js'; import type { Change, DiffRule } from '../types.js'; import { diffTreeOf } from './utils.js'; +const rulesOf = (rule: DiffRule): InitializedDiffRule[] => [ + { ruleId: 'my-rule', impact: 'major', visitor: rule() }, +]; +const judgeWith = (rule: DiffRule, change: Change) => + judgeChanges([change], rulesOf(rule), 'oas3_1')[0]; + describe('judgeChanges', () => { it('should run a nested handler for a member only and an any handler for every change', () => { const cafe = diffTreeOf(` @@ -9,23 +17,20 @@ describe('judgeChanges', () => { #/Order/properties SchemaProperties #/Order/properties/items Schema `); - const order = cafe.get('#/Order')!; const items = cafe.get('#/Order/properties/items')!; - const orderRemoved: Change = { - key: order.label, + key: '#/Order', kind: 'removed', node: order, base: { location: order.base!.location, value: {} }, }; const itemsRemoved: Change = { - key: items.label, + key: '#/Order/properties/items', kind: 'removed', node: items, base: { location: items.base!.location, value: {} }, }; - const rule: DiffRule = () => ({ any(_change, { report }) { report({ message: 'Something changed.' }); @@ -37,49 +42,102 @@ describe('judgeChanges', () => { }, }); - const [orderJudged, itemsJudged] = judgeChanges({ - changes: [orderRemoved, itemsRemoved], - specVersion: 'oas3_1', - ruleSets: [{ 'my-rule': rule }], - impactOf: () => 'major', - directionOf: () => [], - }); - - expect(orderJudged.verdicts).toMatchObject([{ message: 'Something changed.' }]); - expect(itemsJudged.verdicts).toMatchObject([ + expect(judgeWith(rule, orderRemoved).verdicts).toMatchObject([ + { message: 'Something changed.' }, + ]); + expect(judgeWith(rule, itemsRemoved).verdicts).toMatchObject([ { message: 'Something changed.' }, { message: 'A property changed.' }, ]); }); + it('should hand the rule the location of the changed side and report there unless told otherwise', () => { + const cafe = diffTreeOf(` + #/Order Schema + `); + const order = cafe.get('#/Order')!; + const orderRemoved: Change = { + key: '#/Order', + kind: 'removed', + node: order, + base: { location: order.base!.location, value: {} }, + }; + const seen: Location[] = []; + const elsewhere = order.base!.location.child(['type']); + const rule: DiffRule = () => ({ + Schema(_change, { report, location }) { + seen.push(location); + report({ message: 'Removed.' }); + report({ message: 'Elsewhere.', location: elsewhere }); + }, + }); + + const { impact, verdicts } = judgeWith(rule, orderRemoved); + + expect(seen).toEqual([order.base!.location]); + expect(verdicts).toEqual([ + { ruleId: 'my-rule', impact: 'major', message: 'Removed.', location: order.base!.location }, + { ruleId: 'my-rule', impact: 'major', message: 'Elsewhere.', location: elsewhere }, + ]); + expect(impact).toBe('major'); + }); + + it('should give a change no rule speaks about the default impact', () => { + const cafe = diffTreeOf(` + #/Order Schema + `); + const order = cafe.get('#/Order')!; + const orderAdded: Change = { + key: '#/Order', + kind: 'added', + node: order, + revision: { location: order.revision!.location, value: {} }, + }; + + expect(judgeWith(() => ({}), orderAdded)).toMatchObject({ impact: 'minor', verdicts: [] }); + }); + + it('should give a rule the directions of the change when it asks', () => { + const cafe = diffTreeOf(` + #/ Root + #/paths Paths + #/paths/~1orders PathItem + #/paths/~1orders/get Operation + #/paths/~1orders/get/parameters ParameterList + #/paths/~1orders/get/parameters/0 Parameter + `); + const limit = cafe.get('#/paths/~1orders/get/parameters/0')!; + const limitRemoved: Change = { + key: '#/paths/~1orders/get/parameters/0', + kind: 'removed', + node: limit, + base: { location: limit.base!.location, value: {} }, + }; + const rule: DiffRule = () => ({ + Parameter(_change, { report, getDirections }) { + report({ message: `Travels as ${getDirections().join(' and ')}.` }); + }, + }); + + expect(judgeWith(rule, limitRemoved).verdicts).toMatchObject([ + { message: 'Travels as request.' }, + ]); + }); + it('should reject a lint hook, which a diff rule has no use for', () => { const withEnter: DiffRule = () => ({ Schema: { enter() {} } }); - const judge = () => - judgeChanges({ - changes: [], - specVersion: 'oas3_1', - ruleSets: [{ 'my-rule': withEnter }], - impactOf: () => 'major', - directionOf: () => [], - }); - - expect(judge).toThrow("Diff rule 'my-rule' uses 'enter' where diff visitors do not have it."); + expect(() => judgeChanges([], rulesOf(withEnter), 'oas3_1')).toThrow( + "Diff rule 'my-rule' uses 'enter' where diff visitors do not have it." + ); }); it('should reject an any handler nested under a type', () => { const withNestedAny: DiffRule = () => ({ Schema: { any() {} } }); - const judge = () => - judgeChanges({ - changes: [], - specVersion: 'oas3_1', - ruleSets: [{ 'my-rule': withNestedAny }], - impactOf: () => 'major', - directionOf: () => [], - }); - - expect(judge).toThrow("Diff rule 'my-rule' uses 'any' where diff visitors do not have it."); + expect(() => judgeChanges([], rulesOf(withNestedAny), 'oas3_1')).toThrow( + "Diff rule 'my-rule' uses 'any' where diff visitors do not have it." + ); }); }); diff --git a/packages/core/src/diff/__tests__/pair-children.test.ts b/packages/core/src/diff/__tests__/pair-children.test.ts new file mode 100644 index 0000000000..a1c0cdbb8f --- /dev/null +++ b/packages/core/src/diff/__tests__/pair-children.test.ts @@ -0,0 +1,120 @@ +import type { NodeEntry, NodeValue } from '../../node-tree/types.js'; +import { Location } from '../../ref-utils.js'; +import { Source } from '../../resolve.js'; +import { pairChildren } from '../pair-children.js'; +import type { Pair } from '../types.js'; + +const source = new Source('cafe.yaml', ''); + +const child = (key: string | number, type: string, value: object = {}): NodeEntry => ({ + type: { name: type, properties: {} }, + key, + // The pairing reads the key, the type name and the value of a child, and the type of its parent + // to tell a field from a map entry. + value: value as NodeValue, + location: new Location(source, `#/${key}`), + parent: null, + children: [], +}); +const parameter = (index: number, value: object) => child(index, 'Parameter', value); +const property = (name: string) => child(name, 'Schema', { type: 'string' }); +const field = (name: string) => ({ + ...property(name), + parent: { + ...child('owner', 'Schema'), + type: { name: 'Schema', properties: { [name]: { name: 'Schema', properties: {} } } }, + }, +}); + +const keysOf = (pairs: Pair[]) => pairs.map((pair) => [pair.base?.key, pair.revision?.key]); + +describe('pairChildren', () => { + it('should pair children that are the same, wherever they are', () => { + const pairs = pairChildren( + [parameter(0, { name: 'limit', in: 'query' }), parameter(1, { name: 'offset', in: 'query' })], + [parameter(0, { name: 'offset', in: 'query' }), parameter(1, { name: 'limit', in: 'query' })] + ); + + expect(keysOf(pairs)).toEqual([ + [0, 1], + [1, 0], + ]); + }); + + it('should pair the most alike of the rest and leave the others alone', () => { + const pairs = pairChildren( + [property('price'), property('name')], + [property('prices'), property('color')] + ); + + expect(keysOf(pairs)).toEqual([ + ['price', 'prices'], + ['name', undefined], + [undefined, 'color'], + ]); + }); + + it('should pair the same before the alike, so a renamed child never takes the place of an unchanged one', () => { + const pairs = pairChildren([property('limit'), property('limits')], [property('limits')]); + + expect(keysOf(pairs)).toEqual([ + ['limits', 'limits'], + ['limit', undefined], + ]); + }); + + it('should pair duplicates in turn and leave the extra one alone', () => { + const search = { name: 'search', in: 'query' }; + + const pairs = pairChildren( + [parameter(0, search), parameter(1, search)], + [parameter(0, search)] + ); + + expect(keysOf(pairs)).toEqual([ + [0, 0], + [1, undefined], + ]); + }); + + it('should only pair children of one type', () => { + const pairs = pairChildren([child('/menu', 'PathItem')], [child('x-menu', 'SpecExtension')]); + + expect(keysOf(pairs)).toEqual([ + ['/menu', undefined], + [undefined, 'x-menu'], + ]); + }); + + it('should pair a field only with the same field', () => { + const pairs = pairChildren( + [field('items'), field('unevaluatedProperties')], + [field('unevaluatedItems'), field('items')] + ); + + expect(keysOf(pairs)).toEqual([ + ['items', 'items'], + ['unevaluatedProperties', undefined], + [undefined, 'unevaluatedItems'], + ]); + }); + + it('should pair an inline item with a $ref that stands for the same content', () => { + const limit = { name: 'limit', in: 'query' }; + const limitRef = parameter(0, { $ref: '#/components/parameters/Limit' }); + limitRef.resolved = child('Limit', 'Parameter', limit); + + const pairs = pairChildren([parameter(0, limit)], [limitRef]); + + expect(keysOf(pairs)).toEqual([[0, 0]]); + }); + + it('should pair items whose keys are written in another order', () => { + const pairs = pairChildren( + [parameter(0, { name: 'limit', in: 'query', required: true, style: 'form', example: 20 })], + [parameter(0, { example: 20, style: 'form', required: true, in: 'query', name: 'limit' })] + ); + + expect(keysOf(pairs)).toEqual([[0, 0]]); + }); +}); diff --git a/packages/core/src/diff/__tests__/utils.ts b/packages/core/src/diff/__tests__/utils.ts index c4107cf848..1d52abdcb3 100644 --- a/packages/core/src/diff/__tests__/utils.ts +++ b/packages/core/src/diff/__tests__/utils.ts @@ -1,24 +1,32 @@ import { createConfig } from '../../config/index.js'; import { detectSpec } from '../../detect-spec.js'; -import { buildNodeTree } from '../../node-tree/build.js'; +import { buildNodeTree } from '../../node-tree/index.js'; import type { NodeEntry } from '../../node-tree/types.js'; import { getTypes } from '../../oas-types.js'; import { Location } from '../../ref-utils.js'; -import { makeDocumentFromString, Source } from '../../resolve.js'; +import { BaseResolver, makeDocumentFromString, resolveDocument, Source } from '../../resolve.js'; import { normalizeTypes } from '../../types/index.js'; -import { buildDiffTree } from '../diff-tree.js'; -import { resolveDirections } from '../direction.js'; -import type { DiffNode, Directions } from '../types.js'; +import type { DiffNode } from '../types.js'; const source = new Source('tree.yaml', ''); /** The node tree of a YAML document, typed the way the diff types it. */ -export async function nodeTreeOf(yaml: string, absoluteRef = 'api.yaml') { +export async function nodeTreeOf( + yaml: string, + absoluteRef = 'api.yaml', + externalRefResolver = new BaseResolver() +): Promise { const document = makeDocumentFromString(yaml, absoluteRef); const config = await createConfig({}); const specVersion = detectSpec(document.parsed); const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - return buildNodeTree({ document, types, specVersion }); + const resolvedRefMap = await resolveDocument({ + rootDocument: document, + rootType: types.Root, + externalRefResolver, + }); + const ctx = { problems: [], specVersion, visitorsData: {} }; + return buildNodeTree({ document, types, resolvedRefMap, ctx }); } /** @@ -34,10 +42,15 @@ export function treeOf(lines: string): Map { const parentPointer = pointer.slice(0, pointer.lastIndexOf('/')); const parent = entries.get(parentPointer === '#' ? '#/' : parentPointer) ?? null; const entry: NodeEntry = { - type, + type: { name: type, properties: {} }, key: pointer.slice(pointer.lastIndexOf('/') + 1), location: new Location(source, pointer), - value: Object.fromEntries(fields.map((field) => field.split('='))), + value: Object.fromEntries( + fields.map((field) => { + const [name, value] = field.split('='); + return [name, value === 'true' ? true : value === 'false' ? false : value]; + }) + ), parent, children: [], }; @@ -48,25 +61,28 @@ export function treeOf(lines: string): Map { return entries; } -/** A spelled-out tree compared with itself, its diff nodes keyed by pointer. */ +/** A spelled-out tree paired with itself, its diff nodes keyed by pointer. */ export function diffTreeOf(lines: string): Map { - const entries = treeOf(lines); - const [root] = entries.values(); - const { diffNodeOf } = buildDiffTree(root, root, {}); - return new Map([...entries].map(([pointer, entry]) => [pointer, diffNodeOf.get(entry)!])); + const nodes = new Map(); + + for (const [pointer, entry] of treeOf(lines)) { + const parent = entry.parent ? nodes.get(entry.parent.location.pointer)! : null; + nodes.set(pointer, { base: entry, revision: entry, parent, referencedBy: [] }); + } + + return nodes; } -/** The directions in a spelled-out tree compared with itself, given the references in it. */ -export function directionsOfTree( - entries: Map, - references: Array<[from: string, to: string]>, - directions: Directions -) { - const [root] = entries.values(); - const { diffNodeOf } = buildDiffTree(root, root, {}); - return resolveDirections( - references.map(([from, to]) => ({ from: entries.get(from)!, to: entries.get(to)! })), - diffNodeOf, - directions - ); +/** + * The spelled-out diff tree with each `[from, to]` pair linked as a `$ref`: the pair at `to` + * is reached again from the place at `from`, the way `compareTrees` notes it. + */ +export function withReferences( + nodes: Map, + references: Array<[from: string, to: string]> +): Map { + for (const [from, to] of references) { + nodes.get(to)!.referencedBy.push(nodes.get(from)!); + } + return nodes; } diff --git a/packages/core/src/diff/changes.ts b/packages/core/src/diff/changes.ts index 80b692a67a..90ecd8e951 100644 --- a/packages/core/src/diff/changes.ts +++ b/packages/core/src/diff/changes.ts @@ -3,70 +3,54 @@ import type { NodeEntry } from '../node-tree/types.js'; import { dequal } from '../utils/dequal.js'; import { isPlainObject } from '../utils/is-plain-object.js'; import { isScalar, isScalarArray } from '../utils/is-scalar.js'; -import { latestOf } from './diff-tree.js'; +import { comparedSidesOf, latestOf, pointerOf } from './diff-node.js'; import type { Change, DiffNode, LocatedNode } from './types.js'; export function displaySide(change: Change): LocatedNode { return change.kind === 'removed' ? change.base : change.revision; } -/** Every change between the two documents; nothing below a node added or removed as a whole. */ -export function collectChanges(root: DiffNode): Change[] { - const changes: Change[] = []; - - const visit = (node: DiffNode) => { - const { label: key, base, revision } = node; - if (!base) { - changes.push({ key, node, kind: 'added', revision: located(latestOf(node)) }); - return; - } - if (!revision) { - changes.push({ key, node, kind: 'removed', base: located(base) }); - return; - } - - // Map entries matched by an identity other than their key, such as paths of the same shape. - if (typeof base.key === 'string' && base.key !== revision.key) { - changes.push({ - key, - node, - kind: 'modified', - property: 'key', - base: { location: base.location, value: base.key }, - revision: { location: revision.location, value: revision.key }, - }); - } +export function changesOf(node: DiffNode): Change[] { + const { base, revision } = comparedSidesOf(node); - const before = comparableValues(base); - const after = comparableValues(revision); + if (!base) { + return [{ key: pointerOf(node), node, kind: 'added', revision: located(latestOf(node)) }]; + } + if (!revision) { + return [{ key: pointerOf(node), node, kind: 'removed', base: located(base) }]; + } - for (const property of Object.keys({ ...before, ...after })) { - if (dequal(before[property], after[property])) continue; + const changes: Change[] = []; + let key: string | undefined; - changes.push({ - key, - node, - kind: 'modified', - property, - base: locatedProperty(base, property, before[property]), - revision: locatedProperty(revision, property, after[property]), - }); - } + const before = comparableValues(base); + const after = comparableValues(revision); - node.children.forEach(visit); - }; + for (const property of Object.keys({ ...before, ...after })) { + if (dequal(before[property], after[property])) continue; - visit(root); + key ??= pointerOf(node); + changes.push({ + key, + node, + kind: 'modified', + property, + base: locatedProperty(base, property, before[property]), + revision: locatedProperty(revision, property, after[property]), + }); + } return changes; } -/** The node's own scalars and scalar arrays; nested objects, arrays and `$ref`s are nodes of their own. */ function comparableValues(side: NodeEntry): Record { const values: Record = {}; + const childKeys = new Set(side.children.map((child) => child.key)); if (isPlainObject(side.value)) { for (const [name, item] of Object.entries(side.value)) { + // Children and the targets of `$ref`s are compared as pairs of their own. + if (childKeys.has(name) || (name === '$ref' && side.resolved)) continue; if (isScalar(item) || isScalarArray(item)) values[name] = item; } } @@ -74,12 +58,10 @@ function comparableValues(side: NodeEntry): Record { return values; } -// A `$ref` added or removed stands for what it points at, so a rule reads the target's value. function located(side: NodeEntry): LocatedNode { return { location: side.location, value: valueOf(side) }; } -// A property one side does not have is located at the node itself. function locatedProperty(side: NodeEntry, property: string, value: unknown): LocatedNode { const isOwnProperty = isPlainObject(side.value) && property in side.value; return { location: isOwnProperty ? side.location.child([property]) : side.location, value }; diff --git a/packages/core/src/diff/diff-node.ts b/packages/core/src/diff/diff-node.ts new file mode 100644 index 0000000000..2a7a0b0755 --- /dev/null +++ b/packages/core/src/diff/diff-node.ts @@ -0,0 +1,48 @@ +import { keyKindOf } from '../node-tree/access.js'; +import type { NodeEntry } from '../node-tree/types.js'; +import { escapePointerFragment, isRefWithSiblings, joinPointer } from '../ref-utils.js'; +import type { DiffNode, Pair } from './types.js'; + +// A pair has at least one side. +export function latestOf(node: Pair): NodeEntry { + return (node.revision ?? node.base)!; +} + +export function typeOf(node: DiffNode): string { + return latestOf(node).type.name; +} + +// A field or a list item written as a `$ref` is named after its target: `schema` or `0` says less +// than `Order`. A map entry keeps its own key. +export function nameOf(node: DiffNode): string { + const written = latestOf(node); + const name = + keyKindOf(written) === 'entry' ? written.key : (written.resolved?.key ?? written.key); + return String(name); +} + +export function hasKeysNextToRefs(sides: Pair): boolean { + if (!sides.base?.resolved || !sides.revision?.resolved) return false; + return isRefWithSiblings(sides.base.value) || isRefWithSiblings(sides.revision.value); +} + +// A `$ref` stands for its target, unless both sides are `$ref`s and either has keys next to its +// `$ref` (OpenAPI 3.1): then the written keys are compared here, and the targets one level down. +export function comparedSidesOf(node: DiffNode): Pair { + if (!node.base || !node.revision || hasKeysNextToRefs(node)) return node; + + return { + base: node.base.resolved ?? node.base, + revision: node.revision.resolved ?? node.revision, + }; +} + +export function pointerOf(node: DiffNode): string { + if (!node.parent) return '#/'; + + const written = latestOf(node); + const isRefTarget = written === latestOf(comparedSidesOf(node.parent)).resolved; + const segment = isRefTarget ? '$ref' : escapePointerFragment(String(written.key)); + + return joinPointer(pointerOf(node.parent), segment); +} diff --git a/packages/core/src/diff/diff-tree.ts b/packages/core/src/diff/diff-tree.ts index 650503dbdc..05af33b1e0 100644 --- a/packages/core/src/diff/diff-tree.ts +++ b/packages/core/src/diff/diff-tree.ts @@ -1,109 +1,68 @@ import type { NodeEntry } from '../node-tree/types.js'; -import { escapePointerFragment, joinPointer } from '../ref-utils.js'; -import type { DiffNode, Identities } from './types.js'; - -export function latestOf(node: DiffNode): NodeEntry { - return (node.revision ?? node.base)!; -} - -export function typeOf(node: DiffNode): string { - return latestOf(node).type; -} - -/** - * Matches the two documents up top-down into one tree: the roots meet, and the children of a - * node are matched by the segment their container gives them. Every node of either document gets - * a place, including those under a node that exists on one side only, so a reference site is - * always addressable. - */ -export function buildDiffTree( - base: NodeEntry, - revision: NodeEntry, - identities: Identities -): { root: DiffNode; diffNodeOf: Map } { - const diffNodeOf = new Map(); - - function addNode( - parent: DiffNode | null, - label: string, - sides: Pick - ): DiffNode { - const node: DiffNode = { ...sides, parent, children: [], label }; - parent?.children.push(node); - if (node.base) diffNodeOf.set(node.base, node); - if (node.revision) diffNodeOf.set(node.revision, node); - - const container = typeOf(node); - const baseGroups = groupBySegment(node.base?.children ?? [], container, identities); - const revisionGroups = groupBySegment(node.revision?.children ?? [], container, identities); +import { changesOf } from './changes.js'; +import { comparedSidesOf, hasKeysNextToRefs, pointerOf } from './diff-node.js'; +import { pairChildren } from './pair-children.js'; +import type { Change, DiffNode } from './types.js'; + +/** A node that one side does not have is one change: nothing below it is compared. */ +export function compareTrees(base: NodeEntry, revision: NodeEntry): Change[] { + const changes: Change[] = []; + const compared = new Map(); + + function compare(node: DiffNode): void { + const sides = comparedSidesOf(node); + + if (sides.base && sides.revision) { + // A pair reached again, or through itself, was compared where it was reached first. + const id = `${sides.base.location.absolutePointer}|${sides.revision.location.absolutePointer}`; + const first = compared.get(id); + + if (first) { + first.referencedBy.push(node); + return; + } - for (const [segment, baseNodes] of baseGroups) { - matchSiblings(node, segment, baseNodes, revisionGroups.get(segment) ?? []); + compared.set(id, node); } - for (const [segment, revisionNodes] of revisionGroups) { - if (!baseGroups.has(segment)) { - matchSiblings(node, segment, [], revisionNodes); + changes.push(...changesOf(node)); + + if (!sides.base || !sides.revision) return; + + for (const pair of pairChildren(sides.base.children, sides.revision.children)) { + const child: DiffNode = { ...pair, parent: node, referencedBy: [] }; + + // A renamed map entry, such as a path or a property. + if ( + pair.base && + pair.revision && + typeof pair.base.key === 'string' && + pair.base.key !== pair.revision.key + ) { + changes.push({ + key: pointerOf(child), + node: child, + kind: 'modified', + property: 'key', + base: { location: pair.base.location, value: pair.base.key }, + revision: { location: pair.revision.location, value: pair.revision.key }, + }); } - } - - return node; - } - function matchSiblings( - parent: DiffNode, - segment: string, - baseNodes: NodeEntry[], - revisionNodes: NodeEntry[] - ): void { - const sharedLabel = joinPointer(parent.label, segment); - for (let index = 0; index < Math.max(baseNodes.length, revisionNodes.length); index++) { - const label = index === 0 ? sharedLabel : `${sharedLabel}#${index + 1}`; - const base = baseNodes[index]; - const revision = revisionNodes[index]; - - if (base && revision && base.type !== revision.type) { - // A different kind of node in the same place is a removal and an addition. - addNode(parent, label, { base }); - addNode(parent, label, { revision }); - } else { - addNode(parent, label, { base, revision }); - } + compare(child); } - } - return { root: addNode(null, '#/', { base, revision }), diffNodeOf }; -} - -// A child the specification does not identify keeps its key. A list item has no name of its -// own: an inline one is known by its position, a `$ref` by the node it points at, so reordering -// references is not a change. -function segmentOf(child: NodeEntry, container: string, identities: Identities): string { - const identity = identities[container]?.(child); - if (identity) return identity; - if (typeof child.key === 'number' && child.target) { - return `{${escapePointerFragment(child.target.location.pointer)}}`; - } - return escapePointerFragment(String(child.key)); -} - -function groupBySegment( - nodes: NodeEntry[], - container: string, - identities: Identities -): Map { - const groups = new Map(); - - for (const node of nodes) { - const segment = segmentOf(node, container, identities); - const group = groups.get(segment); - - if (group) { - group.push(node); - } else { - groups.set(segment, [node]); + if (hasKeysNextToRefs(sides)) { + compare({ + base: sides.base.resolved, + revision: sides.revision.resolved, + parent: node, + referencedBy: [], + }); } } - return groups; + compare({ base, revision, parent: null, referencedBy: [] }); + + return changes; } diff --git a/packages/core/src/diff/direction.ts b/packages/core/src/diff/direction.ts index 0ba58639e5..76ffa5a963 100644 --- a/packages/core/src/diff/direction.ts +++ b/packages/core/src/diff/direction.ts @@ -1,75 +1,78 @@ -import type { NodeEntry, Reference } from '../node-tree/types.js'; -import type { DiffNode, Direction, Directions } from './types.js'; +import { enclosing, fieldOf } from '../node-tree/access.js'; +import type { NodeEntry } from '../node-tree/types.js'; +import { comparedSidesOf } from './diff-node.js'; +import type { DiffNode, Direction } from './types.js'; const bothDirections: Direction[] = ['request', 'response']; -/** Both sides' directions, in one order so that equal sets compare equal. */ -export function mergeDirections(left: Direction[], right: Direction[]): Direction[] { - return bothDirections.filter( - (direction) => left.includes(direction) || right.includes(direction) - ); -} +/** Empty for data that travels neither way, such as `info` or a component nothing references. */ +export function directionsOf(node: DiffNode): Direction[] { + const directions = directionsIn(node.revision ? 'revision' : 'base', node, new Set()); -export function opposite(direction: Direction): Direction { - return direction === 'request' ? 'response' : 'request'; + return bothDirections.filter((direction) => directions.includes(direction)); } -/** - * The way the data in a node travels. The node's position says it first; where it says nothing, - * the node takes the directions its referenced ancestors are used in, over both documents, so a - * shared component keeps one direction. - */ -export function resolveDirections( - references: Reference[], - diffNodeOf: Map, - directions: Directions -): (node: NodeEntry) => Direction[] { - const usage = new Map(); +function directionsIn( + side: 'base' | 'revision', + node: DiffNode, + visited: Set +): Direction[] { + // Each place is asked once, which also ends the climb through a component that references itself. + if (visited.has(node)) return []; + visited.add(node); - const directionOf = (node: NodeEntry): Direction[] => { - const position = positionOf(node, directions); - if (position) return [position]; + // Only pairs have children or other places that reach them, so every place climbed has `side`. + const own = ownDirection(comparedSidesOf(node)[side]!) ?? ownDirection(node[side]!); + if (own) return [own]; - let merged: Direction[] = []; - for (let current: NodeEntry | null = node; current; current = current.parent) { - merged = mergeDirections(merged, usage.get(diffNodeOf.get(current)!) ?? []); - } - return merged; - }; + return [node.parent, ...node.referencedBy].flatMap((place) => + place ? directionsIn(side, place, visited) : [] + ); +} - // A site inside a referenced node borrows that node's usage, so each time a usage widens, the - // references made from inside it are taken again. Usage only ever widens, so this ends, and a - // reference cycle simply contributes nothing. - const madeInside = new Map(); - for (const reference of references) { - for (let current: NodeEntry | null = reference.from; current; current = current.parent) { - const node = diffNodeOf.get(current)!; - const made = madeInside.get(node); - if (made) made.push(reference); - else madeInside.set(node, [reference]); +function ownDirection(node: NodeEntry): Direction | undefined { + switch (node.type.name) { + case 'RequestBody': + case 'ParameterList': + return directionAt(node, 'request'); + case 'Responses': + return directionAt(node, 'response'); + // "readOnly" data only comes from the API and "writeOnly" data only goes to it (OpenAPI, + // Schema Object), wherever the schema is used. + case 'Schema': + if (fieldOf(node, 'readOnly') === true) return 'response'; + if (fieldOf(node, 'writeOnly') === true) return 'request'; + return undefined; + // AsyncAPI 3: `receive` means another application produces the message, so its payload is + // judged the way a request body is; `send` means this application produces it. An OpenAPI + // operation has no `action`, so it says nothing. + case 'Operation': + return actionDirection(fieldOf(node, 'action')); + // A reply answers the operation, so it travels back the other way. + case 'OperationReply': { + const direction = actionDirection(fieldOf(enclosing(node, 'Operation'), 'action')); + return direction && opposite(direction); } + default: + return undefined; } +} - const pending = [...references]; - while (pending.length) { - const { from, to } = pending.pop()!; - const target = diffNodeOf.get(to)!; - const known = usage.get(target) ?? []; - const widened = mergeDirections(known, directionOf(from)); - if (widened.length === known.length) continue; - - usage.set(target, widened); - for (const reference of madeInside.get(target) ?? []) pending.push(reference); - } - - return directionOf; +// Outside `components`, every `Parameter` sits under a `ParameterList` and every `Response` +// under `Responses`, so the lists carry the direction. Under a callback or a webhook the API +// sends the request itself, so it is the other way round. +function directionAt(node: NodeEntry, direction: Direction): Direction | undefined { + if (enclosing(node, 'Components')) return undefined; + const sentByApi = enclosing(node, 'CallbacksMap') ?? enclosing(node, 'WebhooksMap'); + return sentByApi ? opposite(direction) : direction; } -function positionOf(node: NodeEntry, directions: Directions): Direction | undefined { - for (let current: NodeEntry | null = node; current; current = current.parent) { - const rule = directions[current.type]; - const direction = typeof rule === 'function' ? rule(current) : rule; - if (direction) return direction; - } +function actionDirection(action: unknown): Direction | undefined { + if (action === 'receive') return 'request'; + if (action === 'send') return 'response'; return undefined; } + +function opposite(direction: Direction): Direction { + return direction === 'request' ? 'response' : 'request'; +} diff --git a/packages/core/src/diff/impact.ts b/packages/core/src/diff/impact.ts index fd363028fc..6cdb058f2b 100644 --- a/packages/core/src/diff/impact.ts +++ b/packages/core/src/diff/impact.ts @@ -1,6 +1,7 @@ -import type { Change, Impact } from './types.js'; +import { typeOf } from './diff-node.js'; +import type { Change, DiffNode, Impact } from './types.js'; -/** Lowest first. An impact is the semver part a change requires to be bumped. */ +/** Lowest first. */ export const impacts = ['patch', 'minor', 'major'] as const; export function impactRank(impact: Impact): number { @@ -16,6 +17,14 @@ export function highestImpact(candidates: Impact[]): Impact | undefined { } // What a change means when no rule spoke: something new is a minor, everything else a patch. -export function defaultImpact(kind: Change['kind']): Impact { - return kind === 'added' ? 'minor' : 'patch'; +// A new component adds nothing to the API until a place uses it, and that place is a change of +// its own. +export function defaultImpact(change: Change): Impact { + return change.kind === 'added' && !isComponentOrContainer(change.node) ? 'minor' : 'patch'; +} + +// `components` itself, a map in it such as `schemas`, or a component in that map. +function isComponentOrContainer(node: DiffNode): boolean { + const map = node.parent; + return [node, map, map?.parent].some((level) => !!level && typeOf(level) === 'Components'); } diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index fda50de18d..3ec49b0024 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -1,26 +1,27 @@ import type { Config } from '../config/index.js'; +import { initDiffRules, initRules } from '../config/rules.js'; import { detectSpec, getMajorSpecVersion } from '../detect-spec.js'; -import { buildNodeTree } from '../node-tree/build.js'; +import { buildNodeTree } from '../node-tree/index.js'; import { getTypes, type SpecVersion } from '../oas-types.js'; -import type { Document } from '../resolve.js'; +import { BaseResolver, resolveDocument, type Document } from '../resolve.js'; import { normalizeTypes } from '../types/index.js'; import { HandledError } from '../utils/error.js'; import { isPlainObject } from '../utils/is-plain-object.js'; -import { collectChanges } from './changes.js'; -import { buildDiffTree, typeOf } from './diff-tree.js'; -import { resolveDirections } from './direction.js'; +import { normalizeVisitors } from '../visitors.js'; +import { walkDocument, type WalkContext } from '../walk.js'; +import { typeOf } from './diff-node.js'; +import { compareTrees } from './diff-tree.js'; import { highestImpact } from './impact.js'; import { judgeChanges } from './judge.js'; -import { isDiffFamily } from './rules/index.js'; -import { diffSpecs } from './specs/index.js'; import type { DiffResult, DiffSummary, Impact, JudgedChange } from './types.js'; -export function diffDocuments(opts: { +export async function diffDocuments(opts: { base: Document; revision: Document; config: Config; -}): DiffResult { - const { base, revision, config } = opts; + externalRefResolver?: BaseResolver; +}): Promise { + const { base, revision, config, externalRefResolver = new BaseResolver(config.resolve) } = opts; const baseVersion = detectSpec(base.parsed); const revisionVersion = detectSpec(revision.parsed); @@ -31,39 +32,19 @@ export function diffDocuments(opts: { throw new HandledError(`Cannot compare ${baseVersion} with ${revisionVersion}.`); } - const collect = (document: Document, specVersion: SpecVersion) => { - return buildNodeTree({ - document, - types: normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config), - specVersion, - }); - }; + const [baseTree, revisionTree] = await Promise.all([ + preparedNodeTree(base, baseVersion, config, externalRefResolver), + preparedNodeTree(revision, revisionVersion, config, externalRefResolver), + ]); - const { identities, directions } = isDiffFamily(family) - ? diffSpecs[family] - : { identities: {}, directions: {} }; + const rules = initDiffRules(config.getDiffRulesForSpecVersion(family), config, revisionVersion); - const collectedBase = collect(base, baseVersion); - const collectedRevision = collect(revision, revisionVersion); - - const references = [...collectedBase.references, ...collectedRevision.references]; - - const { root, diffNodeOf } = buildDiffTree( - collectedBase.root, - collectedRevision.root, - identities + const changes = judgeChanges( + compareTrees(baseTree.root, revisionTree.root), + rules, + revisionVersion ); - const directionOf = resolveDirections(references, diffNodeOf, directions); - - const changes = judgeChanges({ - changes: collectChanges(root), - specVersion: revisionVersion, - ruleSets: config.getDiffRulesForSpecVersion(family), - impactOf: (ruleId) => config.getDiffImpact(ruleId, revisionVersion), - directionOf, - }); - return { files: { base: base.source.absoluteRef, @@ -77,6 +58,54 @@ export function diffDocuments(opts: { summary: countByImpact(changes), bump: requiredBump(changes), changes, + problems: [...baseTree.problems, ...revisionTree.problems], + }; +} + +// Preprocessors change the document first, as they do before lint. +async function preparedNodeTree( + document: Document, + specVersion: SpecVersion, + config: Config, + externalRefResolver: BaseResolver +) { + const rules = config.getRulesForSpecVersion(getMajorSpecVersion(specVersion)); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + + const ctx: WalkContext = { + problems: [], + specVersion, + config, + visitorsData: {}, + }; + + const preprocessors = initRules(rules, config, 'preprocessors', specVersion); + + let resolvedRefMap = await resolveDocument({ + rootDocument: document, + rootType: types.Root, + externalRefResolver, + }); + + if (preprocessors.length > 0) { + // Make additional pass to resolve refs defined in preprocessors. + walkDocument({ + document, + rootType: types.Root, + normalizedVisitors: normalizeVisitors(preprocessors, types), + resolvedRefMap, + ctx, + }); + resolvedRefMap = await resolveDocument({ + rootDocument: document, + rootType: types.Root, + externalRefResolver, + }); + } + + return { + root: buildNodeTree({ document, types, resolvedRefMap, ctx }), + problems: ctx.problems.map((problem) => config.addProblemToIgnore(problem)), }; } diff --git a/packages/core/src/diff/judge.ts b/packages/core/src/diff/judge.ts index c51994bd09..babf8418d5 100644 --- a/packages/core/src/diff/judge.ts +++ b/packages/core/src/diff/judge.ts @@ -1,12 +1,12 @@ -import type { NodeEntry } from '../node-tree/types.js'; +import type { InitializedDiffRule } from '../config/rules.js'; import type { SpecVersion } from '../oas-types.js'; import { displaySide } from './changes.js'; -import { latestOf, typeOf } from './diff-tree.js'; +import { typeOf } from './diff-node.js'; +import { directionsOf } from './direction.js'; import { defaultImpact, highestImpact } from './impact.js'; import type { Change, DiffNode, - DiffRule, DiffRuleContext, DiffVisit, DiffVisitor, @@ -16,7 +16,6 @@ import type { RuleVerdict, } from './types.js'; -/** One handler of an enabled rule, with the node types it is nested under; `any` has none. */ type RuleHandler = { ruleId: string; impact: Impact; @@ -24,32 +23,10 @@ type RuleHandler = { visit: DiffVisit; }; -/** The handlers of every rule the configuration turns on, in rule order. */ -function activeHandlers( - ruleSets: Record[], - impactOf: (ruleId: string) => Impact | 'off' -): RuleHandler[] { - const handlers: RuleHandler[] = []; - - for (const ruleSet of ruleSets) { - for (const [ruleId, rule] of Object.entries(ruleSet)) { - const impact = impactOf(ruleId); - if (impact === 'off') continue; - for (const { typePath, visit } of flattenVisitor(rule(), ruleId)) { - handlers.push({ ruleId, impact, typePath, visit }); - } - } - } - return handlers; -} - // A diff visitor sees whole changes, not a walk, so lint's hooks have nothing to run on. const HOOKS = new Set(['enter', 'leave', 'skip']); -/** - * A rule's visitor is nested the way lint visitors are. Flattened, each handler carries the - * types it is nested under, so a change can be checked against it without walking the visitor. - */ +// Each handler with the types it is nested under, so matching a change needs no walk. function flattenVisitor( visitor: DiffVisitor, ruleId: string, @@ -67,7 +44,7 @@ function flattenVisitor( /** * Lint's nesting: `SchemaProperties › Schema` applies to a schema whose nearest enclosing * `SchemaProperties` or `Schema` is a `SchemaProperties` — a property, not a schema further down - * inside one. The empty path applies to every node. + * inside one. */ export function appliesTo(typePath: string[], node: DiffNode): boolean { let current: DiffNode | undefined = node; @@ -86,30 +63,44 @@ function nearest(node: DiffNode | null, types: string[]): DiffNode | undefined { return undefined; } -export function judgeChanges(opts: { - changes: Change[]; - specVersion: SpecVersion; - ruleSets: Record[]; - impactOf: (ruleId: string) => Impact | 'off'; - directionOf: (node: NodeEntry) => Direction[]; -}): JudgedChange[] { - const { changes, specVersion, ruleSets, impactOf, directionOf } = opts; - const handlers = activeHandlers(ruleSets, impactOf); +export function judgeChanges( + changes: Change[], + rules: InitializedDiffRule[], + specVersion: SpecVersion +): JudgedChange[] { + const handlers: RuleHandler[] = rules.flatMap(({ ruleId, impact, visitor }) => + flattenVisitor(visitor, ruleId).map((handler) => ({ ruleId, impact, ...handler })) + ); - return changes.map((change) => { - const verdicts: RuleVerdict[] = []; - const directions = directionOf(latestOf(change.node)); + return changes.map((change) => judgeChange(change, handlers, specVersion)); +} - for (const { ruleId, impact, typePath, visit } of handlers) { - if (!appliesTo(typePath, change.node)) continue; - const report: DiffRuleContext['report'] = ({ - message, - location = displaySide(change).location, - }) => verdicts.push({ ruleId, impact, message, location }); - visit(change, { report, directions, specVersion }); - } +function judgeChange( + change: Change, + handlers: RuleHandler[], + specVersion: SpecVersion +): JudgedChange { + // Computed once per change, and only when a rule asks. + let directions: Direction[] | undefined; + const getDirections = () => (directions ??= directionsOf(change.node)); + const location = displaySide(change).location; + const verdicts: RuleVerdict[] = []; - const impact = highestImpact(verdicts.map((verdict) => verdict.impact)); - return { ...change, impact: impact ?? defaultImpact(change.kind), verdicts }; - }); + for (const { ruleId, impact, typePath, visit } of handlers) { + if (!appliesTo(typePath, change.node)) continue; + + const report: DiffRuleContext['report'] = (verdict) => { + verdicts.push({ + ruleId, + impact, + message: verdict.message, + location: verdict.location ?? location, + }); + }; + + visit(change, { report, location, getDirections, specVersion }); + } + + const impact = highestImpact(verdicts.map((verdict) => verdict.impact)); + return { ...change, verdicts, impact: impact ?? defaultImpact(change) }; } diff --git a/packages/core/src/diff/pair-children.ts b/packages/core/src/diff/pair-children.ts new file mode 100644 index 0000000000..0b4794d43f --- /dev/null +++ b/packages/core/src/diff/pair-children.ts @@ -0,0 +1,112 @@ +import { keyKindOf, valueOf } from '../node-tree/access.js'; +import type { NodeEntry } from '../node-tree/types.js'; +import { isPlainObject } from '../utils/is-plain-object.js'; +import { levenshteinSimilarity } from '../utils/levenshtein.js'; +import type { Pair } from './types.js'; + +// Git counts a file as renamed when at least half of it is unchanged; the same share pairs a +// renamed key or a changed list item with what it was. +const PAIRING_THRESHOLD = 0.5; + +/** + * Pairs each child with the same one on the other side, or else the most alike of its type. The + * same ones pair first, so a changed child never takes the place of an unchanged one. + */ +export function pairChildren(base: NodeEntry[], revision: NodeEntry[]): Pair[] { + const pairs: Pair[] = []; + const unpaired = new Set(revision); + const changed: NodeEntry[] = []; + + const written = knownAs([...base, ...revision], (child) => child.value); + for (const child of base) { + const same = findSame(child, unpaired, written); + + if (same) { + unpaired.delete(same); + pairs.push({ base: child, revision: same }); + } else { + changed.push(child); + } + } + + // A `$ref` is written as the `$ref`, so two `$ref`s to one place are the same at once; what + // changed is paired by what it stands for, so a `$ref` and inline content can still meet. + const meant = knownAs([...changed, ...unpaired], valueOf); + for (const child of changed) { + const alike = findMostAlike(child, unpaired, meant); + + if (alike) unpaired.delete(alike); + pairs.push({ base: child, revision: alike }); + } + + for (const child of unpaired) pairs.push({ revision: child }); + + return pairs; +} + +function knownAs( + children: NodeEntry[], + contentOf: (child: NodeEntry) => unknown +): Map { + return new Map( + children.map((child) => [ + child, + typeof child.key === 'string' ? child.key : jsonWithSortedKeys(contentOf(child)), + ]) + ); +} + +function findSame( + child: NodeEntry, + candidates: Set, + known: Map +): NodeEntry | undefined { + for (const candidate of candidates) { + if (candidate.type.name === child.type.name && known.get(candidate) === known.get(child)) { + return candidate; + } + } + + return undefined; +} + +// The first of the most alike wins a tie, so the document order decides. +function findMostAlike( + child: NodeEntry, + candidates: Set, + known: Map +): NodeEntry | undefined { + let mostAlike: NodeEntry | undefined; + let highestScore = 0; + + for (const candidate of candidates) { + if (candidate.type.name !== child.type.name) continue; + + const score = similarity(child, candidate, known); + + if (score >= PAIRING_THRESHOLD && score > highestScore) { + mostAlike = candidate; + highestScore = score; + } + } + + return mostAlike; +} + +// A field pairs only with the same field. +function similarity(child: NodeEntry, other: NodeEntry, known: Map): number { + if (keyKindOf(child) === 'field') return child.key === other.key ? 1 : 0; + + return levenshteinSimilarity(known.get(child)!, known.get(other)!); +} + +function jsonWithSortedKeys(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(jsonWithSortedKeys).join(',')}]`; + if (!isPlainObject(value)) return JSON.stringify(value) ?? 'null'; + + const fields = Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${jsonWithSortedKeys(value[key])}`); + + return `{${fields.join(',')}}`; +} diff --git a/packages/core/src/diff/rules/__tests__/additional-properties-changed.test.ts b/packages/core/src/diff/rules/__tests__/additional-properties-changed.test.ts deleted file mode 100644 index 4f21dd1cb7..0000000000 --- a/packages/core/src/diff/rules/__tests__/additional-properties-changed.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { outdent } from 'outdent'; - -import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; -import { createConfig } from '../../../config/index.js'; -import { makeDocumentFromString } from '../../../resolve.js'; -import { diffDocuments } from '../../index.js'; - -const cafe = (order: string) => outdent` - openapi: 3.1.0 - info: { title: Cafe, version: 1.0.0 } - paths: - /orders: - post: - requestBody: - content: - application/json: - schema: ${order} - responses: - '201': - description: Created - content: - application/json: - schema: ${order} -`; - -describe('additional-properties-changed', () => { - it('should report closing an open request object, not an open response object', async () => { - const result = diffDocuments({ - base: makeDocumentFromString( - cafe('{ type: object, additionalProperties: true }'), - 'base.yaml' - ), - revision: makeDocumentFromString( - cafe('{ type: object, additionalProperties: false }'), - 'revision.yaml' - ), - config: await createConfig({ diff: { 'additional-properties-changed': 'major' } }), - }); - - expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` - [ - { - "base": { - "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", - "value": true, - }, - "impact": "major", - "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", - "kind": "modified", - "property": "additionalProperties", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", - "value": false, - }, - "verdicts": [ - { - "impact": "major", - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", - "message": "\`additionalProperties\` changed from 'true' to 'false'.", - "ruleId": "additional-properties-changed", - }, - ], - }, - { - "base": { - "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", - "value": true, - }, - "impact": "patch", - "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", - "kind": "modified", - "property": "additionalProperties", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", - "value": false, - }, - "verdicts": [], - }, - ] - `); - }); - - it('should report opening a closed response object, not a closed request object', async () => { - const result = diffDocuments({ - base: makeDocumentFromString( - cafe('{ type: object, additionalProperties: false }'), - 'base.yaml' - ), - revision: makeDocumentFromString( - cafe('{ type: object, additionalProperties: true }'), - 'revision.yaml' - ), - config: await createConfig({ diff: { 'additional-properties-changed': 'major' } }), - }); - - expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` - [ - { - "base": { - "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", - "value": false, - }, - "impact": "patch", - "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", - "kind": "modified", - "property": "additionalProperties", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", - "value": true, - }, - "verdicts": [], - }, - { - "base": { - "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", - "value": false, - }, - "impact": "major", - "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", - "kind": "modified", - "property": "additionalProperties", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", - "value": true, - }, - "verdicts": [ - { - "impact": "major", - "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", - "message": "\`additionalProperties\` changed from 'false' to 'true'.", - "ruleId": "additional-properties-changed", - }, - ], - }, - ] - `); - }); -}); diff --git a/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts b/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts index ce9a905bb8..b057c5343a 100644 --- a/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts @@ -14,7 +14,7 @@ const cafe = (address: string) => outdent` describe('channel-address-changed', () => { it('should report a channel that moved to another address', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe('orders.placed'), 'base.yaml'), revision: makeDocumentFromString(cafe('kitchen.orders.placed'), 'revision.yaml'), config: await createConfig({ diff: { 'channel-address-changed': 'major' } }), @@ -39,7 +39,7 @@ describe('channel-address-changed', () => { { "impact": "major", "location": "revision.yaml#/channels/orderPlaced/address", - "message": "Channel \`orderPlaced\` address changed from 'orders.placed' to 'kitchen.orders.placed'.", + "message": "\`address\` of channel \`orderPlaced\` changed from 'orders.placed' to 'kitchen.orders.placed'.", "ruleId": "channel-address-changed", }, ], @@ -47,4 +47,113 @@ describe('channel-address-changed', () => { ] `); }); + + it('should report a channel that got an address', async () => { + const orders = (channel: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: ${channel} + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(orders('{ title: Orders }'), 'base.yaml'), + revision: makeDocumentFromString( + orders('{ address: orders, title: Orders }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'channel-address-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orders", + "value": undefined, + }, + "impact": "major", + "key": "#/channels/orders", + "kind": "modified", + "property": "address", + "revision": { + "location": "revision.yaml#/channels/orders/address", + "value": "orders", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/channels/orders/address", + "message": "\`address\` of channel \`orders\` was set to 'orders'.", + "ruleId": "channel-address-changed", + }, + ], + }, + ] + `); + }); + + it('should report a channel that lost its address', async () => { + const orders = (channel: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: ${channel} + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(orders('{ address: orders, title: Orders }'), 'base.yaml'), + revision: makeDocumentFromString(orders('{ title: Orders }'), 'revision.yaml'), + config: await createConfig({ diff: { 'channel-address-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orders/address", + "value": "orders", + }, + "impact": "major", + "key": "#/channels/orders", + "kind": "modified", + "property": "address", + "revision": { + "location": "revision.yaml#/channels/orders", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/channels/orders", + "message": "\`address\` of channel \`orders\` was removed.", + "ruleId": "channel-address-changed", + }, + ], + }, + ] + `); + }); + + it('should name the channel an operation points to by the channel, not by the operation field', async () => { + const kitchen = (channel: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: { address: orders } + ordersV2: { address: orders.v2 } + operations: + placeOrder: { action: send, channel: { $ref: '#/channels/${channel}' } } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(kitchen('orders'), 'base.yaml'), + revision: makeDocumentFromString(kitchen('ordersV2'), 'revision.yaml'), + config: await createConfig({ diff: { 'channel-address-changed': 'major' } }), + }); + + expect(result.changes.flatMap((change) => change.verdicts)).toMatchObject([ + { message: "`address` of channel `ordersV2` changed from 'orders' to 'orders.v2'." }, + ]); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/channel-removed.test.ts b/packages/core/src/diff/rules/__tests__/channel-removed.test.ts index 0936bd9665..22c64a005d 100644 --- a/packages/core/src/diff/rules/__tests__/channel-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/channel-removed.test.ts @@ -17,7 +17,7 @@ const ready = 'orderReady: { address: orders.ready }'; // A channel is where messages travel, so both sides of it break together. describe('channel-removed', () => { it('should report a channel that is gone', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${placed}, ${ready} }`), 'base.yaml'), revision: makeDocumentFromString(cafe(`{ ${placed} }`), 'revision.yaml'), config: await createConfig({ diff: { 'channel-removed': 'major' } }), @@ -49,7 +49,7 @@ describe('channel-removed', () => { }); it('should report every channel leaving with the whole map', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${placed} }`), 'base.yaml'), revision: makeDocumentFromString(cafe(), 'revision.yaml'), config: await createConfig({ diff: { 'channel-removed': 'major' } }), @@ -81,4 +81,37 @@ describe('channel-removed', () => { ] `); }); + + it('should not report the components channels map', async () => { + const withComponents = (components: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + components: ${components} + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(withComponents(`{ channels: { ${placed} } }`), 'base.yaml'), + revision: makeDocumentFromString(withComponents('{}'), 'revision.yaml'), + config: await createConfig({ diff: { 'channel-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/channels", + "value": { + "orderPlaced": { + "address": "orders.placed", + }, + }, + }, + "impact": "patch", + "key": "#/components/channels", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts b/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts index 5532169143..15f1545f42 100644 --- a/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts +++ b/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts @@ -25,7 +25,7 @@ const cafe = (status: string) => outdent` describe('enum-values-added', () => { it('should report the values a response may now send, not the ones a request now accepts', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe('{ type: string, enum: [placed, ready] }'), 'base.yaml'), revision: makeDocumentFromString( cafe('{ type: string, enum: [placed, preparing, ready] }'), diff --git a/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts b/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts index eade59a9d5..d3a517adac 100644 --- a/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts @@ -25,7 +25,7 @@ const cafe = (status: string) => outdent` describe('enum-values-removed', () => { it('should report the values a request no longer accepts, not the ones a response stops sending', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe('{ type: string, enum: [placed, preparing, ready] }'), 'base.yaml' @@ -93,4 +93,79 @@ describe('enum-values-removed', () => { ] `); }); + + it('should not report a schema used only in a response, even where a base-side writeOnly property loses values', async () => { + const cafeWithNote = (note: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders/{orderId}: + get: + parameters: + - { name: orderId, in: path, required: true, schema: { type: string } } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + components: + schemas: + Order: + type: object + properties: + note: ${note} + `; + const result = await diffDocuments({ + base: makeDocumentFromString( + cafeWithNote('{ type: string, writeOnly: true, enum: [a, b] }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafeWithNote('{ type: string, enum: [a] }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'enum-values-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/schemas/Order/properties/note/writeOnly", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders~1{orderId}/get/responses/200/content/application~1json/schema/properties/note", + "kind": "modified", + "property": "writeOnly", + "revision": { + "location": "revision.yaml#/components/schemas/Order/properties/note", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/components/schemas/Order/properties/note/enum", + "value": [ + "a", + "b", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders~1{orderId}/get/responses/200/content/application~1json/schema/properties/note", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/components/schemas/Order/properties/note/enum", + "value": [ + "a", + ], + }, + "verdicts": [], + }, + ] + `); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts b/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts index 8468209e5c..fb7ad8d6a9 100644 --- a/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts @@ -22,7 +22,7 @@ const csv = 'text/csv: { schema: { type: string } }'; describe('media-type-removed', () => { it('should report a media type that is gone', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${json}, ${csv} }`), 'base.yaml'), revision: makeDocumentFromString(cafe(`{ ${json} }`), 'revision.yaml'), config: await createConfig({ diff: { 'media-type-removed': 'major' } }), @@ -56,7 +56,7 @@ describe('media-type-removed', () => { }); it('should report every media type leaving with the whole content map', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${json} }`), 'base.yaml'), revision: makeDocumentFromString(cafe(), 'revision.yaml'), config: await createConfig({ diff: { 'media-type-removed': 'major' } }), @@ -90,4 +90,95 @@ describe('media-type-removed', () => { ] `); }); + + it('should report a media type replaced by another', async () => { + const xml = 'application/xml: { schema: { type: array } }'; + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${json} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${xml} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'media-type-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/responses/200/content/application~1json", + "value": "application/json", + }, + "impact": "major", + "key": "#/paths/~1menu/get/responses/200/content/application~1xml", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/responses/200/content/application~1xml", + "value": "application/xml", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/responses/200/content/application~1xml", + "message": "Media type \`application/json\` became \`application/xml\`.", + "ruleId": "media-type-removed", + }, + ], + }, + ] + `); + }); + + it('should not report a media type of a component response nothing references', async () => { + const withComponent = (content: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: {} + components: + responses: + Unused: + description: OK + content: ${content} + `; + const plain = 'text/plain: { schema: { type: string } }'; + const xml = 'application/xml: { schema: { type: array } }'; + + const result = await diffDocuments({ + base: makeDocumentFromString(withComponent(`{ ${json}, ${plain} }`), 'base.yaml'), + revision: makeDocumentFromString(withComponent(`{ ${xml} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'media-type-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/responses/Unused/content/application~1json", + "value": "application/json", + }, + "impact": "patch", + "key": "#/components/responses/Unused/content/application~1xml", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/components/responses/Unused/content/application~1xml", + "value": "application/xml", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/components/responses/Unused/content/text~1plain", + "value": { + "schema": { + "type": "string", + }, + }, + }, + "impact": "patch", + "key": "#/components/responses/Unused/content/text~1plain", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts b/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts index 1244bc6c76..ced0be0211 100644 --- a/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts @@ -5,19 +5,19 @@ import { createConfig } from '../../../config/index.js'; import { makeDocumentFromString } from '../../../resolve.js'; import { diffDocuments } from '../../index.js'; -const cafe = (contentType: string) => outdent` +const cafe = (contentType?: string) => outdent` asyncapi: 3.0.0 info: { title: Cafe kitchen, version: 1.0.0 } channels: orders: address: orders messages: - orderPlaced: { contentType: ${contentType}, payload: { type: object } } + orderPlaced: { ${contentType === undefined ? '' : `contentType: ${contentType}, `}payload: { type: object } } `; describe('message-content-type-changed', () => { it('should report a message encoded another way', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe('application/json'), 'base.yaml'), revision: makeDocumentFromString(cafe('application/avro'), 'revision.yaml'), config: await createConfig({ diff: { 'message-content-type-changed': 'major' } }), @@ -42,7 +42,73 @@ describe('message-content-type-changed', () => { { "impact": "major", "location": "revision.yaml#/channels/orders/messages/orderPlaced/contentType", - "message": "Message \`orderPlaced\` content type changed from 'application/json' to 'application/avro'.", + "message": "\`contentType\` of message \`orderPlaced\` changed from 'application/json' to 'application/avro'.", + "ruleId": "message-content-type-changed", + }, + ], + }, + ] + `); + }); + + it('should report a message that gained or lost its content type', async () => { + const gained = await diffDocuments({ + base: makeDocumentFromString(cafe(), 'base.yaml'), + revision: makeDocumentFromString(cafe('application/json'), 'revision.yaml'), + config: await createConfig({ diff: { 'message-content-type-changed': 'major' } }), + }); + const lost = await diffDocuments({ + base: makeDocumentFromString(cafe('application/json'), 'base.yaml'), + revision: makeDocumentFromString(cafe(), 'revision.yaml'), + config: await createConfig({ diff: { 'message-content-type-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(gained.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orders/messages/orderPlaced", + "value": undefined, + }, + "impact": "major", + "key": "#/channels/orders/messages/orderPlaced", + "kind": "modified", + "property": "contentType", + "revision": { + "location": "revision.yaml#/channels/orders/messages/orderPlaced/contentType", + "value": "application/json", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/channels/orders/messages/orderPlaced/contentType", + "message": "\`contentType\` of message \`orderPlaced\` was set to 'application/json'.", + "ruleId": "message-content-type-changed", + }, + ], + }, + ] + `); + expect(replaceSourceWithRefInChanges(lost.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orders/messages/orderPlaced/contentType", + "value": "application/json", + }, + "impact": "major", + "key": "#/channels/orders/messages/orderPlaced", + "kind": "modified", + "property": "contentType", + "revision": { + "location": "revision.yaml#/channels/orders/messages/orderPlaced", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/channels/orders/messages/orderPlaced", + "message": "\`contentType\` of message \`orderPlaced\` was removed.", "ruleId": "message-content-type-changed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/message-removed.test.ts b/packages/core/src/diff/rules/__tests__/message-removed.test.ts index 20c3d7dac1..909b3ec369 100644 --- a/packages/core/src/diff/rules/__tests__/message-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/message-removed.test.ts @@ -19,7 +19,7 @@ const cancelled = 'orderCancelled: { payload: { type: object } }'; describe('message-removed', () => { it('should report a message that is gone from its channel', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${placed}, ${cancelled} }`), 'base.yaml'), revision: makeDocumentFromString(cafe(`{ ${placed} }`), 'revision.yaml'), config: await createConfig({ diff: { 'message-removed': 'major' } }), @@ -53,7 +53,7 @@ describe('message-removed', () => { }); it('should report every message of a channel leaving with the whole map', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${placed} }`), 'base.yaml'), revision: makeDocumentFromString(cafe(), 'revision.yaml'), config: await createConfig({ diff: { 'message-removed': 'major' } }), @@ -87,4 +87,50 @@ describe('message-removed', () => { ] `); }); + + it('should not report a components messages map, which no channel is', async () => { + const withComponents = (components: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: + address: orders + messages: + ${placed} + components: ${components} + `; + + const result = await diffDocuments({ + base: makeDocumentFromString( + withComponents(`{ messages: { ${cancelled} }, schemas: { Order: { type: object } } }`), + 'base.yaml' + ), + revision: makeDocumentFromString( + withComponents('{ schemas: { Order: { type: object } } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'message-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/messages", + "value": { + "orderCancelled": { + "payload": { + "type": "object", + }, + }, + }, + }, + "impact": "patch", + "key": "#/components/messages", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/numeric-range-changed.test.ts b/packages/core/src/diff/rules/__tests__/numeric-range-changed.test.ts deleted file mode 100644 index 4169d73b68..0000000000 --- a/packages/core/src/diff/rules/__tests__/numeric-range-changed.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { outdent } from 'outdent'; - -import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; -import { createConfig } from '../../../config/index.js'; -import { makeDocumentFromString } from '../../../resolve.js'; -import { diffDocuments } from '../../index.js'; - -const cafe = (quantity: string) => outdent` - openapi: 3.1.0 - info: { title: Cafe, version: 1.0.0 } - paths: - /orders: - post: - requestBody: - content: - application/json: - schema: ${quantity} - responses: - '201': - description: Created - content: - application/json: - schema: ${quantity} -`; - -describe('numeric-range-changed', () => { - it('should report a bound that makes a request accept less, not one that lets a response send less', async () => { - const result = diffDocuments({ - base: makeDocumentFromString(cafe('{ type: integer, maximum: 20 }'), 'base.yaml'), - revision: makeDocumentFromString(cafe('{ type: integer, maximum: 10 }'), 'revision.yaml'), - config: await createConfig({ diff: { 'numeric-range-changed': 'major' } }), - }); - - expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` - [ - { - "base": { - "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum", - "value": 20, - }, - "impact": "major", - "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", - "kind": "modified", - "property": "maximum", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum", - "value": 10, - }, - "verdicts": [ - { - "impact": "major", - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum", - "message": "Upper bound changed from '<= 20' to '<= 10'.", - "ruleId": "numeric-range-changed", - }, - ], - }, - { - "base": { - "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maximum", - "value": 20, - }, - "impact": "patch", - "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", - "kind": "modified", - "property": "maximum", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maximum", - "value": 10, - }, - "verdicts": [], - }, - ] - `); - }); - - it('should report a bound that lets a response send more, not one that makes a request accept more', async () => { - const result = diffDocuments({ - base: makeDocumentFromString(cafe('{ type: integer, minimum: 1 }'), 'base.yaml'), - revision: makeDocumentFromString(cafe('{ type: integer, minimum: 0 }'), 'revision.yaml'), - config: await createConfig({ diff: { 'numeric-range-changed': 'major' } }), - }); - - expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` - [ - { - "base": { - "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", - "value": 1, - }, - "impact": "patch", - "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", - "kind": "modified", - "property": "minimum", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", - "value": 0, - }, - "verdicts": [], - }, - { - "base": { - "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", - "value": 1, - }, - "impact": "major", - "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", - "kind": "modified", - "property": "minimum", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", - "value": 0, - }, - "verdicts": [ - { - "impact": "major", - "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", - "message": "Lower bound changed from '>= 1' to '>= 0'.", - "ruleId": "numeric-range-changed", - }, - ], - }, - ] - `); - }); - - it('should not report an OpenAPI 3.0 bound written the OpenAPI 3.1 way', async () => { - const result = diffDocuments({ - base: makeDocumentFromString( - cafe('{ type: number, minimum: 0, exclusiveMinimum: true }').replace('3.1.0', '3.0.3'), - 'base.yaml' - ), - revision: makeDocumentFromString( - cafe('{ type: number, exclusiveMinimum: 0 }'), - 'revision.yaml' - ), - config: await createConfig({ diff: { 'numeric-range-changed': 'major' } }), - }); - - expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` - [ - { - "base": { - "location": "base.yaml#/openapi", - "value": "3.0.3", - }, - "impact": "patch", - "key": "#/", - "kind": "modified", - "property": "openapi", - "revision": { - "location": "revision.yaml#/openapi", - "value": "3.1.0", - }, - "verdicts": [], - }, - { - "base": { - "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", - "value": 0, - }, - "impact": "patch", - "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", - "kind": "modified", - "property": "minimum", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", - "value": undefined, - }, - "verdicts": [], - }, - { - "base": { - "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/exclusiveMinimum", - "value": true, - }, - "impact": "patch", - "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", - "kind": "modified", - "property": "exclusiveMinimum", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/exclusiveMinimum", - "value": 0, - }, - "verdicts": [], - }, - { - "base": { - "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", - "value": 0, - }, - "impact": "patch", - "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", - "kind": "modified", - "property": "minimum", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", - "value": undefined, - }, - "verdicts": [], - }, - { - "base": { - "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/exclusiveMinimum", - "value": true, - }, - "impact": "patch", - "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", - "kind": "modified", - "property": "exclusiveMinimum", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/exclusiveMinimum", - "value": 0, - }, - "verdicts": [], - }, - ] - `); - }); - - it('should report a bound that became exclusive in a request', async () => { - const result = diffDocuments({ - base: makeDocumentFromString(cafe('{ type: number, minimum: 0 }'), 'base.yaml'), - revision: makeDocumentFromString( - cafe('{ type: number, exclusiveMinimum: 0 }'), - 'revision.yaml' - ), - config: await createConfig({ diff: { 'numeric-range-changed': 'major' } }), - }); - - expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` - [ - { - "base": { - "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", - "value": 0, - }, - "impact": "major", - "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", - "kind": "modified", - "property": "minimum", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", - "value": undefined, - }, - "verdicts": [ - { - "impact": "major", - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", - "message": "Lower bound changed from '>= 0' to '> 0'.", - "ruleId": "numeric-range-changed", - }, - ], - }, - { - "base": { - "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", - "value": undefined, - }, - "impact": "patch", - "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", - "kind": "modified", - "property": "exclusiveMinimum", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/exclusiveMinimum", - "value": 0, - }, - "verdicts": [], - }, - { - "base": { - "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", - "value": 0, - }, - "impact": "patch", - "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", - "kind": "modified", - "property": "minimum", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", - "value": undefined, - }, - "verdicts": [], - }, - { - "base": { - "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", - "value": undefined, - }, - "impact": "patch", - "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", - "kind": "modified", - "property": "exclusiveMinimum", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/exclusiveMinimum", - "value": 0, - }, - "verdicts": [], - }, - ] - `); - }); -}); diff --git a/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts b/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts index c6cba12ebe..506dcb9466 100644 --- a/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts @@ -18,7 +18,7 @@ const cafe = (action: string) => outdent` describe('operation-action-changed', () => { it('should report an operation that now sends what it received', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe('receive'), 'base.yaml'), revision: makeDocumentFromString(cafe('send'), 'revision.yaml'), config: await createConfig({ diff: { 'operation-action-changed': 'major' } }), @@ -43,7 +43,7 @@ describe('operation-action-changed', () => { { "impact": "major", "location": "revision.yaml#/operations/onOrderPlaced/action", - "message": "Operation \`onOrderPlaced\` action changed from 'receive' to 'send'.", + "message": "\`action\` of operation \`onOrderPlaced\` changed from 'receive' to 'send'.", "ruleId": "operation-action-changed", }, ], diff --git a/packages/core/src/diff/rules/__tests__/operation-id-changed.test.ts b/packages/core/src/diff/rules/__tests__/operation-id-changed.test.ts new file mode 100644 index 0000000000..91b097af91 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/operation-id-changed.test.ts @@ -0,0 +1,184 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (operationId?: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + ${operationId === undefined ? '' : `operationId: ${operationId}`} + responses: { 200: { description: OK } } +`; + +const diffOperationIds = async (base?: string, revision?: string) => + diffDocuments({ + base: makeDocumentFromString(cafe(base), 'base.yaml'), + revision: makeDocumentFromString(cafe(revision), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-id-changed': 'major' } }), + }); + +describe('operation-id-changed', () => { + it('should report an operationId that changed or was removed, not one that was added', async () => { + const changed = await diffOperationIds('listMenu', 'getMenu'); + + expect(replaceSourceWithRefInChanges(changed.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/operationId", + "value": "listMenu", + }, + "impact": "major", + "key": "#/paths/~1menu/get", + "kind": "modified", + "property": "operationId", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/operationId", + "value": "getMenu", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/operationId", + "message": "\`operationId\` of operation \`GET /menu\` changed from 'listMenu' to 'getMenu'.", + "ruleId": "operation-id-changed", + }, + ], + }, + ] + `); + + const removed = await diffOperationIds('listMenu'); + + expect(replaceSourceWithRefInChanges(removed.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/operationId", + "value": "listMenu", + }, + "impact": "major", + "key": "#/paths/~1menu/get", + "kind": "modified", + "property": "operationId", + "revision": { + "location": "revision.yaml#/paths/~1menu/get", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get", + "message": "\`operationId\` of operation \`GET /menu\` was removed.", + "ruleId": "operation-id-changed", + }, + ], + }, + ] + `); + + const added = await diffOperationIds(undefined, 'listMenu'); + + expect(replaceSourceWithRefInChanges(added.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1menu/get", + "kind": "modified", + "property": "operationId", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/operationId", + "value": "listMenu", + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report an operationId changed in a webhook or a callback', async () => { + const withOperationIds = (webhookId: string, callbackId: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + callbacks: + ready: + "{$request.body#/url}": + post: + operationId: ${callbackId} + responses: { 200: { description: OK } } + responses: { 200: { description: OK } } + webhooks: + orderReady: + post: + operationId: ${webhookId} + responses: { 200: { description: OK } } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(withOperationIds('onReady', 'notifyReady'), 'base.yaml'), + revision: makeDocumentFromString(withOperationIds('readyHook', 'readyCall'), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-id-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post/operationId", + "value": "notifyReady", + }, + "impact": "major", + "key": "#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post", + "kind": "modified", + "property": "operationId", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post/operationId", + "value": "readyCall", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post/operationId", + "message": "\`operationId\` of operation \`POST {$request.body#/url}\` changed from 'notifyReady' to 'readyCall'.", + "ruleId": "operation-id-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/webhooks/orderReady/post/operationId", + "value": "onReady", + }, + "impact": "major", + "key": "#/webhooks/orderReady/post", + "kind": "modified", + "property": "operationId", + "revision": { + "location": "revision.yaml#/webhooks/orderReady/post/operationId", + "value": "readyHook", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/webhooks/orderReady/post/operationId", + "message": "\`operationId\` of operation \`POST orderReady\` changed from 'onReady' to 'readyHook'.", + "ruleId": "operation-id-changed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/operation-removed.test.ts b/packages/core/src/diff/rules/__tests__/operation-removed.test.ts index 86be25aa19..efb60dd8f7 100644 --- a/packages/core/src/diff/rules/__tests__/operation-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/operation-removed.test.ts @@ -17,7 +17,7 @@ const cancel = 'delete: { responses: { 204: { description: Cancelled } } }'; describe('operation-removed', () => { it('should report an operation that is gone', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${get}, ${cancel} }`), 'base.yaml'), revision: makeDocumentFromString(cafe(`{ ${get} }`), 'revision.yaml'), config: await createConfig({ diff: { 'operation-removed': 'major' } }), @@ -37,7 +37,7 @@ describe('operation-removed', () => { }, }, "impact": "major", - "key": "#/paths/~1orders~1{0}/delete", + "key": "#/paths/~1orders~1{orderId}/delete", "kind": "removed", "verdicts": [ { @@ -51,4 +51,220 @@ describe('operation-removed', () => { ] `); }); + + it('should name an AsyncAPI operation by its key', async () => { + const kitchen = (operations: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: { address: orders } + operations: ${operations} + `; + const receive = `onOrderPlaced: { action: receive, channel: { $ref: '#/channels/orders' } }`; + const send = `sendOrderReady: { action: send, channel: { $ref: '#/channels/orders' } }`; + + const result = await diffDocuments({ + base: makeDocumentFromString(kitchen(`{ ${receive}, ${send} }`), 'base.yaml'), + revision: makeDocumentFromString(kitchen(`{ ${receive} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/operations/sendOrderReady", + "value": { + "action": "send", + "channel": { + "$ref": "#/channels/orders", + }, + }, + }, + "impact": "major", + "key": "#/operations/sendOrderReady", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/operations/sendOrderReady", + "message": "Operation \`sendOrderReady\` was removed.", + "ruleId": "operation-removed", + }, + ], + }, + ] + `); + }); + + it('should report an operation removed from a callback', async () => { + const withCallback = (operations: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + callbacks: { ready: { "{$request.body#/url}": { ${operations} } } } + responses: { 200: { description: OK } } + `; + const post = 'post: { responses: { 200: { description: OK } } }'; + const put = 'put: { responses: { 200: { description: OK } } }'; + + const result = await diffDocuments({ + base: makeDocumentFromString(withCallback(`${post}, ${put}`), 'base.yaml'), + revision: makeDocumentFromString(withCallback(put), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post", + "value": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post", + "message": "Operation \`POST {$request.body#/url}\` was removed.", + "ruleId": "operation-removed", + }, + ], + }, + ] + `); + }); + + it('should report an operation removed from a webhook', async () => { + const withWebhook = (operations: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: {} + webhooks: + orderReady: { ${operations} } + `; + const post = 'post: { responses: { 200: { description: OK } } }'; + const put = 'put: { responses: { 200: { description: OK } } }'; + + const result = await diffDocuments({ + base: makeDocumentFromString(withWebhook(`${post}, ${put}`), 'base.yaml'), + revision: makeDocumentFromString(withWebhook(put), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/webhooks/orderReady/post", + "value": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + "impact": "major", + "key": "#/webhooks/orderReady/post", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/webhooks/orderReady/post", + "message": "Operation \`POST orderReady\` was removed.", + "ruleId": "operation-removed", + }, + ], + }, + ] + `); + }); + + it('should report an AsyncAPI document losing all its operations, not its components operations', async () => { + const kitchen = (operations: string, components: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: { address: orders } + ${operations} + ${components} + `; + const receive = `onOrderPlaced: { action: receive, channel: { $ref: '#/channels/orders' } }`; + const operations = `operations: { ${receive} }`; + const components = `components: { operations: { ${receive} } }`; + + const document = await diffDocuments({ + base: makeDocumentFromString(kitchen(operations, ''), 'base.yaml'), + revision: makeDocumentFromString(kitchen('', ''), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(document.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/operations", + "value": { + "onOrderPlaced": { + "action": "receive", + "channel": { + "$ref": "#/channels/orders", + }, + }, + }, + }, + "impact": "major", + "key": "#/operations", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/operations", + "message": "All operations were removed.", + "ruleId": "operation-removed", + }, + ], + }, + ] + `); + + const unused = await diffDocuments({ + base: makeDocumentFromString(kitchen('', components), 'base.yaml'), + revision: makeDocumentFromString(kitchen('', 'components: {}'), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(unused.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/operations", + "value": { + "onOrderPlaced": { + "action": "receive", + "channel": { + "$ref": "#/channels/orders", + }, + }, + }, + }, + "impact": "patch", + "key": "#/components/operations", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts b/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts index 68de9e2bab..117b983895 100644 --- a/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts +++ b/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts @@ -24,7 +24,7 @@ const search = '{ name: search, in: query, schema: { type: string } }'; describe('parameter-became-required', () => { it('should report an optional parameter that became required, not one that became optional', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe( '[{ name: limit, in: query, schema: { type: integer } }, { name: search, in: query, required: true }]' @@ -48,7 +48,7 @@ describe('parameter-became-required', () => { "value": undefined, }, "impact": "major", - "key": "#/paths/~1menu/get/parameters/{query:limit}", + "key": "#/paths/~1menu/get/parameters/0", "kind": "modified", "property": "required", "revision": { @@ -70,7 +70,7 @@ describe('parameter-became-required', () => { "value": true, }, "impact": "patch", - "key": "#/paths/~1menu/get/parameters/{query:search}", + "key": "#/paths/~1menu/get/parameters/1", "kind": "modified", "property": "required", "revision": { @@ -84,7 +84,7 @@ describe('parameter-became-required', () => { }); it('should report a new required parameter, not a new optional one', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`[${search}]`), 'base.yaml'), revision: makeDocumentFromString( cafe( @@ -99,7 +99,7 @@ describe('parameter-became-required', () => { [ { "impact": "minor", - "key": "#/paths/~1menu/get/parameters/{query:limit}", + "key": "#/paths/~1menu/get/parameters/1", "kind": "added", "revision": { "location": "revision.yaml#/paths/~1menu/get/parameters/1", @@ -115,7 +115,7 @@ describe('parameter-became-required', () => { }, { "impact": "major", - "key": "#/paths/~1menu/get/parameters/{header:locale}", + "key": "#/paths/~1menu/get/parameters/2", "kind": "added", "revision": { "location": "revision.yaml#/paths/~1menu/get/parameters/2", @@ -131,7 +131,7 @@ describe('parameter-became-required', () => { "verdicts": [ { "impact": "major", - "location": "revision.yaml#/paths/~1menu/get/parameters/2", + "location": "revision.yaml#/paths/~1menu/get/parameters/2/required", "message": "Required \`locale\` header parameter was added.", "ruleId": "parameter-became-required", }, @@ -141,8 +141,95 @@ describe('parameter-became-required', () => { `); }); + it('should report each required parameter of a new parameters list at its own required field', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + responses: + '200': { description: OK } + components: + parameters: + C: { name: c, in: query, required: true, schema: { type: string } } + `, + 'base.yaml' + ), + revision: makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + parameters: + - { name: a, in: query, required: true, schema: { type: string } } + - { name: b, in: query, schema: { type: string } } + - { $ref: '#/components/parameters/C' } + responses: + '200': { description: OK } + components: + parameters: + C: { name: c, in: query, required: true, schema: { type: string } } + `, + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-became-required': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1menu/get/parameters", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters", + "value": [ + { + "in": "query", + "name": "a", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "in": "query", + "name": "b", + "schema": { + "type": "string", + }, + }, + { + "$ref": "#/components/parameters/C", + }, + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/0/required", + "message": "Required \`a\` query parameter was added.", + "ruleId": "parameter-became-required", + }, + { + "impact": "major", + "location": "revision.yaml#/components/parameters/C/required", + "message": "Required \`c\` query parameter was added.", + "ruleId": "parameter-became-required", + }, + ], + }, + ] + `); + }); + it('should read whether a referenced parameter is required from what it points at', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe("[{ $ref: '#/components/parameters/Sort' }]"), 'base.yaml'), revision: makeDocumentFromString( cafe( @@ -157,7 +244,7 @@ describe('parameter-became-required', () => { [ { "impact": "major", - "key": "#/paths/~1menu/get/parameters/{query:category}", + "key": "#/paths/~1menu/get/parameters/1", "kind": "added", "revision": { "location": "revision.yaml#/paths/~1menu/get/parameters/1", @@ -173,7 +260,7 @@ describe('parameter-became-required', () => { "verdicts": [ { "impact": "major", - "location": "revision.yaml#/paths/~1menu/get/parameters/1", + "location": "revision.yaml#/components/parameters/Category/required", "message": "Required \`category\` query parameter was added.", "ruleId": "parameter-became-required", }, @@ -184,7 +271,7 @@ describe('parameter-became-required', () => { }); it('should not report referenced parameters listed in another order', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe( "[{ $ref: '#/components/parameters/Sort' }, { $ref: '#/components/parameters/Category' }]" diff --git a/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts b/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts index d1c13bd7fb..aaadbabdca 100644 --- a/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts @@ -25,7 +25,7 @@ const search = '{ name: search, in: query, schema: { type: string } }'; describe('parameter-removed', () => { it('should report a parameter that is gone', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`[${limit}, ${search}]`), 'base.yaml'), revision: makeDocumentFromString(cafe(`[${search}]`), 'revision.yaml'), config: await createConfig({ diff: { 'parameter-removed': 'major' } }), @@ -45,7 +45,7 @@ describe('parameter-removed', () => { }, }, "impact": "major", - "key": "#/paths/~1menu/get/parameters/{query:limit}", + "key": "#/paths/~1menu/get/parameters/0", "kind": "removed", "verdicts": [ { @@ -61,7 +61,7 @@ describe('parameter-removed', () => { }); it('should report the last parameter leaving with the whole list', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`[${limit}]`), 'base.yaml'), revision: makeDocumentFromString(cafe(), 'revision.yaml'), config: await createConfig({ diff: { 'parameter-removed': 'major' } }), @@ -99,7 +99,7 @@ describe('parameter-removed', () => { }); it('should report a referenced parameter swapped for another', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe("[{ $ref: '#/components/parameters/Sort' }]"), 'base.yaml'), revision: makeDocumentFromString( cafe("[{ $ref: '#/components/parameters/Category' }]"), @@ -112,41 +112,228 @@ describe('parameter-removed', () => { [ { "base": { - "location": "base.yaml#/paths/~1menu/get/parameters/0", - "value": { - "in": "query", - "name": "sort", - "schema": { - "type": "string", - }, + "location": "base.yaml#/components/parameters/Sort/name", + "value": "sort", + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/0", + "kind": "modified", + "property": "name", + "revision": { + "location": "revision.yaml#/components/parameters/Category/name", + "value": "category", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/components/parameters/Category/name", + "message": "\`sort\` query parameter was renamed to \`category\`.", + "ruleId": "parameter-removed", }, + ], + }, + { + "base": { + "location": "base.yaml#/components/parameters/Sort", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1menu/get/parameters/0", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/components/parameters/Category/required", + "value": true, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a parameter moved to another place in the request', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('[{ name: token, in: query, schema: { type: string } }]'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('[{ name: token, in: header, schema: { type: string } }]'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0/in", + "value": "query", }, "impact": "major", - "key": "#/paths/~1menu/get/parameters/{query:sort}", - "kind": "removed", + "key": "#/paths/~1menu/get/parameters/0", + "kind": "modified", + "property": "in", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/0/in", + "value": "header", + }, "verdicts": [ { "impact": "major", - "location": "base.yaml#/paths/~1menu/get/parameters/0", - "message": "\`sort\` query parameter was removed.", + "location": "revision.yaml#/paths/~1menu/get/parameters/0/in", + "message": "\`token\` query parameter is now a header parameter.", + "ruleId": "parameter-removed", + }, + ], + }, + ] + `); + }); + + it('should name a parameter by its old name and place when both changed', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('[{ name: id, in: query, schema: { type: string } }]'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('[{ name: itemId, in: header, schema: { type: string } }]'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0/name", + "value": "id", + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/0", + "kind": "modified", + "property": "name", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/0/name", + "value": "itemId", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/0/name", + "message": "\`id\` query parameter was renamed to \`itemId\`.", "ruleId": "parameter-removed", }, ], }, { - "impact": "minor", - "key": "#/paths/~1menu/get/parameters/{query:category}", - "kind": "added", + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0/in", + "value": "query", + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/0", + "kind": "modified", + "property": "in", "revision": { - "location": "revision.yaml#/paths/~1menu/get/parameters/0", - "value": { - "in": "query", - "name": "category", - "required": true, - "schema": { - "type": "string", - }, + "location": "revision.yaml#/paths/~1menu/get/parameters/0/in", + "value": "header", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/0/in", + "message": "\`id\` query parameter is now a header parameter.", + "ruleId": "parameter-removed", }, + ], + }, + ] + `); + }); + + it('should not report a path parameter renamed with its path, or a header renamed only in case', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu/{id}: + get: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - { name: X-Token, in: header, schema: { type: string } } + responses: + '200': { description: OK } + `, + 'base.yaml' + ), + revision: makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu/{itemId}: + get: + parameters: + - { name: itemId, in: path, required: true, schema: { type: string } } + - { name: x-token, in: header, schema: { type: string } } + responses: + '200': { description: OK } + `, + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu~1{id}", + "value": "/menu/{id}", + }, + "impact": "patch", + "key": "#/paths/~1menu~1{itemId}", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1menu~1{itemId}", + "value": "/menu/{itemId}", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1menu~1{id}/get/parameters/0/name", + "value": "id", + }, + "impact": "patch", + "key": "#/paths/~1menu~1{itemId}/get/parameters/0", + "kind": "modified", + "property": "name", + "revision": { + "location": "revision.yaml#/paths/~1menu~1{itemId}/get/parameters/0/name", + "value": "itemId", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1menu~1{id}/get/parameters/1/name", + "value": "X-Token", + }, + "impact": "patch", + "key": "#/paths/~1menu~1{itemId}/get/parameters/1", + "kind": "modified", + "property": "name", + "revision": { + "location": "revision.yaml#/paths/~1menu~1{itemId}/get/parameters/1/name", + "value": "x-token", }, "verdicts": [], }, diff --git a/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts b/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts index 1e2d2c5656..e90eeeca98 100644 --- a/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts @@ -22,7 +22,7 @@ const cafe = (parameters: string) => outdent` describe('parameter-serialization-changed', () => { it('should report a parameter put on the wire another way', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe( '[{ name: category, in: query, style: form, explode: true, schema: { type: array } }]' @@ -46,7 +46,7 @@ describe('parameter-serialization-changed', () => { "value": true, }, "impact": "major", - "key": "#/paths/~1menu/get/parameters/{query:category}", + "key": "#/paths/~1menu/get/parameters/0", "kind": "modified", "property": "explode", "revision": { @@ -65,4 +65,71 @@ describe('parameter-serialization-changed', () => { ] `); }); + + it('should report a serialization keyword that was set or removed', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe( + '[{ name: category, in: query, style: form, schema: { type: array } }, { name: sort, in: query, schema: { type: string } }]' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe( + '[{ name: category, in: query, schema: { type: array } }, { name: sort, in: query, explode: false, schema: { type: string } }]' + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-serialization-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0/style", + "value": "form", + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/0", + "kind": "modified", + "property": "style", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/0", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/0", + "message": "\`style\` of \`category\` query parameter was removed.", + "ruleId": "parameter-serialization-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/1", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/1", + "kind": "modified", + "property": "explode", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/1/explode", + "value": false, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/1/explode", + "message": "\`explode\` of \`sort\` query parameter was set to 'false'.", + "ruleId": "parameter-serialization-changed", + }, + ], + }, + ] + `); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/path-removed.test.ts b/packages/core/src/diff/rules/__tests__/path-removed.test.ts index 66ac49c506..ad6003c914 100644 --- a/packages/core/src/diff/rules/__tests__/path-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/path-removed.test.ts @@ -16,7 +16,7 @@ const revenue = '/revenue: { get: { responses: { 200: { description: OK } } } }' describe('path-removed', () => { it('should report a path that is gone', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${menu}, ${revenue} }`), 'base.yaml'), revision: makeDocumentFromString(cafe(`{ ${menu} }`), 'revision.yaml'), config: await createConfig({ diff: { 'path-removed': 'major' } }), @@ -52,4 +52,445 @@ describe('path-removed', () => { ] `); }); + + it('should report a renamed path, not one whose template names changed', async () => { + const renamed = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${menu} }`), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ /menus: { get: { responses: { 200: { description: OK } } } } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(renamed.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu", + "value": "/menu", + }, + "impact": "major", + "key": "#/paths/~1menus", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1menus", + "value": "/menus", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menus", + "message": "Path \`/menu\` was renamed to \`/menus\`.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + + const menuItem = (name: string) => + `{ "/menu/{${name}}": { get: { responses: { 200: { description: OK } } } } }`; + const renamedTemplate = await diffDocuments({ + base: makeDocumentFromString(cafe(menuItem('id')), 'base.yaml'), + revision: makeDocumentFromString(cafe(menuItem('itemId')), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(renamedTemplate.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu~1{id}", + "value": "/menu/{id}", + }, + "impact": "patch", + "key": "#/paths/~1menu~1{itemId}", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1menu~1{itemId}", + "value": "/menu/{itemId}", + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a removed webhook', async () => { + const withWebhooks = (webhooks: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: { ${menu} } + webhooks: ${webhooks} + `; + const orderPlaced = 'orderPlaced: { post: { responses: { 200: { description: OK } } } }'; + const orderReady = 'orderReady: { post: { responses: { 200: { description: OK } } } }'; + const webhook = await diffDocuments({ + base: makeDocumentFromString(withWebhooks(`{ ${orderPlaced}, ${orderReady} }`), 'base.yaml'), + revision: makeDocumentFromString(withWebhooks(`{ ${orderPlaced} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(webhook.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/webhooks/orderReady", + "value": { + "post": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/webhooks/orderReady", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/webhooks/orderReady", + "message": "Webhook \`orderReady\` was removed.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + }); + + it('should report a removed callback and a callback URL it no longer calls', async () => { + const withCallbacks = (callbacks: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + callbacks: ${callbacks} + responses: { 200: { description: OK } } + `; + const url = '"{$request.body#/url}": { post: { responses: { 200: { description: OK } } } }'; + const other = '"{$request.body#/other}": { post: { responses: { 200: { description: OK } } } }'; + + const callback = await diffDocuments({ + base: makeDocumentFromString(withCallbacks(`{ ready: { ${url} } }`), 'base.yaml'), + revision: makeDocumentFromString(withCallbacks('{}'), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(callback.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/callbacks/ready", + "value": { + "{$request.body#/url}": { + "post": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/callbacks/ready", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/callbacks/ready", + "message": "Callback \`ready\` was removed.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + + const callbackUrl = await diffDocuments({ + base: makeDocumentFromString(withCallbacks(`{ ready: { ${url}, ${other} } }`), 'base.yaml'), + revision: makeDocumentFromString(withCallbacks(`{ ready: { ${url} } }`), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(callbackUrl.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1other}", + "value": { + "post": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/callbacks/ready/{$request.body#~1other}", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1other}", + "message": "Callback \`ready\` no longer calls \`{$request.body#/other}\`.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + }); + + it('should report an operation losing all its callbacks', async () => { + const withCallbacks = (callbacks: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + ${callbacks} + responses: { 200: { description: OK } } + `; + const url = '"{$request.body#/url}": { post: { responses: { 200: { description: OK } } } }'; + + const result = await diffDocuments({ + base: makeDocumentFromString(withCallbacks(`callbacks: { ready: { ${url} } }`), 'base.yaml'), + revision: makeDocumentFromString(withCallbacks(''), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/callbacks", + "value": { + "ready": { + "{$request.body#/url}": { + "post": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/callbacks", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/callbacks", + "message": "All callbacks were removed.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + }); + + it('should report the document losing all its paths', async () => { + const withWebhooks = (paths: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + ${paths} + webhooks: + orderReady: { post: { responses: { 200: { description: OK } } } } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(withWebhooks(`paths: { ${menu} }`), 'base.yaml'), + revision: makeDocumentFromString(withWebhooks(''), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths", + "value": { + "/menu": { + "get": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/paths", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths", + "message": "All paths were removed.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + }); + + it('should report the document losing all its webhooks', async () => { + const withWebhooks = (webhooks: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: { ${menu} } + ${webhooks} + `; + + const result = await diffDocuments({ + base: makeDocumentFromString( + withWebhooks( + 'webhooks: { orderReady: { post: { responses: { 200: { description: OK } } } } }' + ), + 'base.yaml' + ), + revision: makeDocumentFromString(withWebhooks(''), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/webhooks", + "value": { + "orderReady": { + "post": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/webhooks", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/webhooks", + "message": "All webhooks were removed.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + }); + + it('should not report a callback of the components that nothing references', async () => { + const withComponentCallback = (callback: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: {} + components: + callbacks: + Ready: ${callback} + `; + const url = (operations: string) => `"{$request.body#/url}": { ${operations} }`; + const post = (operationId: string) => + `post: { operationId: ${operationId}, responses: { 200: { description: OK } } }`; + const put = 'put: { responses: { 200: { description: OK } } }'; + const other = '"{$request.body#/other}": { post: { responses: { 200: { description: OK } } } }'; + + const result = await diffDocuments({ + base: makeDocumentFromString( + withComponentCallback(`{ ${url(`${post('notifyReady')}, ${put}`)}, ${other} }`), + 'base.yaml' + ), + revision: makeDocumentFromString( + withComponentCallback(`{ ${url(post('readyCall'))} }`), + 'revision.yaml' + ), + config: await createConfig({ + diff: { + 'path-removed': 'major', + 'operation-removed': 'major', + 'operation-id-changed': 'major', + }, + }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/callbacks/Ready/{$request.body#~1url}/post/operationId", + "value": "notifyReady", + }, + "impact": "patch", + "key": "#/components/callbacks/Ready/{$request.body#~1url}/post", + "kind": "modified", + "property": "operationId", + "revision": { + "location": "revision.yaml#/components/callbacks/Ready/{$request.body#~1url}/post/operationId", + "value": "readyCall", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/components/callbacks/Ready/{$request.body#~1url}/put", + "value": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + "impact": "patch", + "key": "#/components/callbacks/Ready/{$request.body#~1url}/put", + "kind": "removed", + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/components/callbacks/Ready/{$request.body#~1other}", + "value": { + "post": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + "impact": "patch", + "key": "#/components/callbacks/Ready/{$request.body#~1other}", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/property-removed.test.ts b/packages/core/src/diff/rules/__tests__/property-removed.test.ts index 6f1264a9b6..ef3fc8105b 100644 --- a/packages/core/src/diff/rules/__tests__/property-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/property-removed.test.ts @@ -25,7 +25,7 @@ const cafe = (order: string) => outdent` describe('property-removed', () => { it('should report a property a response no longer sends, not one a request no longer takes', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe( '{ type: object, properties: { menuItemId: { type: string }, note: { type: string } } }' @@ -77,7 +77,7 @@ describe('property-removed', () => { }); it('should not report a oneOf alternative, which is not a property', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe('{ oneOf: [{ type: string }, { type: integer }] }'), 'base.yaml' @@ -117,7 +117,7 @@ describe('property-removed', () => { }); it('should not report a writeOnly property, which a response never sends', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe('{ properties: { pin: { type: string, writeOnly: true } } }'), 'base.yaml' @@ -159,7 +159,7 @@ describe('property-removed', () => { }); it('should report every property leaving with the whole properties map', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe('{ type: object, properties: { note: { type: string } } }'), 'base.yaml' @@ -208,4 +208,123 @@ describe('property-removed', () => { ] `); }); + + it('should report a property renamed in a response, not in a request', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: object, properties: { name: { type: string } } }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, properties: { names: { type: string } } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'property-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/name", + "value": "name", + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/names", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/names", + "value": "names", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/name", + "value": "name", + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/names", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/names", + "value": "names", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/names", + "message": "Property \`name\` was renamed to \`names\`.", + "ruleId": "property-removed", + }, + ], + }, + ] + `); + }); + + it('should name the owner of a removed property by the place of its $ref, not by its target', async () => { + const order = (item: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + get: + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + components: + schemas: + Order: { type: object, properties: { item: { $ref: '#/components/schemas/${item}' } } } + Item: { type: object, properties: { name: { type: string }, price: { type: number } } } + MenuItem: { type: object, properties: { name: { type: string } } } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(order('Item'), 'base.yaml'), + revision: makeDocumentFromString(order('MenuItem'), 'revision.yaml'), + config: await createConfig({ diff: { 'property-removed': 'major' } }), + }); + + expect(result.changes.flatMap((change) => change.verdicts)).toMatchObject([ + { message: 'Property `price` of `item` was removed.' }, + ]); + }); + + it('should not name the owner of a property written next to a $ref', async () => { + const order = (properties: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + get: + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Order', properties: ${properties} } + components: + schemas: + Order: { type: object } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString( + order('{ id: { type: string }, extra: { type: string } }'), + 'base.yaml' + ), + revision: makeDocumentFromString(order('{ id: { type: string } }'), 'revision.yaml'), + config: await createConfig({ diff: { 'property-removed': 'major' } }), + }); + + expect(result.changes.flatMap((change) => change.verdicts)).toMatchObject([ + { message: 'Property `extra` was removed.' }, + ]); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts b/packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts deleted file mode 100644 index 92c73585ba..0000000000 --- a/packages/core/src/diff/rules/__tests__/ref-target-changed.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { outdent } from 'outdent'; - -import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; -import { createConfig } from '../../../config/index.js'; -import { makeDocumentFromString } from '../../../resolve.js'; -import { diffDocuments } from '../../index.js'; - -const cafe = (itemSchema: string) => outdent` - openapi: 3.1.0 - info: { title: Cafe, version: 1.0.0 } - paths: - /menu/{menuItemId}: - get: - parameters: - - { name: menuItemId, in: path, required: true, schema: { type: string } } - responses: - '200': - description: OK - content: - application/json: - schema: { $ref: '#/components/schemas/${itemSchema}' } - components: - schemas: - Beverage: { type: object } - Dessert: { type: object, required: [flavour] } - Drink: { type: object } -`; - -describe('ref-target-changed', () => { - it('should report a $ref that points at a schema that describes other data', async () => { - const result = diffDocuments({ - base: makeDocumentFromString(cafe('Beverage'), 'base.yaml'), - revision: makeDocumentFromString(cafe('Dessert'), 'revision.yaml'), - config: await createConfig({ diff: { 'ref-target-changed': 'major' } }), - }); - - expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` - [ - { - "base": { - "location": "base.yaml#/paths/~1menu~1{menuItemId}/get/responses/200/content/application~1json/schema/$ref", - "value": "#/components/schemas/Beverage", - }, - "impact": "major", - "key": "#/paths/~1menu~1{0}/get/responses/200/content/application~1json/schema", - "kind": "modified", - "property": "$ref", - "revision": { - "location": "revision.yaml#/paths/~1menu~1{menuItemId}/get/responses/200/content/application~1json/schema/$ref", - "value": "#/components/schemas/Dessert", - }, - "verdicts": [ - { - "impact": "major", - "location": "revision.yaml#/paths/~1menu~1{menuItemId}/get/responses/200/content/application~1json/schema/$ref", - "message": "Reference target changed from '#/components/schemas/Beverage' to '#/components/schemas/Dessert', which describes other data.", - "ruleId": "ref-target-changed", - }, - ], - }, - ] - `); - }); - - it('should not report a $ref that points at a renamed schema', async () => { - const result = diffDocuments({ - base: makeDocumentFromString(cafe('Beverage'), 'base.yaml'), - revision: makeDocumentFromString(cafe('Drink'), 'revision.yaml'), - config: await createConfig({ diff: { 'ref-target-changed': 'major' } }), - }); - - expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` - [ - { - "base": { - "location": "base.yaml#/paths/~1menu~1{menuItemId}/get/responses/200/content/application~1json/schema/$ref", - "value": "#/components/schemas/Beverage", - }, - "impact": "patch", - "key": "#/paths/~1menu~1{0}/get/responses/200/content/application~1json/schema", - "kind": "modified", - "property": "$ref", - "revision": { - "location": "revision.yaml#/paths/~1menu~1{menuItemId}/get/responses/200/content/application~1json/schema/$ref", - "value": "#/components/schemas/Drink", - }, - "verdicts": [], - }, - ] - `); - }); -}); diff --git a/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts b/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts index 8dcb243957..656971a384 100644 --- a/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts +++ b/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts @@ -20,7 +20,7 @@ const cafe = (required: boolean) => outdent` describe('request-body-became-required', () => { it('should report an optional request body that became required', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(false), 'base.yaml'), revision: makeDocumentFromString(cafe(true), 'revision.yaml'), config: await createConfig({ diff: { 'request-body-became-required': 'major' } }), @@ -55,7 +55,7 @@ describe('request-body-became-required', () => { }); it('should not report a required request body that became optional', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(true), 'base.yaml'), revision: makeDocumentFromString(cafe(false), 'revision.yaml'), config: await createConfig({ diff: { 'request-body-became-required': 'major' } }), diff --git a/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts b/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts index df19954bd0..6d58b087e9 100644 --- a/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts @@ -18,7 +18,7 @@ const order = 'requestBody: { content: { application/json: { schema: { type: obj describe('request-body-removed', () => { it('should report a request body that is gone', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${order}, ${created} }`), 'base.yaml'), revision: makeDocumentFromString(cafe(`{ ${created} }`), 'revision.yaml'), config: await createConfig({ diff: { 'request-body-removed': 'major' } }), diff --git a/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts b/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts index 36f35b078d..4a77331bd4 100644 --- a/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts +++ b/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts @@ -25,7 +25,7 @@ const cafe = (order: string) => outdent` describe('required-properties-added', () => { it('should report the properties a request must now send, not the ones a response now promises', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe('{ type: object, required: [menuItemId] }'), 'base.yaml'), revision: makeDocumentFromString( cafe('{ type: object, required: [menuItemId, quantity] }'), @@ -88,7 +88,7 @@ describe('required-properties-added', () => { }); it('should not report a readOnly property, which a request never sends', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe('{ properties: { orderId: { type: string, readOnly: true } } }'), 'base.yaml' @@ -139,4 +139,148 @@ describe('required-properties-added', () => { ] `); }); + + it('should not report a schema that became readOnly along with its own new required property', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe( + '{ type: object, properties: { note: { type: object, properties: { text: { type: string } } } } }' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe( + '{ type: object, properties: { note: { type: object, readOnly: true, required: [text], properties: { text: { type: string } } } } }' + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'required-properties-added': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "kind": "modified", + "property": "readOnly", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/readOnly", + "value": true, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/required", + "value": [ + "text", + ], + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note", + "kind": "modified", + "property": "readOnly", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note/readOnly", + "value": true, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note/required", + "value": [ + "text", + ], + }, + "verdicts": [], + }, + ] + `); + }); + + it('should not report a $ref property named by the component key whose target is readOnly', async () => { + const cafeWithUser = (order: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${order} + responses: + '201': + description: Created + components: + schemas: + User: { type: object, readOnly: true } + `; + const result = await diffDocuments({ + base: makeDocumentFromString( + cafeWithUser( + '{ type: object, properties: { owner: { $ref: "#/components/schemas/User" } } }' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafeWithUser( + '{ type: object, required: [owner], properties: { owner: { $ref: "#/components/schemas/User" } } }' + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'required-properties-added': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "value": [ + "owner", + ], + }, + "verdicts": [], + }, + ] + `); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts b/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts index 7a36db0b75..1b3cb4432d 100644 --- a/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts @@ -25,7 +25,7 @@ const cafe = (order: string) => outdent` describe('required-properties-removed', () => { it('should report the properties a response no longer promises, not the ones a request may now omit', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe('{ type: object, required: [menuItemId, quantity] }'), 'base.yaml' @@ -91,7 +91,7 @@ describe('required-properties-removed', () => { }); it('should not report a writeOnly property, which a response never sends', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe('{ required: [pin], properties: { pin: { type: string, writeOnly: true } } }'), 'base.yaml' @@ -142,4 +142,85 @@ describe('required-properties-removed', () => { ] `); }); + + it('should not report a writeOnly property removed together with its required entry', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe( + '{ required: [secret], properties: { id: { type: string }, secret: { type: string, writeOnly: true } } }' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ properties: { id: { type: string } } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'required-properties-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "value": [ + "secret", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/secret", + "value": { + "type": "string", + "writeOnly": true, + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/secret", + "kind": "removed", + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required", + "value": [ + "secret", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/secret", + "value": { + "type": "string", + "writeOnly": true, + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/secret", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts b/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts index 459a6e8f6a..2373446db6 100644 --- a/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts @@ -20,7 +20,7 @@ const rateLimit = 'X-Rate-Limit: { schema: { type: integer } }'; describe('response-header-removed', () => { it('should report a header that is gone', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe(`{ description: Created, headers: { ${location}, ${rateLimit} } }`), 'base.yaml' @@ -60,7 +60,7 @@ describe('response-header-removed', () => { }); it('should report every header leaving with the whole map', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe(`{ description: Created, headers: { ${location} } }`), 'base.yaml' @@ -97,4 +97,108 @@ describe('response-header-removed', () => { ] `); }); + + it('should report a renamed header, not one renamed only in case', async () => { + const withHeader = (name: string) => + cafe(`{ description: Created, headers: { ${name}: { schema: { type: integer } } } }`); + const config = await createConfig({ diff: { 'response-header-removed': 'major' } }); + + const renamed = await diffDocuments({ + base: makeDocumentFromString(withHeader('X-Rate'), 'base.yaml'), + revision: makeDocumentFromString(withHeader('X-RateLimit'), 'revision.yaml'), + config, + }); + const recased = await diffDocuments({ + base: makeDocumentFromString(withHeader('X-Rate'), 'base.yaml'), + revision: makeDocumentFromString(withHeader('x-rate'), 'revision.yaml'), + config, + }); + + expect(replaceSourceWithRefInChanges(renamed.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/headers/X-Rate", + "value": "X-Rate", + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/headers/X-RateLimit", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/headers/X-RateLimit", + "value": "X-RateLimit", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/headers/X-RateLimit", + "message": "Response header \`X-Rate\` was renamed to \`X-RateLimit\`.", + "ruleId": "response-header-removed", + }, + ], + }, + ] + `); + expect(replaceSourceWithRefInChanges(recased.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/headers/X-Rate", + "value": "X-Rate", + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/headers/x-rate", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/headers/x-rate", + "value": "x-rate", + }, + "verdicts": [], + }, + ] + `); + }); + + it('should not report a removed component header', async () => { + const withComponents = (headers: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + responses: + '201': { description: Created } + components: + headers: ${headers} + `; + const result = await diffDocuments({ + base: makeDocumentFromString( + withComponents('{ X-Rate: { schema: { type: integer } } }'), + 'base.yaml' + ), + revision: makeDocumentFromString(withComponents('{}'), 'revision.yaml'), + config: await createConfig({ diff: { 'response-header-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/headers/X-Rate", + "value": { + "schema": { + "type": "integer", + }, + }, + }, + "impact": "patch", + "key": "#/components/headers/X-Rate", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/response-removed.test.ts b/packages/core/src/diff/rules/__tests__/response-removed.test.ts index 500c16305f..1f49001167 100644 --- a/packages/core/src/diff/rules/__tests__/response-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/response-removed.test.ts @@ -1,8 +1,8 @@ import { outdent } from 'outdent'; -import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { replaceSourceWithRefInChanges, resolverWithFiles } from '../../../../__tests__/utils.js'; import { createConfig } from '../../../config/index.js'; -import { makeDocumentFromString } from '../../../resolve.js'; +import { makeDocumentFromString, type Document } from '../../../resolve.js'; import { diffDocuments } from '../../index.js'; const cafe = (responses: string) => outdent` @@ -18,7 +18,7 @@ const cafe = (responses: string) => outdent` describe('response-removed', () => { it('should report a response that is gone', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe("{ '200': { description: OK }, '404': { description: Not found } }"), 'base.yaml' @@ -37,7 +37,7 @@ describe('response-removed', () => { }, }, "impact": "major", - "key": "#/paths/~1orders~1{0}/get/responses/404", + "key": "#/paths/~1orders~1{orderId}/get/responses/404", "kind": "removed", "verdicts": [ { @@ -51,4 +51,112 @@ describe('response-removed', () => { ] `); }); + + it('should report a response that became another status code', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe("{ '200': { description: OK } }"), 'base.yaml'), + revision: makeDocumentFromString(cafe("{ '204': { description: OK } }"), 'revision.yaml'), + config: await createConfig({ diff: { 'response-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders~1{orderId}/get/responses/200", + "value": "200", + }, + "impact": "major", + "key": "#/paths/~1orders~1{orderId}/get/responses/204", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1orders~1{orderId}/get/responses/204", + "value": "204", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders~1{orderId}/get/responses/204", + "message": "Response \`200\` became \`204\`.", + "ruleId": "response-removed", + }, + ], + }, + ] + `); + }); + + it('should not report a removed component response, which no operation returns', async () => { + const withComponents = (components: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + get: + responses: + '200': { description: OK } + components: + responses: ${components} + `; + const result = await diffDocuments({ + base: makeDocumentFromString( + withComponents('{ NotFound: { description: Not found } }'), + 'base.yaml' + ), + revision: makeDocumentFromString(withComponents('{}'), 'revision.yaml'), + config: await createConfig({ diff: { 'response-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/responses/NotFound", + "value": { + "description": "Not found", + }, + }, + "impact": "patch", + "key": "#/components/responses/NotFound", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); + + it('should name a removed response by its own code when it points at a response in another file', async () => { + const failed = "{ $ref: '#/components/responses/Failed' }"; + const orders = (responses: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + responses: ${responses} + components: + responses: + Failed: { $ref: failed.yaml } + `; + const resolver = resolverWithFiles({ + 'failed.yaml': 'description: Failed', + 'base.yaml': orders(`{ '400': ${failed}, '500': ${failed} }`), + 'revision.yaml': orders(`{ '400': ${failed} }`), + }); + const [base, revision] = (await Promise.all( + ['base.yaml', 'revision.yaml'].map((file) => resolver.resolveDocument(null, file, true)) + )) as Document[]; + + const result = await diffDocuments({ + base, + revision, + config: await createConfig({ diff: { 'response-removed': 'major' } }), + externalRefResolver: resolver, + }); + + expect( + result.changes.flatMap(({ verdicts }) => verdicts.map(({ message }) => message)) + ).toEqual(['Response `500` was removed.']); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts index d652a127d3..995a8ec814 100644 --- a/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts @@ -5,7 +5,7 @@ import { createConfig } from '../../../config/index.js'; import { makeDocumentFromString } from '../../../resolve.js'; import { diffDocuments } from '../../index.js'; -const cafe = (payment: string) => outdent` +const cafe = (payment: string, responsePayment = payment) => outdent` openapi: 3.1.0 info: { title: Cafe, version: 1.0.0 } paths: @@ -20,12 +20,12 @@ const cafe = (payment: string) => outdent` description: Created content: application/json: - schema: ${payment} + schema: ${responsePayment} `; describe('schema-combinator-changed', () => { it('should report a oneOf alternative a request no longer accepts, not one a response stops sending', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe('{ oneOf: [{ const: card }, { const: cash }] }'), 'base.yaml' @@ -72,7 +72,7 @@ describe('schema-combinator-changed', () => { }); it('should report an allOf member a request must now satisfy, not one a response now satisfies', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe('{ allOf: [{ required: [amount] }] }'), 'base.yaml'), revision: makeDocumentFromString( cafe('{ allOf: [{ required: [amount] }, { required: [currency] }] }'), @@ -121,4 +121,70 @@ describe('schema-combinator-changed', () => { ] `); }); + + it('should report a whole oneOf removed from a response and a whole allOf added to a request', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: object }', '{ type: string, oneOf: [{ const: card }, { const: cash }] }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, allOf: [{ required: [card] }] }', '{ type: string }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-combinator-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf", + "value": [ + { + "required": [ + "card", + ], + }, + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf", + "message": "\`allOf\` was added.", + "ruleId": "schema-combinator-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf", + "value": [ + { + "const": "card", + }, + { + "const": "cash", + }, + ], + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf", + "message": "\`oneOf\` was removed.", + "ruleId": "schema-combinator-changed", + }, + ], + }, + ] + `); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/schema-constraint-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-constraint-changed.test.ts new file mode 100644 index 0000000000..1d8df967b0 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/schema-constraint-changed.test.ts @@ -0,0 +1,1272 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (requestSchema: string, responseSchema = requestSchema) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${requestSchema} + responses: + '201': + description: Created + content: + application/json: + schema: ${responseSchema} +`; + +describe('schema-constraint-changed', () => { + it('should report a bound that makes a request accept less, not one that lets a response send less', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: integer, maximum: 20 }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ type: integer, maximum: 10 }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum", + "value": 20, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "maximum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum", + "value": 10, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum", + "message": "Upper bound changed from '<= 20' to '<= 10'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maximum", + "value": 20, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "maximum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maximum", + "value": 10, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a bound that lets a response send more, not one that makes a request accept more', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: integer, minimum: 1 }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ type: integer, minimum: 0 }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", + "value": 1, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", + "value": 0, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", + "value": 1, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", + "value": 0, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", + "message": "Lower bound changed from '>= 1' to '>= 0'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should not report an OpenAPI 3.0 bound written the OpenAPI 3.1 way', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: number, minimum: 0, exclusiveMinimum: true }').replace('3.1.0', '3.0.3'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: number, exclusiveMinimum: 0 }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/openapi", + "value": "3.0.3", + }, + "impact": "patch", + "key": "#/", + "kind": "modified", + "property": "openapi", + "revision": { + "location": "revision.yaml#/openapi", + "value": "3.1.0", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", + "value": 0, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/exclusiveMinimum", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "exclusiveMinimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/exclusiveMinimum", + "value": 0, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", + "value": 0, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/exclusiveMinimum", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "exclusiveMinimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/exclusiveMinimum", + "value": 0, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a bound that became exclusive in a request', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: number, minimum: 0 }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: number, exclusiveMinimum: 0 }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", + "value": 0, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "message": "Lower bound changed from '>= 0' to '> 0'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "exclusiveMinimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/exclusiveMinimum", + "value": 0, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", + "value": 0, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "exclusiveMinimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/exclusiveMinimum", + "value": 0, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a length limit that makes a request accept less', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: string, maxLength: 200 }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ type: string, maxLength: 100 }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength", + "value": 200, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "maxLength", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength", + "value": 100, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength", + "message": "\`maxLength\` changed from '200' to '100'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxLength", + "value": 200, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "maxLength", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxLength", + "value": 100, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a pattern that changed in a response, which may now send values a client rejects', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe("{ type: string, pattern: '^[a-z]+$' }"), 'base.yaml'), + revision: makeDocumentFromString( + cafe("{ type: string, pattern: '^[a-z ]+$' }"), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern", + "value": "^[a-z]+$", + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "pattern", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern", + "value": "^[a-z ]+$", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern", + "message": "\`pattern\` changed from '^[a-z]+$' to '^[a-z ]+$'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/pattern", + "value": "^[a-z]+$", + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "pattern", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/pattern", + "value": "^[a-z ]+$", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/pattern", + "message": "\`pattern\` changed from '^[a-z]+$' to '^[a-z ]+$'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should report a format added to a request, not to a response', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: string }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: string, format: date-time }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "format", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/format", + "value": "date-time", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/format", + "message": "\`format\` was set to 'date-time'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "format", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/format", + "value": "date-time", + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report closing an open request object, not an open response object', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: object, additionalProperties: true }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, additionalProperties: false }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "value": true, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "additionalProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "value": false, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "message": "\`additionalProperties\` changed from 'true' to 'false'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "additionalProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", + "value": false, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report opening a closed response object, not a closed request object', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: object, additionalProperties: false }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, additionalProperties: true }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "value": false, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "additionalProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "value": true, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", + "value": false, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "additionalProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", + "value": true, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", + "message": "\`additionalProperties\` changed from 'false' to 'true'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should report an array limit that makes a request accept less, not one that lets a response send less', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: array, maxItems: 10 }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ type: array, maxItems: 5 }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxItems", + "value": 10, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "maxItems", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxItems", + "value": 5, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxItems", + "message": "\`maxItems\` changed from '10' to '5'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxItems", + "value": 10, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "maxItems", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxItems", + "value": 5, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report items that must now be unique in a request', async () => { + const config = await createConfig({ diff: { 'schema-constraint-changed': 'major' } }); + + const uniqueItemsRequired = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: array }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ type: array, uniqueItems: true }'), 'revision.yaml'), + config, + }); + const uniqueItemsDeclined = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: array }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: array, uniqueItems: false }'), + 'revision.yaml' + ), + config, + }); + + expect(replaceSourceWithRefInChanges(uniqueItemsRequired.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "uniqueItems", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/uniqueItems", + "value": true, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/uniqueItems", + "message": "\`uniqueItems\` was set to 'true'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "uniqueItems", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/uniqueItems", + "value": true, + }, + "verdicts": [], + }, + ] + `); + expect(replaceSourceWithRefInChanges(uniqueItemsDeclined.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "uniqueItems", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/uniqueItems", + "value": false, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "uniqueItems", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/uniqueItems", + "value": false, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a const that changed on both sides, since neither accepts the other value', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ const: small }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ const: large }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/const", + "value": "small", + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "const", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/const", + "value": "large", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/const", + "message": "\`const\` changed from 'small' to 'large'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/const", + "value": "small", + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "const", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/const", + "value": "large", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/const", + "message": "\`const\` changed from 'small' to 'large'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should report a subschema that constrains a request, and its removal from a response', async () => { + const constrained = '{ type: object, not: { required: [secret] } }'; + const config = await createConfig({ diff: { 'schema-constraint-changed': 'major' } }); + + const addedToRequest = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: object }', '{ type: object }'), 'base.yaml'), + revision: makeDocumentFromString(cafe(constrained, '{ type: object }'), 'revision.yaml'), + config, + }); + const removedFromResponse = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: object }', constrained), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: object }', '{ type: object }'), + 'revision.yaml' + ), + config, + }); + + expect(replaceSourceWithRefInChanges(addedToRequest.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/not", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/not", + "value": { + "required": [ + "secret", + ], + }, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/not", + "message": "\`not\` was added.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + expect(replaceSourceWithRefInChanges(removedFromResponse.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/not", + "value": { + "required": [ + "secret", + ], + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/not", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/not", + "message": "\`not\` was removed.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should report a tuple added to a request and dependent schemas removed from a response', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: array }', '{ type: object, dependentSchemas: { a: { required: [b] } } }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: array, prefixItems: [{ type: string }] }', '{ type: object }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/prefixItems", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/prefixItems", + "value": [ + { + "type": "string", + }, + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/prefixItems", + "message": "\`prefixItems\` was added.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/dependentSchemas", + "value": { + "a": { + "required": [ + "b", + ], + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/dependentSchemas", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/dependentSchemas", + "message": "\`dependentSchemas\` was removed.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should not report a property named like a keyword', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: object, properties: {} }', '{ type: object }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, properties: { not: { type: string } } }', '{ type: object }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "minor", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/not", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/not", + "value": { + "type": "string", + }, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a closed object opened with a schema once, as accepting more', async () => { + const config = await createConfig({ diff: { 'schema-constraint-changed': 'major' } }); + const closed = '{ type: object, unevaluatedProperties: false }'; + const opened = '{ type: object, unevaluatedProperties: { type: string } }'; + + const openedInResponse = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: object }', closed), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ type: object }', opened), 'revision.yaml'), + config, + }); + const openedInRequest = await diffDocuments({ + base: makeDocumentFromString(cafe(closed, '{ type: object }'), 'base.yaml'), + revision: makeDocumentFromString(cafe(opened, '{ type: object }'), 'revision.yaml'), + config, + }); + + expect(replaceSourceWithRefInChanges(openedInResponse.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/unevaluatedProperties", + "value": false, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "unevaluatedProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/unevaluatedProperties", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/unevaluatedProperties", + "message": "\`unevaluatedProperties\` was removed.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "impact": "minor", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/unevaluatedProperties", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/unevaluatedProperties", + "value": { + "type": "string", + }, + }, + "verdicts": [], + }, + ] + `); + expect(replaceSourceWithRefInChanges(openedInRequest.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/unevaluatedProperties", + "value": false, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "unevaluatedProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/unevaluatedProperties", + "value": undefined, + }, + "verdicts": [], + }, + { + "impact": "minor", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/unevaluatedProperties", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/unevaluatedProperties", + "value": { + "type": "string", + }, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a lower bound set in a request and an upper bound removed in a response', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: integer }', '{ type: integer, maximum: 10 }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: integer, minimum: 1 }', '{ type: integer }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", + "value": 1, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", + "message": "Lower bound was set to '>= 1'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maximum", + "value": 10, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "maximum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "message": "Upper bound was removed.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should report dependent required properties added to a request', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: object }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: object, dependentRequired: { a: [b] } }', '{ type: object }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/dependentRequired", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/dependentRequired", + "value": { + "a": [ + "b", + ], + }, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/dependentRequired", + "message": "\`dependentRequired\` was added.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should report a schema that constrains the extra properties of an open request object', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: object }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: object, additionalProperties: { type: string } }', '{ type: object }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "value": { + "type": "string", + }, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "message": "\`additionalProperties\` was added.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should judge a closed tuple opened with an items schema as accepting more', async () => { + const closed = '{ type: array, prefixItems: [{ type: string }], items: false }'; + const opened = '{ type: array, prefixItems: [{ type: string }], items: { type: string } }'; + + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(closed), 'base.yaml'), + revision: makeDocumentFromString(cafe(opened), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/items", + "value": false, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "items", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/items", + "value": undefined, + }, + "verdicts": [], + }, + { + "impact": "minor", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/items", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/items", + "value": { + "type": "string", + }, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/items", + "value": false, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "items", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/items", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/items", + "message": "\`items\` was removed.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "impact": "minor", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/items", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/items", + "value": { + "type": "string", + }, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should not report a lone if, which constrains nothing', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: object }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: object, if: { required: [a] } }', '{ type: object }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "minor", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/if", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/if", + "value": { + "required": [ + "a", + ], + }, + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/schema-format-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-format-changed.test.ts deleted file mode 100644 index 5806e990c5..0000000000 --- a/packages/core/src/diff/rules/__tests__/schema-format-changed.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { outdent } from 'outdent'; - -import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; -import { createConfig } from '../../../config/index.js'; -import { makeDocumentFromString } from '../../../resolve.js'; -import { diffDocuments } from '../../index.js'; - -const cafe = (pickupAt: string) => outdent` - openapi: 3.1.0 - info: { title: Cafe, version: 1.0.0 } - paths: - /orders: - post: - requestBody: - content: - application/json: - schema: ${pickupAt} - responses: - '201': - description: Created - content: - application/json: - schema: ${pickupAt} -`; - -describe('schema-format-changed', () => { - it('should report a format added to a request, not to a response', async () => { - const result = diffDocuments({ - base: makeDocumentFromString(cafe('{ type: string }'), 'base.yaml'), - revision: makeDocumentFromString( - cafe('{ type: string, format: date-time }'), - 'revision.yaml' - ), - config: await createConfig({ diff: { 'schema-format-changed': 'major' } }), - }); - - expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` - [ - { - "base": { - "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", - "value": undefined, - }, - "impact": "major", - "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", - "kind": "modified", - "property": "format", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/format", - "value": "date-time", - }, - "verdicts": [ - { - "impact": "major", - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/format", - "message": "\`format\` was added with value 'date-time'.", - "ruleId": "schema-format-changed", - }, - ], - }, - { - "base": { - "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", - "value": undefined, - }, - "impact": "patch", - "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", - "kind": "modified", - "property": "format", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/format", - "value": "date-time", - }, - "verdicts": [], - }, - ] - `); - }); -}); diff --git a/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts index b46c11a3ae..ad794e6034 100644 --- a/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts @@ -25,7 +25,7 @@ const cafe = (openapi: string, price: string) => outdent` describe('schema-type-changed', () => { it('should report a request that accepts fewer types, not a response that sends fewer', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe('3.1.0', '{ type: number }'), 'base.yaml'), revision: makeDocumentFromString(cafe('3.1.0', '{ type: integer }'), 'revision.yaml'), config: await createConfig({ diff: { 'schema-type-changed': 'major' } }), @@ -75,7 +75,7 @@ describe('schema-type-changed', () => { }); it('should report a response that sends more types, not a request that accepts more', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe('3.1.0', '{ type: integer }'), 'base.yaml'), revision: makeDocumentFromString(cafe('3.1.0', '{ type: number }'), 'revision.yaml'), config: await createConfig({ diff: { 'schema-type-changed': 'major' } }), @@ -125,7 +125,7 @@ describe('schema-type-changed', () => { }); it('should report a request that no longer accepts null in OpenAPI 3.0', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe('3.0.3', '{ type: number, nullable: true }'), 'base.yaml'), revision: makeDocumentFromString(cafe('3.0.3', '{ type: number }'), 'revision.yaml'), config: await createConfig({ diff: { 'schema-type-changed': 'major' } }), @@ -175,7 +175,7 @@ describe('schema-type-changed', () => { }); it("should read OpenAPI 3.0 `nullable: true` as the 3.1 `type: [..., 'null']`", async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe('3.0.3', '{ type: number, nullable: true }'), 'base.yaml'), revision: makeDocumentFromString( cafe('3.1.0', '{ type: [number, "null"] }'), @@ -272,7 +272,7 @@ describe('schema-type-changed', () => { }); it('should report a type that a schema without one now requires in a request', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe('3.1.0', '{ description: Price }'), 'base.yaml'), revision: makeDocumentFromString( cafe('3.1.0', '{ description: Price, type: number }'), @@ -325,7 +325,7 @@ describe('schema-type-changed', () => { }); it('should report a type that a response no longer keeps to', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe('3.1.0', '{ description: Price, type: number }'), 'base.yaml' diff --git a/packages/core/src/diff/rules/__tests__/security-requirement-changed.test.ts b/packages/core/src/diff/rules/__tests__/security-requirement-changed.test.ts index 74e0de2cec..131b6b550a 100644 --- a/packages/core/src/diff/rules/__tests__/security-requirement-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/security-requirement-changed.test.ts @@ -26,7 +26,7 @@ const ok = 'responses: { 200: { description: OK } }'; describe('security-requirement-changed', () => { it('should report a security list that appears where there was none', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${ok} }`), 'base.yaml'), revision: makeDocumentFromString( cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), @@ -63,7 +63,7 @@ describe('security-requirement-changed', () => { }); it('should report the first requirement filling a list that asked for none', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${ok}, security: [] }`), 'base.yaml'), revision: makeDocumentFromString( cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), @@ -76,7 +76,7 @@ describe('security-requirement-changed', () => { [ { "impact": "major", - "key": "#/paths/~1orders/get/security/{ApiKey}", + "key": "#/paths/~1orders/get/security/0", "kind": "added", "revision": { "location": "revision.yaml#/paths/~1orders/get/security/0", @@ -98,7 +98,7 @@ describe('security-requirement-changed', () => { }); it('should not report another way to authenticate', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'), revision: makeDocumentFromString( cafe(`{ ${ok}, security: [{ ApiKey: [] }, { OAuth: [orders:read] }] }`), @@ -111,7 +111,7 @@ describe('security-requirement-changed', () => { [ { "impact": "minor", - "key": "#/paths/~1orders/get/security/{OAuth}", + "key": "#/paths/~1orders/get/security/1", "kind": "added", "revision": { "location": "revision.yaml#/paths/~1orders/get/security/1", @@ -128,7 +128,7 @@ describe('security-requirement-changed', () => { }); it('should report the scopes a requirement now asks for', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe(`{ ${ok}, security: [{ OAuth: [orders:read] }] }`), 'base.yaml' @@ -150,7 +150,7 @@ describe('security-requirement-changed', () => { ], }, "impact": "major", - "key": "#/paths/~1orders/get/security/{OAuth}", + "key": "#/paths/~1orders/get/security/0", "kind": "modified", "property": "OAuth", "revision": { @@ -174,7 +174,7 @@ describe('security-requirement-changed', () => { }); it('should report a requirement that now asks for one more scheme', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'), revision: makeDocumentFromString( cafe(`{ ${ok}, security: [{ ApiKey: [], OAuth: [] }] }`), @@ -188,41 +188,84 @@ describe('security-requirement-changed', () => { { "base": { "location": "base.yaml#/paths/~1orders/get/security/0", - "value": { - "ApiKey": [], - }, + "value": undefined, }, "impact": "major", - "key": "#/paths/~1orders/get/security/{ApiKey}", - "kind": "removed", + "key": "#/paths/~1orders/get/security/0", + "kind": "modified", + "property": "OAuth", + "revision": { + "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth", + "value": [], + }, "verdicts": [ { "impact": "major", - "location": "base.yaml#/paths/~1orders/get/security/0", - "message": "Security requirement \`ApiKey\` is no longer accepted.", + "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth", + "message": "Security requirement \`ApiKey\` now requires \`OAuth\`.", "ruleId": "security-requirement-changed", }, ], }, + ] + `); + }); + + it('should report a requirement that swapped its scheme', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'), + revision: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ OAuth: [] }] }`), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ { - "impact": "minor", - "key": "#/paths/~1orders/get/security/{ApiKey+OAuth}", - "kind": "added", + "base": { + "location": "base.yaml#/paths/~1orders/get/security/0/ApiKey", + "value": [], + }, + "impact": "patch", + "key": "#/paths/~1orders/get/security/0", + "kind": "modified", + "property": "ApiKey", "revision": { "location": "revision.yaml#/paths/~1orders/get/security/0", - "value": { - "ApiKey": [], - "OAuth": [], - }, + "value": undefined, }, "verdicts": [], }, + { + "base": { + "location": "base.yaml#/paths/~1orders/get/security/0", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1orders/get/security/0", + "kind": "modified", + "property": "OAuth", + "revision": { + "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth", + "value": [], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth", + "message": "Security requirement \`ApiKey\` now requires \`OAuth\`.", + "ruleId": "security-requirement-changed", + }, + ], + }, ] `); }); it('should report an alternative that clients can no longer use', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString( cafe(`{ ${ok}, security: [{ ApiKey: [] }, { OAuth: [] }] }`), 'base.yaml' @@ -244,7 +287,7 @@ describe('security-requirement-changed', () => { }, }, "impact": "major", - "key": "#/paths/~1orders/get/security/{ApiKey}", + "key": "#/paths/~1orders/get/security/0", "kind": "removed", "verdicts": [ { @@ -260,7 +303,7 @@ describe('security-requirement-changed', () => { }); it('should not report a requirement that another one still covers, or a list that asks for none', async () => { - const covered = diffDocuments({ + const covered = await diffDocuments({ base: makeDocumentFromString( cafe(`{ ${ok}, security: [{ ApiKey: [], OAuth: [] }] }`), 'base.yaml' @@ -271,7 +314,7 @@ describe('security-requirement-changed', () => { ), config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }), }); - const optional = diffDocuments({ + const optional = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'), revision: makeDocumentFromString(cafe(`{ ${ok}, security: [] }`), 'revision.yaml'), config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }), @@ -281,26 +324,16 @@ describe('security-requirement-changed', () => { [ { "base": { - "location": "base.yaml#/paths/~1orders/get/security/0", - "value": { - "ApiKey": [], - "OAuth": [], - }, + "location": "base.yaml#/paths/~1orders/get/security/0/OAuth", + "value": [], }, "impact": "patch", - "key": "#/paths/~1orders/get/security/{ApiKey+OAuth}", - "kind": "removed", - "verdicts": [], - }, - { - "impact": "minor", - "key": "#/paths/~1orders/get/security/{ApiKey}", - "kind": "added", + "key": "#/paths/~1orders/get/security/0", + "kind": "modified", + "property": "OAuth", "revision": { "location": "revision.yaml#/paths/~1orders/get/security/0", - "value": { - "ApiKey": [], - }, + "value": undefined, }, "verdicts": [], }, @@ -316,7 +349,7 @@ describe('security-requirement-changed', () => { }, }, "impact": "patch", - "key": "#/paths/~1orders/get/security/{ApiKey}", + "key": "#/paths/~1orders/get/security/0", "kind": "removed", "verdicts": [], }, diff --git a/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts b/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts index a73cc9db3a..2428f7bc18 100644 --- a/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts +++ b/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts @@ -5,24 +5,23 @@ import { createConfig } from '../../../config/index.js'; import { makeDocumentFromString } from '../../../resolve.js'; import { diffDocuments } from '../../index.js'; -const cafe = (apiKey: string) => outdent` +const cafe = (schemes: string) => outdent` openapi: 3.1.0 info: { title: Cafe, version: 1.0.0 } paths: {} components: - securitySchemes: - ApiKey: ${apiKey} + securitySchemes: ${schemes} `; +const apiKey = 'ApiKey: { type: apiKey, in: header, name: X-Api-Key }'; +const bearer = 'Bearer: { type: http, scheme: bearer }'; + describe('security-scheme-changed', () => { it('should report a field clients authenticate with', async () => { - const result = diffDocuments({ - base: makeDocumentFromString( - cafe('{ type: apiKey, in: header, name: X-Api-Key }'), - 'base.yaml' - ), + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${apiKey} }`), 'base.yaml'), revision: makeDocumentFromString( - cafe('{ type: apiKey, in: header, name: X-Cafe-Key }'), + cafe('{ ApiKey: { type: apiKey, in: header, name: X-Cafe-Key } }'), 'revision.yaml' ), config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }), @@ -57,12 +56,12 @@ describe('security-scheme-changed', () => { }); it('should report a scheme of another type once, not for every field it drags along', async () => { - const result = diffDocuments({ - base: makeDocumentFromString( - cafe('{ type: apiKey, in: header, name: X-Api-Key }'), - 'base.yaml' + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${apiKey} }`), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ ApiKey: { type: http, scheme: bearer } }'), + 'revision.yaml' ), - revision: makeDocumentFromString(cafe('{ type: http, scheme: bearer }'), 'revision.yaml'), config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }), }); @@ -138,4 +137,104 @@ describe('security-scheme-changed', () => { ] `); }); + + it('should report a field that a scheme gains or loses', async () => { + const withFormat = 'Bearer: { type: http, scheme: bearer, bearerFormat: JWT }'; + const gained = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${bearer} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${withFormat} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }), + }); + const lost = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${withFormat} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${bearer} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(gained.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/securitySchemes/Bearer", + "value": undefined, + }, + "impact": "major", + "key": "#/components/securitySchemes/Bearer", + "kind": "modified", + "property": "bearerFormat", + "revision": { + "location": "revision.yaml#/components/securitySchemes/Bearer/bearerFormat", + "value": "JWT", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/components/securitySchemes/Bearer/bearerFormat", + "message": "\`bearerFormat\` of security scheme \`Bearer\` was set to 'JWT'.", + "ruleId": "security-scheme-changed", + }, + ], + }, + ] + `); + expect(replaceSourceWithRefInChanges(lost.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/securitySchemes/Bearer/bearerFormat", + "value": "JWT", + }, + "impact": "major", + "key": "#/components/securitySchemes/Bearer", + "kind": "modified", + "property": "bearerFormat", + "revision": { + "location": "revision.yaml#/components/securitySchemes/Bearer", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/components/securitySchemes/Bearer", + "message": "\`bearerFormat\` of security scheme \`Bearer\` was removed.", + "ruleId": "security-scheme-changed", + }, + ], + }, + ] + `); + }); + + it('should report a security scheme that is gone', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${apiKey}, ${bearer} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${apiKey} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/securitySchemes/Bearer", + "value": { + "scheme": "bearer", + "type": "http", + }, + }, + "impact": "major", + "key": "#/components/securitySchemes/Bearer", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/components/securitySchemes/Bearer", + "message": "Security scheme \`Bearer\` was removed.", + "ruleId": "security-scheme-changed", + }, + ], + }, + ] + `); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/security-scheme-removed.test.ts b/packages/core/src/diff/rules/__tests__/security-scheme-removed.test.ts deleted file mode 100644 index 56e1e34462..0000000000 --- a/packages/core/src/diff/rules/__tests__/security-scheme-removed.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { outdent } from 'outdent'; - -import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; -import { createConfig } from '../../../config/index.js'; -import { makeDocumentFromString } from '../../../resolve.js'; -import { diffDocuments } from '../../index.js'; - -const cafe = (schemes: string) => outdent` - openapi: 3.1.0 - info: { title: Cafe, version: 1.0.0 } - paths: {} - components: - securitySchemes: ${schemes} -`; - -const apiKey = 'ApiKey: { type: apiKey, in: header, name: X-Api-Key }'; -const bearer = 'Bearer: { type: http, scheme: bearer }'; - -describe('security-scheme-removed', () => { - it('should report a security scheme that is gone', async () => { - const result = diffDocuments({ - base: makeDocumentFromString(cafe(`{ ${apiKey}, ${bearer} }`), 'base.yaml'), - revision: makeDocumentFromString(cafe(`{ ${apiKey} }`), 'revision.yaml'), - config: await createConfig({ diff: { 'security-scheme-removed': 'major' } }), - }); - - expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` - [ - { - "base": { - "location": "base.yaml#/components/securitySchemes/Bearer", - "value": { - "scheme": "bearer", - "type": "http", - }, - }, - "impact": "major", - "key": "#/components/securitySchemes/Bearer", - "kind": "removed", - "verdicts": [ - { - "impact": "major", - "location": "base.yaml#/components/securitySchemes/Bearer", - "message": "Security scheme \`Bearer\` was removed.", - "ruleId": "security-scheme-removed", - }, - ], - }, - ] - `); - }); -}); diff --git a/packages/core/src/diff/rules/__tests__/server-removed.test.ts b/packages/core/src/diff/rules/__tests__/server-removed.test.ts index bc7653936f..b9862afa93 100644 --- a/packages/core/src/diff/rules/__tests__/server-removed.test.ts +++ b/packages/core/src/diff/rules/__tests__/server-removed.test.ts @@ -17,7 +17,7 @@ const sandbox = 'sandbox: { host: sandbox.cafe.example, protocol: amqp }'; describe('server-removed', () => { it('should report a server that is gone', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${production}, ${sandbox} }`), 'base.yaml'), revision: makeDocumentFromString(cafe(`{ ${production} }`), 'revision.yaml'), config: await createConfig({ diff: { 'server-removed': 'major' } }), @@ -50,7 +50,7 @@ describe('server-removed', () => { }); it('should report every server leaving with the whole map', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`{ ${production} }`), 'base.yaml'), revision: makeDocumentFromString(cafe(), 'revision.yaml'), config: await createConfig({ diff: { 'server-removed': 'major' } }), @@ -83,6 +83,153 @@ describe('server-removed', () => { ] `); }); + + it('should report an AsyncAPI server that moved to another host', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ production: { host: kitchen.cafe.test, protocol: amqp } }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ production: { host: kitchen.cafe.dev, protocol: amqp } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers/production/host", + "value": "kitchen.cafe.test", + }, + "impact": "major", + "key": "#/servers/production", + "kind": "modified", + "property": "host", + "revision": { + "location": "revision.yaml#/servers/production/host", + "value": "kitchen.cafe.dev", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/servers/production/host", + "message": "\`host\` of server \`production\` changed from 'kitchen.cafe.test' to 'kitchen.cafe.dev'.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + }); + + it('should report an AsyncAPI server that gained or lost its pathname', async () => { + const withoutPathname = '{ production: { host: broker.cafe.example, protocol: amqp } }'; + const withPathname = + '{ production: { host: broker.cafe.example, pathname: /v1, protocol: amqp } }'; + const config = await createConfig({ diff: { 'server-removed': 'major' } }); + + const gained = await diffDocuments({ + base: makeDocumentFromString(cafe(withoutPathname), 'base.yaml'), + revision: makeDocumentFromString(cafe(withPathname), 'revision.yaml'), + config, + }); + const lost = await diffDocuments({ + base: makeDocumentFromString(cafe(withPathname), 'base.yaml'), + revision: makeDocumentFromString(cafe(withoutPathname), 'revision.yaml'), + config, + }); + + expect(replaceSourceWithRefInChanges(gained.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers/production", + "value": undefined, + }, + "impact": "major", + "key": "#/servers/production", + "kind": "modified", + "property": "pathname", + "revision": { + "location": "revision.yaml#/servers/production/pathname", + "value": "/v1", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/servers/production/pathname", + "message": "\`pathname\` of server \`production\` was set to '/v1'.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + expect(replaceSourceWithRefInChanges(lost.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers/production/pathname", + "value": "/v1", + }, + "impact": "major", + "key": "#/servers/production", + "kind": "modified", + "property": "pathname", + "revision": { + "location": "revision.yaml#/servers/production", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/servers/production", + "message": "\`pathname\` of server \`production\` was removed.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + }); + + it('should not report the components servers map', async () => { + const withComponents = (components: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: {} + components: ${components} + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(withComponents(`{ servers: { ${production} } }`), 'base.yaml'), + revision: makeDocumentFromString(withComponents('{}'), 'revision.yaml'), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/servers", + "value": { + "production": { + "host": "broker.cafe.example", + "protocol": "amqp", + }, + }, + }, + "impact": "patch", + "key": "#/components/servers", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); }); describe('server-removed for OpenAPI', () => { @@ -100,7 +247,7 @@ describe('server-removed for OpenAPI', () => { const sandbox = '{ url: https://sandbox.cafe.example }'; it('should report a server that is gone, whatever its position in the list', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`[${sandbox}, ${production}]`), 'base.yaml'), revision: makeDocumentFromString(cafe(`[${production}]`), 'revision.yaml'), config: await createConfig({ diff: { 'server-removed': 'major' } }), @@ -116,7 +263,7 @@ describe('server-removed for OpenAPI', () => { }, }, "impact": "major", - "key": "#/servers/{https:~1~1sandbox.cafe.example}", + "key": "#/servers/0", "kind": "removed", "verdicts": [ { @@ -132,7 +279,7 @@ describe('server-removed for OpenAPI', () => { }); it('should report the document losing its servers list', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`[${production}]`), 'base.yaml'), revision: makeDocumentFromString(cafe(), 'revision.yaml'), config: await createConfig({ diff: { 'server-removed': 'major' } }), @@ -166,7 +313,7 @@ describe('server-removed for OpenAPI', () => { }); it('should not report an operation losing its own servers, which falls back to the document', async () => { - const result = diffDocuments({ + const result = await diffDocuments({ base: makeDocumentFromString(cafe(`[${production}]`, `[${sandbox}]`), 'base.yaml'), revision: makeDocumentFromString(cafe(`[${production}]`), 'revision.yaml'), config: await createConfig({ diff: { 'server-removed': 'major' } }), @@ -191,4 +338,39 @@ describe('server-removed for OpenAPI', () => { ] `); }); + + it('should report a server that moved to another URL', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('[{ url: https://api.cafe.test }]'), 'base.yaml'), + revision: makeDocumentFromString(cafe('[{ url: https://api.cafe.dev }]'), 'revision.yaml'), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers/0/url", + "value": "https://api.cafe.test", + }, + "impact": "major", + "key": "#/servers/0", + "kind": "modified", + "property": "url", + "revision": { + "location": "revision.yaml#/servers/0/url", + "value": "https://api.cafe.dev", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/servers/0/url", + "message": "Server \`https://api.cafe.test\` became \`https://api.cafe.dev\`.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + }); }); diff --git a/packages/core/src/diff/rules/__tests__/string-constraint-changed.test.ts b/packages/core/src/diff/rules/__tests__/string-constraint-changed.test.ts deleted file mode 100644 index 0ef285c0ab..0000000000 --- a/packages/core/src/diff/rules/__tests__/string-constraint-changed.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { outdent } from 'outdent'; - -import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; -import { createConfig } from '../../../config/index.js'; -import { makeDocumentFromString } from '../../../resolve.js'; -import { diffDocuments } from '../../index.js'; - -const cafe = (note: string) => outdent` - openapi: 3.1.0 - info: { title: Cafe, version: 1.0.0 } - paths: - /orders: - post: - requestBody: - content: - application/json: - schema: ${note} - responses: - '201': - description: Created - content: - application/json: - schema: ${note} -`; - -describe('string-constraint-changed', () => { - it('should report a length limit that makes a request accept less', async () => { - const result = diffDocuments({ - base: makeDocumentFromString(cafe('{ type: string, maxLength: 200 }'), 'base.yaml'), - revision: makeDocumentFromString(cafe('{ type: string, maxLength: 100 }'), 'revision.yaml'), - config: await createConfig({ diff: { 'string-constraint-changed': 'major' } }), - }); - - expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` - [ - { - "base": { - "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength", - "value": 200, - }, - "impact": "major", - "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", - "kind": "modified", - "property": "maxLength", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength", - "value": 100, - }, - "verdicts": [ - { - "impact": "major", - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength", - "message": "\`maxLength\` changed from '200' to '100'.", - "ruleId": "string-constraint-changed", - }, - ], - }, - { - "base": { - "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxLength", - "value": 200, - }, - "impact": "patch", - "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", - "kind": "modified", - "property": "maxLength", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxLength", - "value": 100, - }, - "verdicts": [], - }, - ] - `); - }); - - it('should treat any pattern change as accepting less, since patterns cannot be compared', async () => { - const result = diffDocuments({ - base: makeDocumentFromString(cafe("{ type: string, pattern: '^[a-z]+$' }"), 'base.yaml'), - revision: makeDocumentFromString( - cafe("{ type: string, pattern: '^[a-z ]+$' }"), - 'revision.yaml' - ), - config: await createConfig({ diff: { 'string-constraint-changed': 'major' } }), - }); - - expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` - [ - { - "base": { - "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern", - "value": "^[a-z]+$", - }, - "impact": "major", - "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", - "kind": "modified", - "property": "pattern", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern", - "value": "^[a-z ]+$", - }, - "verdicts": [ - { - "impact": "major", - "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern", - "message": "\`pattern\` changed from '^[a-z]+$' to '^[a-z ]+$'.", - "ruleId": "string-constraint-changed", - }, - ], - }, - { - "base": { - "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/pattern", - "value": "^[a-z]+$", - }, - "impact": "patch", - "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", - "kind": "modified", - "property": "pattern", - "revision": { - "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/pattern", - "value": "^[a-z ]+$", - }, - "verdicts": [], - }, - ] - `); - }); -}); diff --git a/packages/core/src/diff/rules/additional-properties-changed.ts b/packages/core/src/diff/rules/additional-properties-changed.ts deleted file mode 100644 index 696212bf38..0000000000 --- a/packages/core/src/diff/rules/additional-properties-changed.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { constraintRule } from './utils.js'; - -export const AdditionalPropertiesChanged = constraintRule(['additionalProperties']); diff --git a/packages/core/src/diff/rules/channel-address-changed.ts b/packages/core/src/diff/rules/channel-address-changed.ts index e1625ff5a7..d135619ac8 100644 --- a/packages/core/src/diff/rules/channel-address-changed.ts +++ b/packages/core/src/diff/rules/channel-address-changed.ts @@ -1,15 +1,16 @@ +import { nameOf } from '../diff-node.js'; import type { DiffRule } from '../types.js'; -import { describeChange, nameOf } from './utils.js'; export const ChannelAddressChanged: DiffRule = () => ({ Channel(change, { report }) { if (change.kind !== 'modified' || change.property !== 'address') return; - report({ - message: describeChange( - `Channel \`${nameOf(change.node)}\` address`, - change.base.value, - change.revision.value - ), - }); + + const subject = `\`address\` of channel \`${nameOf(change.node)}\``; + const { value: before } = change.base; + const { value: after } = change.revision; + + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); }, }); diff --git a/packages/core/src/diff/rules/channel-removed.ts b/packages/core/src/diff/rules/channel-removed.ts index fcc51fddf2..bc072d441b 100644 --- a/packages/core/src/diff/rules/channel-removed.ts +++ b/packages/core/src/diff/rules/channel-removed.ts @@ -1,14 +1,16 @@ +import { nameOf } from '../diff-node.js'; import type { DiffRule } from '../types.js'; -import { nameOf } from './utils.js'; +import { isRootField } from './utils.js'; -// A channel is where AsyncAPI messages travel, so both sides of it break together and the -// rule does not read the direction. export const ChannelRemoved: DiffRule = () => ({ Channel(change, { report }) { - if (change.kind === 'removed') + if (change.kind === 'removed') { report({ message: `Channel \`${nameOf(change.node)}\` was removed.` }); + } }, NamedChannels(change, { report }) { - if (change.kind === 'removed') report({ message: 'All channels were removed.' }); + if (change.kind === 'removed' && isRootField(change.node)) { + report({ message: 'All channels were removed.' }); + } }, }); diff --git a/packages/core/src/diff/rules/enum-values-added.ts b/packages/core/src/diff/rules/enum-values-added.ts index c6d9bf986d..8206b408b9 100644 --- a/packages/core/src/diff/rules/enum-values-added.ts +++ b/packages/core/src/diff/rules/enum-values-added.ts @@ -1,12 +1,16 @@ +import { itemsNotIn } from '../../utils/items-not-in.js'; import type { DiffRule } from '../types.js'; -import { itemsOnlyIn, ofSchema, quoted } from './utils.js'; +import { ofNamedSchema } from './utils.js'; export const EnumValuesAdded: DiffRule = () => ({ - Schema(change, { report, directions }) { + Schema(change, { report, getDirections }) { if (change.kind !== 'modified' || change.property !== 'enum') return; - if (!directions.includes('response')) return; - const added = itemsOnlyIn(change.revision.value, change.base.value); - if (added.length) - report({ message: `Enum${ofSchema(change.node)} gained values: ${quoted(added)}.` }); + if (!getDirections().includes('response')) return; + + const added = itemsNotIn(change.revision.value, change.base.value); + if (!added.length) return; + + const values = added.map((value) => `'${value}'`).join(', '); + report({ message: `Enum${ofNamedSchema(change.node)} gained values: ${values}.` }); }, }); diff --git a/packages/core/src/diff/rules/enum-values-removed.ts b/packages/core/src/diff/rules/enum-values-removed.ts index 0111505b5c..726dea08f7 100644 --- a/packages/core/src/diff/rules/enum-values-removed.ts +++ b/packages/core/src/diff/rules/enum-values-removed.ts @@ -1,12 +1,16 @@ +import { itemsNotIn } from '../../utils/items-not-in.js'; import type { DiffRule } from '../types.js'; -import { itemsOnlyIn, ofSchema, quoted } from './utils.js'; +import { ofNamedSchema } from './utils.js'; export const EnumValuesRemoved: DiffRule = () => ({ - Schema(change, { report, directions }) { + Schema(change, { report, getDirections }) { if (change.kind !== 'modified' || change.property !== 'enum') return; - if (!directions.includes('request')) return; - const removed = itemsOnlyIn(change.base.value, change.revision.value); - if (removed.length) - report({ message: `Enum${ofSchema(change.node)} lost values: ${quoted(removed)}.` }); + if (!getDirections().includes('request')) return; + + const removed = itemsNotIn(change.base.value, change.revision.value); + if (!removed.length) return; + + const values = removed.map((value) => `'${value}'`).join(', '); + report({ message: `Enum${ofNamedSchema(change.node)} lost values: ${values}.` }); }, }); diff --git a/packages/core/src/diff/rules/index.ts b/packages/core/src/diff/rules/index.ts index 03a5c6f1fb..4bd1f62b66 100644 --- a/packages/core/src/diff/rules/index.ts +++ b/packages/core/src/diff/rules/index.ts @@ -1,5 +1,4 @@ import type { DiffRule } from '../types.js'; -import { AdditionalPropertiesChanged } from './additional-properties-changed.js'; import { ChannelAddressChanged } from './channel-address-changed.js'; import { ChannelRemoved } from './channel-removed.js'; import { EnumValuesAdded } from './enum-values-added.js'; @@ -7,15 +6,14 @@ import { EnumValuesRemoved } from './enum-values-removed.js'; import { MediaTypeRemoved } from './media-type-removed.js'; import { MessageContentTypeChanged } from './message-content-type-changed.js'; import { MessageRemoved } from './message-removed.js'; -import { NumericRangeChanged } from './numeric-range-changed.js'; import { OperationActionChanged } from './operation-action-changed.js'; +import { OperationIdChanged } from './operation-id-changed.js'; import { OperationRemoved } from './operation-removed.js'; import { ParameterBecameRequired } from './parameter-became-required.js'; import { ParameterRemoved } from './parameter-removed.js'; import { ParameterSerializationChanged } from './parameter-serialization-changed.js'; import { PathRemoved } from './path-removed.js'; import { PropertyRemoved } from './property-removed.js'; -import { RefTargetChanged } from './ref-target-changed.js'; import { RequestBodyBecameRequired } from './request-body-became-required.js'; import { RequestBodyRemoved } from './request-body-removed.js'; import { RequiredPropertiesAdded } from './required-properties-added.js'; @@ -23,18 +21,13 @@ import { RequiredPropertiesRemoved } from './required-properties-removed.js'; import { ResponseHeaderRemoved } from './response-header-removed.js'; import { ResponseRemoved } from './response-removed.js'; import { SchemaCombinatorChanged } from './schema-combinator-changed.js'; -import { SchemaFormatChanged } from './schema-format-changed.js'; +import { SchemaConstraintChanged } from './schema-constraint-changed.js'; import { SchemaTypeChanged } from './schema-type-changed.js'; import { SecurityRequirementChanged } from './security-requirement-changed.js'; import { SecuritySchemeChanged } from './security-scheme-changed.js'; -import { SecuritySchemeRemoved } from './security-scheme-removed.js'; import { ServerRemoved } from './server-removed.js'; -import { StringConstraintChanged } from './string-constraint-changed.js'; -/** - * Every rule over a `Schema` node, shared by both registries: an AsyncAPI payload is the same - * node type, judged by the same questions. - */ +// An AsyncAPI 3 payload is a `Schema` node too. const schemaRules = { 'schema-type-changed': SchemaTypeChanged, 'enum-values-removed': EnumValuesRemoved, @@ -42,14 +35,12 @@ const schemaRules = { 'required-properties-added': RequiredPropertiesAdded, 'required-properties-removed': RequiredPropertiesRemoved, 'property-removed': PropertyRemoved, - 'numeric-range-changed': NumericRangeChanged, - 'string-constraint-changed': StringConstraintChanged, - 'schema-format-changed': SchemaFormatChanged, - 'additional-properties-changed': AdditionalPropertiesChanged, + 'schema-constraint-changed': SchemaConstraintChanged, } satisfies Record; export const oas3Rules = { 'operation-removed': OperationRemoved, + 'operation-id-changed': OperationIdChanged, 'path-removed': PathRemoved, 'parameter-removed': ParameterRemoved, 'parameter-became-required': ParameterBecameRequired, @@ -61,18 +52,13 @@ export const oas3Rules = { 'media-type-removed': MediaTypeRemoved, 'security-requirement-changed': SecurityRequirementChanged, 'security-scheme-changed': SecuritySchemeChanged, - 'security-scheme-removed': SecuritySchemeRemoved, 'server-removed': ServerRemoved, - 'ref-target-changed': RefTargetChanged, ...schemaRules, 'schema-combinator-changed': SchemaCombinatorChanged, } satisfies Record; -/** - * An AsyncAPI 3 payload is a `Schema` node of the same shape the OpenAPI rules already judge, - * so the whole schema rule set is reused. The combinator rule is not: the shared JSON Schema - * type tree calls every combinator list `SchemaList`, so the keywords cannot be told apart. - */ +// No `schema-combinator-changed`: the JSON Schema type tree of AsyncAPI calls every combinator +// list `SchemaList`, so the rule could not tell the keywords apart. export const async3Rules = { 'channel-removed': ChannelRemoved, 'channel-address-changed': ChannelAddressChanged, @@ -81,12 +67,13 @@ export const async3Rules = { 'operation-removed': OperationRemoved, 'operation-action-changed': OperationActionChanged, 'server-removed': ServerRemoved, - 'ref-target-changed': RefTargetChanged, ...schemaRules, } satisfies Record; -/** The specification families diff rules exist for. */ -export const diffRuleSets = { oas3: oas3Rules, async3: async3Rules }; +export const diffRuleSets = { + oas3: oas3Rules, + async3: async3Rules, +}; export type DiffRuleSets = typeof diffRuleSets; export type DiffFamily = keyof DiffRuleSets; diff --git a/packages/core/src/diff/rules/media-type-removed.ts b/packages/core/src/diff/rules/media-type-removed.ts index 6b86ece772..c7a0c5d580 100644 --- a/packages/core/src/diff/rules/media-type-removed.ts +++ b/packages/core/src/diff/rules/media-type-removed.ts @@ -1,13 +1,23 @@ +import { nameOf } from '../diff-node.js'; import type { DiffRule } from '../types.js'; -import { nameOf } from './utils.js'; -// Removing every media type lands as one change on the map, removing one lands on the media type. +// A media type of a component nothing references travels neither way, so it breaks no client. export const MediaTypeRemoved: DiffRule = () => ({ - MediaType(change, { report }) { - if (change.kind === 'removed') + MediaType(change, { report, getDirections }) { + if (getDirections().length === 0) return; + + if (change.kind === 'removed') { report({ message: `Media type \`${nameOf(change.node)}\` was removed.` }); + } + if (change.kind === 'modified' && change.property === 'key') { + report({ + message: `Media type \`${change.base.value}\` became \`${change.revision.value}\`.`, + }); + } }, - MediaTypesMap(change, { report }) { - if (change.kind === 'removed') report({ message: 'All media types were removed.' }); + MediaTypesMap(change, { report, getDirections }) { + if (change.kind === 'removed' && getDirections().length > 0) { + report({ message: 'All media types were removed.' }); + } }, }); diff --git a/packages/core/src/diff/rules/message-content-type-changed.ts b/packages/core/src/diff/rules/message-content-type-changed.ts index 851d00f3da..3f7142de7a 100644 --- a/packages/core/src/diff/rules/message-content-type-changed.ts +++ b/packages/core/src/diff/rules/message-content-type-changed.ts @@ -1,15 +1,16 @@ +import { nameOf } from '../diff-node.js'; import type { DiffRule } from '../types.js'; -import { describeChange, nameOf } from './utils.js'; export const MessageContentTypeChanged: DiffRule = () => ({ Message(change, { report }) { if (change.kind !== 'modified' || change.property !== 'contentType') return; - report({ - message: describeChange( - `Message \`${nameOf(change.node)}\` content type`, - change.base.value, - change.revision.value - ), - }); + + const subject = `\`contentType\` of message \`${nameOf(change.node)}\``; + const { value: before } = change.base; + const { value: after } = change.revision; + + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); }, }); diff --git a/packages/core/src/diff/rules/message-removed.ts b/packages/core/src/diff/rules/message-removed.ts index 5ea94cecce..b9714f901f 100644 --- a/packages/core/src/diff/rules/message-removed.ts +++ b/packages/core/src/diff/rules/message-removed.ts @@ -1,15 +1,19 @@ +import { nameOf } from '../diff-node.js'; import type { DiffRule } from '../types.js'; -import { nameOf } from './utils.js'; export const MessageRemoved: DiffRule = () => ({ Message(change, { report }) { - if (change.kind === 'removed') + if (change.kind === 'removed') { report({ message: `Message \`${nameOf(change.node)}\` was removed.` }); + } }, - NamedMessages(change, { report }) { - if (change.kind === 'removed') - report({ - message: `All messages of channel \`${nameOf(change.node.parent!)}\` were removed.`, - }); + Channel: { + NamedMessages(change, { report }) { + if (change.kind === 'removed') { + report({ + message: `All messages of channel \`${nameOf(change.node.parent!)}\` were removed.`, + }); + } + }, }, }); diff --git a/packages/core/src/diff/rules/numeric-range-changed.ts b/packages/core/src/diff/rules/numeric-range-changed.ts deleted file mode 100644 index 651b54766a..0000000000 --- a/packages/core/src/diff/rules/numeric-range-changed.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { fieldOf } from '../../node-tree/access.js'; -import type { NodeEntry } from '../../node-tree/types.js'; -import { dequal } from '../../utils/dequal.js'; -import type { DiffRule } from '../types.js'; -import { breakingDirection, describeChange, judgeConstraint, ofSchema } from './utils.js'; - -type Bound = { value: number; exclusive: boolean }; - -// `sign` makes a greater difference mean a tighter bound on both sides: a higher minimum and a -// lower maximum each accept fewer values. -const LOWER = { - name: 'Lower bound', - inclusive: 'minimum', - exclusive: 'exclusiveMinimum', - operator: '>', - sign: 1, -}; -const UPPER = { - name: 'Upper bound', - inclusive: 'maximum', - exclusive: 'exclusiveMaximum', - operator: '<', - sign: -1, -}; -type Side = typeof LOWER; - -/** Above zero when `bound` accepts fewer values than `other`, below zero when it accepts more. */ -function compareTightness(bound: Bound, other: Bound, side: Side): number { - if (bound.value !== other.value) return (bound.value - other.value) * side.sign; - return Number(bound.exclusive) - Number(other.exclusive); -} - -// OpenAPI 3.0 makes `minimum` exclusive with `exclusiveMinimum: true`; OpenAPI 3.1 gives -// `exclusiveMinimum` a value of its own, and the tighter of the two keywords is the bound. -function boundOf(schema: NodeEntry | undefined, side: Side): Bound | undefined { - const inclusive = fieldOf(schema, side.inclusive); - const exclusive = fieldOf(schema, side.exclusive); - if (typeof exclusive === 'number') { - const exclusiveBound = { value: exclusive, exclusive: true }; - if (typeof inclusive !== 'number') return exclusiveBound; - const inclusiveBound = { value: inclusive, exclusive: false }; - return compareTightness(inclusiveBound, exclusiveBound, side) > 0 - ? inclusiveBound - : exclusiveBound; - } - if (typeof inclusive === 'number') return { value: inclusive, exclusive: exclusive === true }; - return undefined; -} - -/** Above zero when the side accepts fewer values after the change, below zero when it accepts more. */ -function tighteningOf(before: Bound | undefined, after: Bound | undefined, side: Side): number { - if (!before) return 1; // a new bound - if (!after) return -1; // a removed bound - return compareTightness(after, before, side); -} - -function describeBound(bound: Bound | undefined, side: Side): string | undefined { - return bound && `${side.operator}${bound.exclusive ? '' : '='} ${bound.value}`; -} - -export const NumericRangeChanged: DiffRule = () => ({ - Schema(change, context) { - if (change.kind !== 'modified') return; - if (change.property === 'multipleOf') return judgeConstraint(change, context); - - const side = [LOWER, UPPER].find( - ({ inclusive, exclusive }) => change.property === inclusive || change.property === exclusive - ); - if (!side) return; - - // Both keywords of a side can change together, so the side is judged once: by its inclusive - // keyword when that one changed. - const { base, revision } = change.node; - const inclusiveChanged = !dequal( - fieldOf(base, side.inclusive), - fieldOf(revision, side.inclusive) - ); - if (change.property === side.exclusive && inclusiveChanged) return; - - const before = boundOf(base, side); - const after = boundOf(revision, side); - if (!before && !after) return; - const tightening = tighteningOf(before, after, side); - if (tightening === 0) return; - - if (context.directions.includes(breakingDirection(tightening > 0))) { - const subject = `${side.name}${ofSchema(change.node)}`; - context.report({ - message: describeChange(subject, describeBound(before, side), describeBound(after, side)), - }); - } - }, -}); diff --git a/packages/core/src/diff/rules/operation-action-changed.ts b/packages/core/src/diff/rules/operation-action-changed.ts index dbf0e48676..7fc20b3e48 100644 --- a/packages/core/src/diff/rules/operation-action-changed.ts +++ b/packages/core/src/diff/rules/operation-action-changed.ts @@ -1,17 +1,18 @@ +import { nameOf } from '../diff-node.js'; import type { DiffRule } from '../types.js'; -import { describeChange, nameOf } from './utils.js'; // AsyncAPI only: an operation states its own direction, and swapping it turns every // message of the channel around. export const OperationActionChanged: DiffRule = () => ({ Operation(change, { report }) { if (change.kind !== 'modified' || change.property !== 'action') return; - report({ - message: describeChange( - `Operation \`${nameOf(change.node)}\` action`, - change.base.value, - change.revision.value - ), - }); + + const subject = `\`action\` of operation \`${nameOf(change.node)}\``; + const { value: before } = change.base; + const { value: after } = change.revision; + + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); }, }); diff --git a/packages/core/src/diff/rules/operation-id-changed.ts b/packages/core/src/diff/rules/operation-id-changed.ts new file mode 100644 index 0000000000..ce575b77a7 --- /dev/null +++ b/packages/core/src/diff/rules/operation-id-changed.ts @@ -0,0 +1,24 @@ +import { nameOf } from '../diff-node.js'; +import type { DiffRule, DiffVisit } from '../types.js'; + +const operationIdChanged: DiffVisit = (change, { report }) => { + if (change.kind !== 'modified' || change.property !== 'operationId') return; + + const { value: before } = change.base; + const { value: after } = change.revision; + if (before === undefined) return; + + const method = nameOf(change.node).toUpperCase(); + const subject = `\`operationId\` of operation \`${method} ${nameOf(change.node.parent!)}\``; + + if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); +}; + +// Generated clients name their methods after `operationId`, so a changed or removed one breaks the +// code that calls them. A first `operationId` breaks nothing. +export const OperationIdChanged: DiffRule = () => ({ + Paths: { PathItem: { Operation: operationIdChanged } }, + WebhooksMap: { PathItem: { Operation: operationIdChanged } }, + CallbacksMap: { Callback: { PathItem: { Operation: operationIdChanged } } }, +}); diff --git a/packages/core/src/diff/rules/operation-removed.ts b/packages/core/src/diff/rules/operation-removed.ts index 536c0b5f9e..96181446d5 100644 --- a/packages/core/src/diff/rules/operation-removed.ts +++ b/packages/core/src/diff/rules/operation-removed.ts @@ -1,20 +1,31 @@ -import { typeOf } from '../diff-tree.js'; -import type { DiffNode, DiffRule } from '../types.js'; -import { nameOf } from './utils.js'; +import { nameOf } from '../diff-node.js'; +import type { DiffRule, DiffVisit } from '../types.js'; +import { isRootField } from './utils.js'; -// An OpenAPI operation is named by its method and path; an AsyncAPI one by its own key. -function describeOperation(operation: DiffNode): string { - const pathItem = operation.parent; - if (pathItem && typeOf(pathItem) === 'PathItem') { - return `${nameOf(operation).toUpperCase()} ${nameOf(pathItem)}`; - } - return nameOf(operation); -} +// An OpenAPI operation is named by its method and its path, or the URL of its callback. +const pathOperationRemoved: DiffVisit = (change, { report }) => { + if (change.kind !== 'removed') return; + + const method = nameOf(change.node).toUpperCase(); + report({ message: `Operation \`${method} ${nameOf(change.node.parent!)}\` was removed.` }); +}; export const OperationRemoved: DiffRule = () => ({ - Operation(change, { report }) { - if (change.kind === 'removed') { - report({ message: `Operation \`${describeOperation(change.node)}\` was removed.` }); - } + Paths: { PathItem: { Operation: pathOperationRemoved } }, + WebhooksMap: { PathItem: { Operation: pathOperationRemoved } }, + CallbacksMap: { Callback: { PathItem: { Operation: pathOperationRemoved } } }, + NamedOperations: { + Operation(change, { report }) { + if (change.kind === 'removed') { + report({ message: `Operation \`${nameOf(change.node)}\` was removed.` }); + } + }, + }, + Root: { + NamedOperations(change, { report }) { + if (change.kind === 'removed' && isRootField(change.node)) { + report({ message: 'All operations were removed.' }); + } + }, }, }); diff --git a/packages/core/src/diff/rules/parameter-became-required.ts b/packages/core/src/diff/rules/parameter-became-required.ts index e61a0ef3f8..a1b9f63aaf 100644 --- a/packages/core/src/diff/rules/parameter-became-required.ts +++ b/packages/core/src/diff/rules/parameter-became-required.ts @@ -1,32 +1,42 @@ -import { isPlainObject } from '../../utils/is-plain-object.js'; +import { fieldOf, locationOf } from '../../node-tree/access.js'; +import type { NodeEntry } from '../../node-tree/types.js'; +import type { Location } from '../../ref-utils.js'; +import { latestOf } from '../diff-node.js'; import type { DiffRule } from '../types.js'; -import { describeParameter } from './utils.js'; -function isRequired(parameter: unknown): boolean { - return isPlainObject(parameter) && parameter.required === true; -} - -// A client must now send a parameter that it did not have to: a new required one, or one that -// became required. The first parameter of an operation arrives with the whole `parameters` list, -// so that change lands on the list rather than on a parameter. +// The first parameters of an operation arrive with the whole `parameters` list, so each required +// one is reported on the list, at its own `required` field. export const ParameterBecameRequired: DiffRule = () => ({ - Parameter(change, { report, directions }) { - if (!directions.includes('request')) return; - if (change.kind === 'added' && isRequired(change.revision.value)) { - report({ message: `Required ${describeParameter(change.node)} was added.` }); + Parameter(change, { report, getDirections }) { + if (!getDirections().includes('request')) return; + + const parameter = latestOf(change.node); + const name = `\`${fieldOf(parameter, 'name')}\` ${fieldOf(parameter, 'in')} parameter`; + + if (change.kind === 'added' && fieldOf(parameter, 'required') === true) { + report({ message: `Required ${name} was added.`, location: requiredFieldOf(parameter) }); } if ( change.kind === 'modified' && change.property === 'required' && change.revision.value === true ) { - report({ message: `${describeParameter(change.node)} became required.` }); + report({ message: `${name} became required.` }); } }, - ParameterList(change, { report, directions }) { - if (change.kind !== 'added' || !directions.includes('request')) return; - if (Array.isArray(change.revision.value) && change.revision.value.some(isRequired)) { - report({ message: 'Required parameters were added.' }); + ParameterList(change, { report, getDirections }) { + if (change.kind !== 'added' || !getDirections().includes('request')) return; + + for (const parameter of latestOf(change.node).children) { + if (fieldOf(parameter, 'required') !== true) continue; + + const name = `\`${fieldOf(parameter, 'name')}\` ${fieldOf(parameter, 'in')} parameter`; + report({ message: `Required ${name} was added.`, location: requiredFieldOf(parameter) }); } }, }); + +// A `$ref` parameter is required where it points. +function requiredFieldOf(parameter: NodeEntry): Location { + return locationOf(parameter).child(['required']); +} diff --git a/packages/core/src/diff/rules/parameter-removed.ts b/packages/core/src/diff/rules/parameter-removed.ts index 09513e42fd..70224559a0 100644 --- a/packages/core/src/diff/rules/parameter-removed.ts +++ b/packages/core/src/diff/rules/parameter-removed.ts @@ -1,16 +1,38 @@ +import { fieldOf } from '../../node-tree/access.js'; +import { latestOf } from '../diff-node.js'; import type { DiffRule } from '../types.js'; -import { describeParameter } from './utils.js'; -// The last parameter of an operation leaves with the whole `parameters` list, so that change -// lands on the list rather than on a parameter. export const ParameterRemoved: DiffRule = () => ({ - Parameter(change, { report, directions }) { - if (change.kind === 'removed' && directions.includes('request')) { - report({ message: `${describeParameter(change.node)} was removed.` }); + Parameter(change, { report, getDirections }) { + if (!getDirections().includes('request')) return; + + if (change.kind === 'removed') { + const parameter = latestOf(change.node); + const name = `\`${fieldOf(parameter, 'name')}\` ${fieldOf(parameter, 'in')} parameter`; + report({ message: `${name} was removed.` }); + } + if (change.kind !== 'modified') return; + + // The client still sends the parameter as the base had it, so both reports name that one. + const oldName = fieldOf(change.node.base, 'name'); + const oldIn = fieldOf(change.node.base, 'in'); + const { value: before } = change.base; + const { value: after } = change.revision; + + if (change.property === 'in') { + report({ message: `\`${oldName}\` ${before} parameter is now a ${after} parameter.` }); + } + + // A path parameter is named for the docs only, and a header name is not case-sensitive, so + // renaming those asks nothing of the client. + const caseOnly = + oldIn === 'header' && String(before).toLowerCase() === String(after).toLowerCase(); + if (change.property === 'name' && oldIn !== 'path' && !caseOnly) { + report({ message: `\`${before}\` ${oldIn} parameter was renamed to \`${after}\`.` }); } }, - ParameterList(change, { report, directions }) { - if (change.kind === 'removed' && directions.includes('request')) { + ParameterList(change, { report, getDirections }) { + if (change.kind === 'removed' && getDirections().includes('request')) { report({ message: 'All parameters were removed.' }); } }, diff --git a/packages/core/src/diff/rules/parameter-serialization-changed.ts b/packages/core/src/diff/rules/parameter-serialization-changed.ts index 459cda2ac3..23599bc79e 100644 --- a/packages/core/src/diff/rules/parameter-serialization-changed.ts +++ b/packages/core/src/diff/rules/parameter-serialization-changed.ts @@ -1,20 +1,24 @@ +import { fieldOf } from '../../node-tree/access.js'; +import { latestOf } from '../diff-node.js'; import type { DiffRule } from '../types.js'; -import { describeChange, describeParameter } from './utils.js'; // How a value is put on the wire is part of the contract: a client that encoded // the old way is not understood after the change. const SERIALIZATION = new Set(['style', 'explode', 'allowReserved', 'allowEmptyValue']); export const ParameterSerializationChanged: DiffRule = () => ({ - Parameter(change, { report, directions }) { + Parameter(change, { report, getDirections }) { if (change.kind !== 'modified' || !SERIALIZATION.has(change.property)) return; - if (!directions.includes('request')) return; - report({ - message: describeChange( - `\`${change.property}\` of ${describeParameter(change.node)}`, - change.base.value, - change.revision.value - ), - }); + if (!getDirections().includes('request')) return; + + const parameter = latestOf(change.node); + const name = `\`${fieldOf(parameter, 'name')}\` ${fieldOf(parameter, 'in')} parameter`; + const subject = `\`${change.property}\` of ${name}`; + const { value: before } = change.base; + const { value: after } = change.revision; + + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); }, }); diff --git a/packages/core/src/diff/rules/path-removed.ts b/packages/core/src/diff/rules/path-removed.ts index a38e398693..9d9683f285 100644 --- a/packages/core/src/diff/rules/path-removed.ts +++ b/packages/core/src/diff/rules/path-removed.ts @@ -1,9 +1,60 @@ +import { nameOf } from '../diff-node.js'; import type { DiffRule } from '../types.js'; -import { nameOf } from './utils.js'; + +// `/menu/{id}` and `/menu/{itemId}` are one URL: a template names its variables for the docs only. +const templateShape = (path: unknown) => String(path).replace(/\{[^}]+\}/g, '{}'); export const PathRemoved: DiffRule = () => ({ - PathItem(change, { report }) { - if (change.kind === 'removed') - report({ message: `Path \`${nameOf(change.node)}\` was removed.` }); + Paths: { + PathItem(change, { report }) { + if (change.kind === 'removed') { + report({ message: `Path \`${nameOf(change.node)}\` was removed.` }); + } + if (change.kind !== 'modified' || change.property !== 'key') return; + + const { value: before } = change.base; + const { value: after } = change.revision; + if (templateShape(before) !== templateShape(after)) { + report({ message: `Path \`${before}\` was renamed to \`${after}\`.` }); + } + }, + }, + Root: { + Paths(change, { report }) { + if (change.kind === 'removed') report({ message: 'All paths were removed.' }); + }, + WebhooksMap(change, { report }) { + if (change.kind === 'removed') report({ message: 'All webhooks were removed.' }); + }, + }, + // A webhook's key names it for the docs, so only its removal is reported. + WebhooksMap: { + PathItem(change, { report }) { + if (change.kind === 'removed') { + report({ message: `Webhook \`${nameOf(change.node)}\` was removed.` }); + } + }, + }, + // A callback is the API calling the client back, judged like a webhook. The callbacks of + // `components` are reported where an operation uses them. + Operation: { + CallbacksMap(change, { report }) { + if (change.kind === 'removed') report({ message: 'All callbacks were removed.' }); + }, + Callback(change, { report }) { + if (change.kind === 'removed') { + report({ message: `Callback \`${nameOf(change.node)}\` was removed.` }); + } + }, + }, + CallbacksMap: { + Callback: { + PathItem(change, { report }) { + if (change.kind !== 'removed') return; + + const callback = nameOf(change.node.parent!); + report({ message: `Callback \`${callback}\` no longer calls \`${nameOf(change.node)}\`.` }); + }, + }, }, }); diff --git a/packages/core/src/diff/rules/property-removed.ts b/packages/core/src/diff/rules/property-removed.ts index 0455b08fb4..5e4e81f2e9 100644 --- a/packages/core/src/diff/rules/property-removed.ts +++ b/packages/core/src/diff/rules/property-removed.ts @@ -1,22 +1,28 @@ +import { nameOf } from '../diff-node.js'; import type { DiffRule } from '../types.js'; -import { nameOf, ofSchema } from './utils.js'; +import { ofNamedSchema } from './utils.js'; -// A member of a `properties` map; an alternative of a `oneOf` is a schema too, but not a property. -// Removing every property lands as one change on the `properties` map of the schema. export const PropertyRemoved: DiffRule = () => ({ SchemaProperties: { - Schema(change, { report, directions }) { - if (change.kind === 'removed' && directions.includes('response')) { - report({ - message: `Property \`${nameOf(change.node)}\`${ofSchema(change.node.parent?.parent)} was removed.`, - }); + Schema(change, { report, getDirections }) { + if (!getDirections().includes('response')) return; + + const owner = ofNamedSchema(change.node.parent?.parent); + + if (change.kind === 'removed') { + report({ message: `Property \`${nameOf(change.node)}\`${owner} was removed.` }); + } + if (change.kind === 'modified' && change.property === 'key') { + const { value: before } = change.base; + const { value: after } = change.revision; + report({ message: `Property \`${before}\`${owner} was renamed to \`${after}\`.` }); } }, }, Schema: { - SchemaProperties(change, { report, directions }) { - if (change.kind === 'removed' && directions.includes('response')) { - report({ message: `All properties${ofSchema(change.node.parent)} were removed.` }); + SchemaProperties(change, { report, getDirections }) { + if (change.kind === 'removed' && getDirections().includes('response')) { + report({ message: `All properties${ofNamedSchema(change.node.parent)} were removed.` }); } }, }, diff --git a/packages/core/src/diff/rules/ref-target-changed.ts b/packages/core/src/diff/rules/ref-target-changed.ts deleted file mode 100644 index 2e61e13264..0000000000 --- a/packages/core/src/diff/rules/ref-target-changed.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { valueOf } from '../../node-tree/access.js'; -import { dequal } from '../../utils/dequal.js'; -import type { DiffRule } from '../types.js'; - -function describeTarget(value: unknown): string { - return typeof value === 'string' ? `'${value}'` : 'an inline definition'; -} - -// Two targets are compared at their own places, not with each other, so a new target that -// describes other data cannot be told apart from a renamed one — unless both read the same. -export const RefTargetChanged: DiffRule = () => ({ - any(change, { report }) { - if (change.kind !== 'modified' || change.property !== '$ref') return; - const { base, revision } = change.node; - if (base && revision && dequal(valueOf(base), valueOf(revision))) return; - report({ - message: `Reference target changed from ${describeTarget(change.base.value)} to ${describeTarget( - change.revision.value - )}, which describes other data.`, - }); - }, -}); diff --git a/packages/core/src/diff/rules/request-body-became-required.ts b/packages/core/src/diff/rules/request-body-became-required.ts index 08f6044971..0bf7628d8e 100644 --- a/packages/core/src/diff/rules/request-body-became-required.ts +++ b/packages/core/src/diff/rules/request-body-became-required.ts @@ -1,9 +1,9 @@ import type { DiffRule } from '../types.js'; export const RequestBodyBecameRequired: DiffRule = () => ({ - RequestBody(change, { report, directions }) { + RequestBody(change, { report, getDirections }) { if (change.kind !== 'modified' || change.property !== 'required') return; - if (directions.includes('request') && change.revision.value === true) { + if (getDirections().includes('request') && change.revision.value === true) { report({ message: 'Request body became required.' }); } }, diff --git a/packages/core/src/diff/rules/request-body-removed.ts b/packages/core/src/diff/rules/request-body-removed.ts index 0d7c2af2a3..03293ddee2 100644 --- a/packages/core/src/diff/rules/request-body-removed.ts +++ b/packages/core/src/diff/rules/request-body-removed.ts @@ -1,8 +1,8 @@ import type { DiffRule } from '../types.js'; export const RequestBodyRemoved: DiffRule = () => ({ - RequestBody(change, { report, directions }) { - if (change.kind === 'removed' && directions.includes('request')) { + RequestBody(change, { report, getDirections }) { + if (change.kind === 'removed' && getDirections().includes('request')) { report({ message: 'Request body was removed.' }); } }, diff --git a/packages/core/src/diff/rules/required-properties-added.ts b/packages/core/src/diff/rules/required-properties-added.ts index 480eebeaf7..a80bf3e5f7 100644 --- a/packages/core/src/diff/rules/required-properties-added.ts +++ b/packages/core/src/diff/rules/required-properties-added.ts @@ -1,16 +1,20 @@ +import { itemsNotIn } from '../../utils/items-not-in.js'; import type { DiffRule } from '../types.js'; -import { itemsOnlyIn, named, ofSchema, propertiesMarked } from './utils.js'; +import { ofNamedSchema, propertiesMarkedAs } from './utils.js'; export const RequiredPropertiesAdded: DiffRule = () => ({ - Schema(change, { report, directions }) { + Schema(change, { report, getDirections }) { if (change.kind !== 'modified' || change.property !== 'required') return; - if (!directions.includes('request')) return; + if (!getDirections().includes('request')) return; + // A request does not send a `readOnly` property, so requiring it asks nothing of the client. - const readOnly = propertiesMarked(change.node, 'readOnly'); - const added = itemsOnlyIn(change.revision.value, change.base.value).filter( + const readOnly = propertiesMarkedAs(change.node, 'readOnly'); + const added = itemsNotIn(change.revision.value, change.base.value).filter( (name) => !readOnly.includes(String(name)) ); - if (added.length) - report({ message: `Properties${ofSchema(change.node)} became required: ${named(added)}.` }); + if (!added.length) return; + + const names = added.map((name) => `\`${name}\``).join(', '); + report({ message: `Properties${ofNamedSchema(change.node)} became required: ${names}.` }); }, }); diff --git a/packages/core/src/diff/rules/required-properties-removed.ts b/packages/core/src/diff/rules/required-properties-removed.ts index 95930931b4..b102f719ff 100644 --- a/packages/core/src/diff/rules/required-properties-removed.ts +++ b/packages/core/src/diff/rules/required-properties-removed.ts @@ -1,19 +1,22 @@ +import { itemsNotIn } from '../../utils/items-not-in.js'; import type { DiffRule } from '../types.js'; -import { itemsOnlyIn, named, ofSchema, propertiesMarked } from './utils.js'; +import { ofNamedSchema, propertiesMarkedAs } from './utils.js'; export const RequiredPropertiesRemoved: DiffRule = () => ({ - Schema(change, { report, directions }) { + Schema(change, { report, getDirections }) { if (change.kind !== 'modified' || change.property !== 'required') return; - if (!directions.includes('response')) return; + if (!getDirections().includes('response')) return; + // A response does not send a `writeOnly` property, so no client reads it. - const writeOnly = propertiesMarked(change.node, 'writeOnly'); - const removed = itemsOnlyIn(change.base.value, change.revision.value).filter( + const writeOnly = propertiesMarkedAs(change.node, 'writeOnly'); + const removed = itemsNotIn(change.base.value, change.revision.value).filter( (name) => !writeOnly.includes(String(name)) ); - if (removed.length) { - report({ - message: `Properties${ofSchema(change.node)} are no longer required: ${named(removed)}.`, - }); - } + if (!removed.length) return; + + const names = removed.map((name) => `\`${name}\``).join(', '); + report({ + message: `Properties${ofNamedSchema(change.node)} are no longer required: ${names}.`, + }); }, }); diff --git a/packages/core/src/diff/rules/response-header-removed.ts b/packages/core/src/diff/rules/response-header-removed.ts index e68d7545fa..3882af2284 100644 --- a/packages/core/src/diff/rules/response-header-removed.ts +++ b/packages/core/src/diff/rules/response-header-removed.ts @@ -1,19 +1,29 @@ +import { nameOf } from '../diff-node.js'; import type { DiffRule } from '../types.js'; -import { nameOf } from './utils.js'; -// Dropping every header collapses into a single change on the map, dropping one lands on -// the header itself. export const ResponseHeaderRemoved: DiffRule = () => ({ - Header(change, { report, directions }) { - if (change.kind === 'removed' && directions.includes('response')) { - report({ message: `Response header \`${nameOf(change.node)}\` was removed.` }); - } - }, - HeadersMap(change, { report, directions }) { - if (change.kind === 'removed' && directions.includes('response')) { - report({ - message: `All headers of response \`${nameOf(change.node.parent!)}\` were removed.`, - }); - } + Response: { + Header(change, { report, getDirections }) { + if (!getDirections().includes('response')) return; + + if (change.kind === 'removed') { + report({ message: `Response header \`${nameOf(change.node)}\` was removed.` }); + } + if (change.kind !== 'modified' || change.property !== 'key') return; + + // A header name is not case-sensitive. + const { value: before } = change.base; + const { value: after } = change.revision; + if (String(before).toLowerCase() !== String(after).toLowerCase()) { + report({ message: `Response header \`${before}\` was renamed to \`${after}\`.` }); + } + }, + HeadersMap(change, { report, getDirections }) { + if (change.kind === 'removed' && getDirections().includes('response')) { + report({ + message: `All headers of response \`${nameOf(change.node.parent!)}\` were removed.`, + }); + } + }, }, }); diff --git a/packages/core/src/diff/rules/response-removed.ts b/packages/core/src/diff/rules/response-removed.ts index 465b629910..761b020713 100644 --- a/packages/core/src/diff/rules/response-removed.ts +++ b/packages/core/src/diff/rules/response-removed.ts @@ -1,9 +1,18 @@ +import { nameOf } from '../diff-node.js'; import type { DiffRule } from '../types.js'; -import { nameOf } from './utils.js'; +// A component response is returned by no operation until one references it. export const ResponseRemoved: DiffRule = () => ({ - Response(change, { report }) { - if (change.kind === 'removed') - report({ message: `Response \`${nameOf(change.node)}\` was removed.` }); + Responses: { + Response(change, { report }) { + if (change.kind === 'removed') { + report({ message: `Response \`${nameOf(change.node)}\` was removed.` }); + } + if (change.kind === 'modified' && change.property === 'key') { + report({ + message: `Response \`${change.base.value}\` became \`${change.revision.value}\`.`, + }); + } + }, }, }); diff --git a/packages/core/src/diff/rules/schema-combinator-changed.ts b/packages/core/src/diff/rules/schema-combinator-changed.ts index 91c2a06d98..6ea4750de1 100644 --- a/packages/core/src/diff/rules/schema-combinator-changed.ts +++ b/packages/core/src/diff/rules/schema-combinator-changed.ts @@ -1,25 +1,37 @@ +import { nameOf } from '../diff-node.js'; import type { DiffRule, DiffVisit } from '../types.js'; -import { breakingDirection, ofSchema } from './utils.js'; +import { ofNamedSchema } from './utils.js'; -// `oneOf`/`anyOf` list alternatives, so dropping one accepts less; `allOf` combines -// constraints, so adding one accepts less. Only the OpenAPI type tree names each list after -// its keyword; the shared JSON Schema tree calls them all `SchemaList`, so there the rule -// could not tell the keywords apart and is not registered. +// `oneOf`/`anyOf` list alternatives, so dropping one accepts less; `allOf` combines constraints, +// so adding one accepts less. function combinatorChanged(combinator: 'allOf' | 'anyOf' | 'oneOf'): DiffVisit { - return (change, { report, directions }) => { + return (change, { report, getDirections }) => { if (change.kind === 'modified') return; + const removed = change.kind === 'removed'; const acceptsLess = combinator === 'allOf' ? !removed : removed; - if (directions.includes(breakingDirection(acceptsLess))) { - const owner = ofSchema(change.node.parent?.parent); - report({ - message: `\`${combinator}\` subschema${owner} was ${removed ? 'removed' : 'added'}.`, - }); - } + if (!getDirections().includes(acceptsLess ? 'request' : 'response')) return; + + const owner = ofNamedSchema(change.node.parent?.parent); + report({ + message: `\`${combinator}\` subschema${owner} was ${removed ? 'removed' : 'added'}.`, + }); }; } +// A whole list added constrains the schema, whichever keyword it is; removed, it frees it. +const listChanged: DiffVisit = (change, { report, getDirections }) => { + if (change.kind === 'modified') return; + + const added = change.kind === 'added'; + if (!getDirections().includes(added ? 'request' : 'response')) return; + + const owner = ofNamedSchema(change.node.parent); + report({ message: `\`${nameOf(change.node)}\`${owner} was ${added ? 'added' : 'removed'}.` }); +}; + export const SchemaCombinatorChanged: DiffRule = () => ({ + Schema: { AllOf: listChanged, AnyOf: listChanged, OneOf: listChanged }, AllOf: { Schema: combinatorChanged('allOf') }, AnyOf: { Schema: combinatorChanged('anyOf') }, OneOf: { Schema: combinatorChanged('oneOf') }, diff --git a/packages/core/src/diff/rules/schema-constraint-changed.ts b/packages/core/src/diff/rules/schema-constraint-changed.ts new file mode 100644 index 0000000000..1ebd241fd4 --- /dev/null +++ b/packages/core/src/diff/rules/schema-constraint-changed.ts @@ -0,0 +1,202 @@ +import { fieldOf, keyKindOf } from '../../node-tree/access.js'; +import type { NodeEntry } from '../../node-tree/types.js'; +import { dequal } from '../../utils/dequal.js'; +import { latestOf } from '../diff-node.js'; +import type { Change, DiffRule, DiffRuleContext, DiffVisit, Direction } from '../types.js'; +import { ofNamedSchema } from './utils.js'; + +type ModifiedChange = Extract; + +/** How a change moves the values a schema accepts; `unknown` where the two cannot be compared. */ +type Shift = 'fewer' | 'more' | 'unknown'; + +const LOWER_LIMITS = new Set(['minLength', 'minItems', 'minProperties']); +const UPPER_LIMITS = new Set(['maxLength', 'maxItems', 'maxProperties']); +// Two values of these cannot be compared, so any change is taken as both fewer and more values. +const OPAQUE = new Set([ + 'multipleOf', + 'pattern', + 'format', + 'const', + 'contentEncoding', + 'contentMediaType', +]); +// Flags that constrain the values only when set to this value. +const CONSTRAINING_FLAGS: Record = { + uniqueItems: true, + additionalProperties: false, + unevaluatedItems: false, + unevaluatedProperties: false, + items: false, +}; +// `if` is not here: it constrains nothing on its own. +const SUBSCHEMAS = new Set([ + 'not', + 'then', + 'else', + 'contains', + 'propertyNames', + 'items', + 'additionalProperties', + 'unevaluatedItems', + 'unevaluatedProperties', + 'prefixItems', + 'dependentSchemas', + 'dependentRequired', +]); + +type Bound = { value: number; exclusive: boolean }; + +// `sign` makes a greater difference mean a tighter bound on both sides: a higher minimum and a +// lower maximum each accept fewer values. +const LOWER = { + name: 'Lower bound', + inclusive: 'minimum', + exclusive: 'exclusiveMinimum', + operator: '>', + sign: 1, +}; +const UPPER = { + name: 'Upper bound', + inclusive: 'maximum', + exclusive: 'exclusiveMaximum', + operator: '<', + sign: -1, +}; +type Side = typeof LOWER; + +// Accepting fewer values breaks the clients that send them; accepting more, the ones that read +// them. +function breaks(shift: Shift, directions: Direction[]): boolean { + return ( + (shift !== 'more' && directions.includes('request')) || + (shift !== 'fewer' && directions.includes('response')) + ); +} + +function shiftOf(keyword: string, before: unknown, after: unknown): Shift | undefined { + if (Object.hasOwn(CONSTRAINING_FLAGS, keyword)) { + const wasConstrained = before === CONSTRAINING_FLAGS[keyword]; + const isConstrained = after === CONSTRAINING_FLAGS[keyword]; + if (wasConstrained === isConstrained) return undefined; + return isConstrained ? 'fewer' : 'more'; + } + if (!LOWER_LIMITS.has(keyword) && !UPPER_LIMITS.has(keyword) && !OPAQUE.has(keyword)) { + return undefined; + } + if (before === undefined) return 'fewer'; + if (after === undefined) return 'more'; + if (typeof before !== 'number' || typeof after !== 'number' || OPAQUE.has(keyword)) { + return 'unknown'; + } + return LOWER_LIMITS.has(keyword) === after > before ? 'fewer' : 'more'; +} + +/** Above zero when `bound` accepts fewer values than `other`, below zero when it accepts more. */ +function compareTightness(bound: Bound, other: Bound, side: Side): number { + if (bound.value !== other.value) return (bound.value - other.value) * side.sign; + return Number(bound.exclusive) - Number(other.exclusive); +} + +// OpenAPI 3.0 makes `minimum` exclusive with `exclusiveMinimum: true`; OpenAPI 3.1 gives +// `exclusiveMinimum` a value of its own, and the tighter of the two keywords is the bound. +function boundOf(schema: NodeEntry | undefined, side: Side): Bound | undefined { + const inclusive = fieldOf(schema, side.inclusive); + const exclusive = fieldOf(schema, side.exclusive); + if (typeof exclusive === 'number') { + const exclusiveBound = { value: exclusive, exclusive: true }; + if (typeof inclusive !== 'number') return exclusiveBound; + const inclusiveBound = { value: inclusive, exclusive: false }; + return compareTightness(inclusiveBound, exclusiveBound, side) > 0 + ? inclusiveBound + : exclusiveBound; + } + if (typeof inclusive === 'number') return { value: inclusive, exclusive: exclusive === true }; + return undefined; +} + +/** Above zero when the side accepts fewer values after the change, below zero when more. */ +function tighteningOf(before: Bound | undefined, after: Bound | undefined, side: Side): number { + if (!before) return 1; + if (!after) return -1; + return compareTightness(after, before, side); +} + +function describeBound(bound: Bound | undefined, side: Side): string | undefined { + return bound && `${side.operator}${bound.exclusive ? '' : '='} ${bound.value}`; +} + +// A subschema constrains the values: added, the schema accepts fewer; removed, it accepts more. +const judgeSubschema: DiffVisit = (change, { report, getDirections }) => { + const keyword = latestOf(change.node); + const key = String(keyword.key); + if (change.kind === 'modified' || keyKindOf(keyword) !== 'field' || !SUBSCHEMAS.has(key)) return; + + const added = change.kind === 'added'; + const parent = change.node.parent; + const other = added ? parent?.base : parent?.revision; + // A schema in place of `additionalProperties: false` accepts more; the flag's change reports it. + if (Object.hasOwn(CONSTRAINING_FLAGS, key) && fieldOf(other, key) === CONSTRAINING_FLAGS[key]) { + return; + } + if (!breaks(added ? 'fewer' : 'more', getDirections())) return; + + report({ message: `\`${key}\`${ofNamedSchema(parent)} was ${added ? 'added' : 'removed'}.` }); +}; + +function judgeKeyword(change: ModifiedChange, { report, getDirections }: DiffRuleContext): void { + const { value: before } = change.base; + const { value: after } = change.revision; + const shift = shiftOf(change.property, before, after); + if (!shift || !breaks(shift, getDirections())) return; + + const subject = `\`${change.property}\`${ofNamedSchema(change.node)}`; + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); +} + +// Both keywords of a side can change together, so the side is judged once: by its inclusive +// keyword when that one changed. +function judgeBound( + change: ModifiedChange, + side: Side, + { report, getDirections }: DiffRuleContext +): void { + const { base, revision } = change.node; + const inclusiveChanged = !dequal( + fieldOf(base, side.inclusive), + fieldOf(revision, side.inclusive) + ); + if (change.property === side.exclusive && inclusiveChanged) return; + + const beforeBound = boundOf(base, side); + const afterBound = boundOf(revision, side); + if (!beforeBound && !afterBound) return; + + const tightening = tighteningOf(beforeBound, afterBound, side); + if (tightening === 0 || !breaks(tightening > 0 ? 'fewer' : 'more', getDirections())) return; + + const subject = `${side.name}${ofNamedSchema(change.node)}`; + const before = describeBound(beforeBound, side); + const after = describeBound(afterBound, side); + + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); +} + +export const SchemaConstraintChanged: DiffRule = () => ({ + Schema(change, context) { + if (change.kind !== 'modified') return judgeSubschema(change, context); + + const side = [LOWER, UPPER].find( + ({ inclusive, exclusive }) => change.property === inclusive || change.property === exclusive + ); + if (side) judgeBound(change, side, context); + else judgeKeyword(change, context); + }, + SchemaList: judgeSubschema, + SchemaMap: judgeSubschema, + DependentRequired: judgeSubschema, +}); diff --git a/packages/core/src/diff/rules/schema-format-changed.ts b/packages/core/src/diff/rules/schema-format-changed.ts deleted file mode 100644 index 75802d3a4f..0000000000 --- a/packages/core/src/diff/rules/schema-format-changed.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { constraintRule } from './utils.js'; - -export const SchemaFormatChanged = constraintRule(['format']); diff --git a/packages/core/src/diff/rules/schema-type-changed.ts b/packages/core/src/diff/rules/schema-type-changed.ts index 1c64548e7e..cf0d6b9524 100644 --- a/packages/core/src/diff/rules/schema-type-changed.ts +++ b/packages/core/src/diff/rules/schema-type-changed.ts @@ -1,7 +1,7 @@ import { fieldOf } from '../../node-tree/access.js'; import { dequal } from '../../utils/dequal.js'; import type { DiffRule } from '../types.js'; -import { ofSchema } from './utils.js'; +import { ofNamedSchema } from './utils.js'; // `integer` accepts a subset of what `number` does, so it is the one implicit widening among // the JSON Schema primitive types. @@ -19,7 +19,6 @@ function acceptedTypes(type: unknown, nullable?: unknown): string[] { return addsNull ? [...types, 'null'] : types; } -/** The types of `types` that `accepted` does not take. */ function typesNotIn(types: string[], accepted: string[]): string[] { return types.filter((type) => { const wider = WIDER_TYPE[type]; @@ -28,11 +27,12 @@ function typesNotIn(types: string[], accepted: string[]): string[] { } export const SchemaTypeChanged: DiffRule = () => ({ - Schema(change, { report, directions }) { + Schema(change, { report, getDirections }) { if (change.kind !== 'modified') return; + const { base, revision } = change.node; - // `nullable: true` is OpenAPI 3.0's spelling of `type: [..., 'null']`, so a change to either - // is judged on what the node accepts as a whole, and once: by `type` when both changed. + // A change to `type` or `nullable` is judged on what the schema accepts as a whole, and once: + // by `type` when both changed. const typeChanged = !dequal(fieldOf(base, 'type'), fieldOf(revision, 'type')); if (change.property !== 'type' && (change.property !== 'nullable' || typeChanged)) return; @@ -43,11 +43,11 @@ export const SchemaTypeChanged: DiffRule = () => ({ const widened = before.length > 0 && (!after.length || typesNotIn(after, before).length > 0); const described = `from '${before.join(' | ') || 'any'}' to '${after.join(' | ') || 'any'}'`; - if (directions.includes('request') && narrowed) { - report({ message: `Type${ofSchema(change.node)} narrowed ${described}.` }); + if (getDirections().includes('request') && narrowed) { + report({ message: `Type${ofNamedSchema(change.node)} narrowed ${described}.` }); } - if (directions.includes('response') && widened) { - report({ message: `Type${ofSchema(change.node)} widened ${described}.` }); + if (getDirections().includes('response') && widened) { + report({ message: `Type${ofNamedSchema(change.node)} widened ${described}.` }); } }, }); diff --git a/packages/core/src/diff/rules/security-requirement-changed.ts b/packages/core/src/diff/rules/security-requirement-changed.ts index ba670cf946..2b999380a5 100644 --- a/packages/core/src/diff/rules/security-requirement-changed.ts +++ b/packages/core/src/diff/rules/security-requirement-changed.ts @@ -1,7 +1,7 @@ import { valueOf } from '../../node-tree/access.js'; import { isPlainObject } from '../../utils/is-plain-object.js'; +import { itemsNotIn } from '../../utils/items-not-in.js'; import type { DiffRule } from '../types.js'; -import { itemsOnlyIn, quoted } from './utils.js'; /** The schemes a client authenticates with, each with the scopes that its credentials hold. */ type Credentials = Record; @@ -15,7 +15,7 @@ function passesOne(credentials: Credentials, requirements: unknown): boolean { isPlainObject(requirement) && Object.entries(requirement).every( ([scheme, scopes]) => - scheme in credentials && itemsOnlyIn(scopes, credentials[scheme]).length === 0 + scheme in credentials && itemsNotIn(scopes, credentials[scheme]).length === 0 ) ); } @@ -46,15 +46,21 @@ export const SecurityRequirementChanged: DiffRule = () => ({ const credentials = change.node.base && valueOf(change.node.base); if (!isPlainObject(credentials) || passesOne(credentials, requirements)) return; + const schemes = Object.keys(credentials).join(' + '); if (change.kind === 'removed') { - const schemes = Object.keys(credentials).join(' + '); report({ message: `Security requirement \`${schemes}\` is no longer accepted.` }); + } else if (change.base.value === undefined) { + // A scheme the requirement did not have: a client must now hold it too. + report({ + message: `Security requirement \`${schemes}\` now requires \`${change.property}\`.`, + }); } else { - // A modified requirement keeps its schemes: the value of each one is its list of scopes. - const added = itemsOnlyIn(change.revision.value, change.base.value); + // The requirement keeps this scheme: its value is the list of scopes. + const added = itemsNotIn(change.revision.value, change.base.value); if (!added.length) return; + const scopes = added.map((scope) => `'${scope}'`).join(', '); report({ - message: `Security scheme \`${change.property}\` requires new scopes: ${quoted(added)}.`, + message: `Security scheme \`${change.property}\` requires new scopes: ${scopes}.`, }); } }, diff --git a/packages/core/src/diff/rules/security-scheme-changed.ts b/packages/core/src/diff/rules/security-scheme-changed.ts index 9ab51669fe..22efbd240f 100644 --- a/packages/core/src/diff/rules/security-scheme-changed.ts +++ b/packages/core/src/diff/rules/security-scheme-changed.ts @@ -1,6 +1,6 @@ import { fieldOf } from '../../node-tree/access.js'; +import { nameOf } from '../diff-node.js'; import type { DiffRule } from '../types.js'; -import { describeChange, nameOf } from './utils.js'; const SCHEME_IDENTITY = new Set([ 'type', @@ -13,6 +13,9 @@ const SCHEME_IDENTITY = new Set([ export const SecuritySchemeChanged: DiffRule = () => ({ SecurityScheme(change, { report }) { + if (change.kind === 'removed') { + report({ message: `Security scheme \`${nameOf(change.node)}\` was removed.` }); + } if (change.kind !== 'modified' || !SCHEME_IDENTITY.has(change.property)) return; // Switching the scheme's `type` drags its other fields along (an apiKey has @@ -20,12 +23,12 @@ export const SecuritySchemeChanged: DiffRule = () => ({ const typeChanged = fieldOf(change.node.base, 'type') !== fieldOf(change.node.revision, 'type'); if (typeChanged && change.property !== 'type') return; - report({ - message: describeChange( - `\`${change.property}\` of security scheme \`${nameOf(change.node)}\``, - change.base.value, - change.revision.value - ), - }); + const subject = `\`${change.property}\` of security scheme \`${nameOf(change.node)}\``; + const { value: before } = change.base; + const { value: after } = change.revision; + + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); }, }); diff --git a/packages/core/src/diff/rules/security-scheme-removed.ts b/packages/core/src/diff/rules/security-scheme-removed.ts deleted file mode 100644 index 04e75a0b38..0000000000 --- a/packages/core/src/diff/rules/security-scheme-removed.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { DiffRule } from '../types.js'; -import { nameOf } from './utils.js'; - -export const SecuritySchemeRemoved: DiffRule = () => ({ - SecurityScheme(change, { report }) { - if (change.kind === 'removed') - report({ message: `Security scheme \`${nameOf(change.node)}\` was removed.` }); - }, -}); diff --git a/packages/core/src/diff/rules/server-removed.ts b/packages/core/src/diff/rules/server-removed.ts index 4d8d118727..f61491ae90 100644 --- a/packages/core/src/diff/rules/server-removed.ts +++ b/packages/core/src/diff/rules/server-removed.ts @@ -1,30 +1,43 @@ import { fieldOf } from '../../node-tree/access.js'; -import { latestOf, typeOf } from '../diff-tree.js'; -import type { DiffNode, DiffRule } from '../types.js'; -import { nameOf } from './utils.js'; +import { latestOf, nameOf } from '../diff-node.js'; +import type { DiffRule, DiffVisit } from '../types.js'; +import { isRootField } from './utils.js'; -// An OpenAPI server sits in a list and is known by its URL; an AsyncAPI one by its key. -function describeServer(server: DiffNode): string { - const url = fieldOf(latestOf(server), 'url'); - return typeof url === 'string' ? url : nameOf(server); -} +const ADDRESS = new Set(['host', 'pathname', 'protocol']); + +// An OpenAPI path or operation that loses its own `servers` falls back to the document's, so only +// the document's list counts. +const allServersRemoved: DiffVisit = (change, { report }) => { + if (change.kind === 'removed' && isRootField(change.node)) { + report({ message: 'All servers were removed.' }); + } +}; -// Dropping every server collapses into a single change on the map or list, dropping one lands -// on the server itself. An OpenAPI path or operation that loses its own `servers` falls back to -// the document's, so only the document's list counts. export const ServerRemoved: DiffRule = () => ({ Server(change, { report }) { if (change.kind === 'removed') { - report({ message: `Server \`${describeServer(change.node)}\` was removed.` }); + const url = fieldOf(latestOf(change.node), 'url'); + const name = typeof url === 'string' ? url : nameOf(change.node); + report({ message: `Server \`${name}\` was removed.` }); } - }, - ServerMap(change, { report }) { - if (change.kind === 'removed') report({ message: 'All servers were removed.' }); - }, - ServerList(change, { report }) { - const isDocumentList = change.node.parent && typeOf(change.node.parent) === 'Root'; - if (change.kind === 'removed' && isDocumentList) { - report({ message: 'All servers were removed.' }); + if (change.kind !== 'modified') return; + + // An OpenAPI server is known by its URL; an AsyncAPI one by its key, and it moves with its + // `host`, `pathname`, or `protocol`. + const { value: before } = change.base; + const { value: after } = change.revision; + + if (change.property === 'url') { + report({ message: `Server \`${before}\` became \`${after}\`.` }); + } + if (ADDRESS.has(change.property)) { + const subject = `\`${change.property}\` of server \`${nameOf(change.node)}\``; + + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); } }, + ServerMap: allServersRemoved, + ServerList: allServersRemoved, }); diff --git a/packages/core/src/diff/rules/string-constraint-changed.ts b/packages/core/src/diff/rules/string-constraint-changed.ts deleted file mode 100644 index 0678fb82fd..0000000000 --- a/packages/core/src/diff/rules/string-constraint-changed.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { constraintRule } from './utils.js'; - -export const StringConstraintChanged = constraintRule(['minLength', 'maxLength', 'pattern']); diff --git a/packages/core/src/diff/rules/utils.ts b/packages/core/src/diff/rules/utils.ts index 6acb97d0f8..d32e1ca328 100644 --- a/packages/core/src/diff/rules/utils.ts +++ b/packages/core/src/diff/rules/utils.ts @@ -1,113 +1,34 @@ import { fieldOf } from '../../node-tree/access.js'; -import { latestOf, typeOf } from '../diff-tree.js'; -import type { DiffNode, DiffRule, DiffVisit, Direction } from '../types.js'; +import type { NodeEntry } from '../../node-tree/types.js'; +import { comparedSidesOf, latestOf, nameOf, typeOf } from '../diff-node.js'; +import type { DiffNode } from '../types.js'; -/** What a node is called in its document: its key, or the key of the node a `$ref` item points at. */ -export function nameOf(node: DiffNode): string { - const entry = latestOf(node); - return String(entry.target?.key ?? entry.key); -} +/** `` of `Order` `` for a property or a component schema; nothing for an inline one. */ +export function ofNamedSchema(schema: DiffNode | null | undefined): string { + if (!schema) return ''; -/** ` of `name`` for a property or a named component schema, and nothing for an inline one. */ -export function ofSchema(node: DiffNode | null | undefined): string { - const container = node?.parent && typeOf(node.parent); - if (container !== 'SchemaProperties' && container !== 'NamedSchemas') return ''; - return ` of \`${nameOf(node!)}\``; + const container = latestOf(comparedSidesOf(schema)).parent?.type.name; + const isNamed = container === 'SchemaProperties' || container === 'NamedSchemas'; + return isNamed ? ` of \`${nameOf(schema)}\`` : ''; } -/** The names of the properties of `schema` that are marked with `keyword`, such as `readOnly`. */ -export function propertiesMarked(schema: DiffNode, keyword: 'readOnly' | 'writeOnly'): string[] { - const properties = schema.children.find((child) => typeOf(child) === 'SchemaProperties'); - return (properties?.children ?? []) - .filter((property) => fieldOf(latestOf(property), keyword) === true) - .map(nameOf); -} +export function propertiesMarkedAs(schema: DiffNode, keyword: 'readOnly' | 'writeOnly'): string[] { + const latestByName = new Map(); + const sides = comparedSidesOf(schema); -/** A parameter as a message names it, such as ``\`limit\` query parameter``. */ -export function describeParameter(node: DiffNode): string { - const location = fieldOf(latestOf(node), 'in'); - const name = fieldOf(latestOf(node), 'name'); - return [ - typeof name === 'string' ? `\`${name}\`` : undefined, - typeof location === 'string' ? location : undefined, - 'parameter', - ] - .filter(Boolean) - .join(' '); -} - -/** The values of a list, each quoted, for a message. */ -export function quoted(values: unknown[]): string { - return values.map((value) => `'${value}'`).join(', '); -} - -/** The names of a list, each in backticks, for a message. */ -export function named(values: unknown[]): string { - return values.map((value) => `\`${value}\``).join(', '); -} - -/** The items of `list` that `other` does not have. */ -export function itemsOnlyIn(list: unknown, other: unknown): unknown[] { - if (!Array.isArray(list)) return []; - const otherItems = Array.isArray(other) ? other : []; - return list.filter((item) => !otherItems.includes(item)); -} - -/** Accepting less breaks the clients that send the data; accepting more, the ones that read it. */ -export function breakingDirection(acceptsLess: boolean): Direction { - return acceptsLess ? 'request' : 'response'; -} - -const LOWER_BOUNDS = new Set(['minLength', 'minItems', 'minProperties']); -const UPPER_BOUNDS = new Set(['maxLength', 'maxItems', 'maxProperties']); -// Equivalence of these cannot be decided by comparing values, so any change to one is taken -// as accepting less rather than guessed at. -const OPAQUE = new Set(['pattern', 'format', 'multipleOf']); - -/** Whether a schema accepts less once `constraint` moved from `before` to `after`. */ -export function acceptsLess(constraint: string, before: unknown, after: unknown): boolean { - if (before === undefined) return true; // a new constraint - if (after === undefined) return false; // one dropped - // Only opening a closed object accepts more; swapping in a schema is narrower than an open one. - if (constraint === 'additionalProperties') return !(before === false && after === true); - if (OPAQUE.has(constraint)) return true; - - if (typeof before === 'number' && typeof after === 'number') { - if (LOWER_BOUNDS.has(constraint)) return after > before; - if (UPPER_BOUNDS.has(constraint)) return after < before; + for (const side of [sides.base, sides.revision]) { + const properties = side?.children.find((child) => child.type.name === 'SchemaProperties'); + for (const property of properties?.children ?? []) { + latestByName.set(String(property.key), property); + } } - return true; -} -/** How a value moved, for a message: set where it was absent, dropped, or changed. */ -export function describeChange(subject: string, before: unknown, after: unknown): string { - if (before === undefined) return `${subject} was added with value '${after}'.`; - if (after === undefined) return `${subject} was removed.`; - return `${subject} changed from '${before}' to '${after}'.`; + return [...latestByName.keys()].filter( + (name) => fieldOf(latestByName.get(name), keyword) === true + ); } -/** Reports a constraint that moved so that the schema accepts less in a request, or more in a response. */ -export const judgeConstraint: DiffVisit = (change, { report, directions }) => { - if (change.kind !== 'modified') return; - const before = change.base.value; - const after = change.revision.value; - if (directions.includes(breakingDirection(acceptsLess(change.property, before, after)))) { - const subject = `\`${change.property}\`${ofSchema(change.node)}`; - report({ message: describeChange(subject, before, after) }); - } -}; - -/** - * A rule over one group of constraints on a value. The groups stay separate rules so a report - * can name the constraint that actually moved. - */ -export function constraintRule(constraints: string[]): DiffRule { - const watched = new Set(constraints); - return () => ({ - Schema(change, context) { - if (change.kind === 'modified' && watched.has(change.property)) { - judgeConstraint(change, context); - } - }, - }); +// `components` has maps of the same types as the document; only the document's own count. +export function isRootField(node: DiffNode): boolean { + return node.parent !== null && typeOf(node.parent) === 'Root'; } diff --git a/packages/core/src/diff/specs/async3.ts b/packages/core/src/diff/specs/async3.ts deleted file mode 100644 index da9cf798f6..0000000000 --- a/packages/core/src/diff/specs/async3.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { enclosing, fieldOf } from '../../node-tree/access.js'; -import { opposite } from '../direction.js'; -import type { Direction, Directions } from '../types.js'; - -/** - * `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 it is judged as a response. - */ -function actionDirection(action: unknown): Direction | undefined { - if (action === 'receive') return 'request'; - if (action === 'send') return 'response'; - return undefined; -} - -// Channels and their messages sit outside the operations, so they take the direction of the -// operations that reference them. -export const async3Directions: Directions = { - Operation: (operation) => actionDirection(fieldOf(operation, 'action')), - // A reply answers the operation, so it travels back the other way. - OperationReply: (reply) => { - const direction = actionDirection(fieldOf(enclosing(reply, 'Operation'), 'action')); - return direction && opposite(direction); - }, -}; diff --git a/packages/core/src/diff/specs/index.ts b/packages/core/src/diff/specs/index.ts deleted file mode 100644 index 4c8ec8b11e..0000000000 --- a/packages/core/src/diff/specs/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { DiffFamily } from '../rules/index.js'; -import type { Directions, Identities } from '../types.js'; -import { async3Directions } from './async3.js'; -import { oas3Directions, oas3Identities } from './oas3.js'; - -/** Every family with diff rules; any other family is compared by its structure alone. */ -export const diffSpecs: Record = { - oas3: { identities: oas3Identities, directions: oas3Directions }, - // Channels, messages and operations are named maps, so a node's own key is its identity. - async3: { identities: {}, directions: async3Directions }, -}; diff --git a/packages/core/src/diff/specs/oas3.ts b/packages/core/src/diff/specs/oas3.ts deleted file mode 100644 index 1de0c5b3e1..0000000000 --- a/packages/core/src/diff/specs/oas3.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { enclosing, fieldOf, valueOf } from '../../node-tree/access.js'; -import type { NodeEntry } from '../../node-tree/types.js'; -import { escapePointerFragment } from '../../ref-utils.js'; -import { isPlainObject } from '../../utils/is-plain-object.js'; -import { templateParameterNames, templateShape } from '../../utils/path-template.js'; -import { opposite } from '../direction.js'; -import type { Direction, Directions, Identities } from '../types.js'; - -export const oas3Identities: Identities = { - Paths: pathIdentity, - ParameterList: parameterIdentity, - ServerList: (server) => named(fieldOf(server, 'url')), - TagList: (tag) => named(fieldOf(tag, 'name')), - SecurityRequirementList: schemeNamesIdentity, -}; - -export const oas3Directions: Directions = { - RequestBody: side('request'), - ParameterList: side('request'), - Parameter: side('request'), - Responses: side('response'), - Response: side('response'), - Schema: accessSide, -}; - -// "readOnly" data only comes from the API and "writeOnly" data only goes to it (OpenAPI, Schema -// Object), wherever the schema is used. -function accessSide(schema: NodeEntry): Direction | undefined { - if (fieldOf(schema, 'readOnly') === true) return 'response'; - if (fieldOf(schema, 'writeOnly') === true) return 'request'; - return undefined; -} - -/** - * The data a node describes travels `direction` — the other way round under a callback or a - * webhook, which the API sends to the consumer itself. A component is compared at its own - * place, so its position says nothing and only the sites that reference it tell. - */ -function side(direction: Direction) { - return (node: NodeEntry): Direction | undefined => { - if (enclosing(node, 'Components')) return undefined; - const sentByApi = enclosing(node, 'CallbacksMap') ?? enclosing(node, 'WebhooksMap'); - return sentByApi ? opposite(direction) : direction; - }; -} - -// "Templated paths with the same hierarchy but different templated names MUST NOT exist -// as they are identical" (OpenAPI, Paths Object) — the shape is the identity, the names are not. -function pathIdentity(path: NodeEntry): string { - return escapePointerFragment(templateShape(String(path.key))); -} - -function parameterIdentity(parameter: NodeEntry): string | undefined { - const location = fieldOf(parameter, 'in'); - const name = fieldOf(parameter, 'name'); - if (typeof location !== 'string' || typeof name !== 'string') return undefined; - return location === 'path' - ? pathParameterSegment(name, parameter) - : `{${escapePointerFragment(location)}:${escapePointerFragment(name)}}`; -} - -/** - * "If `in` is 'path', the `name` field MUST correspond to a template expression occurring - * within the path" (OpenAPI, Parameter Object) — so the position of that expression is the - * identity. A name the template does not contain (an invalid document, or a callback whose - * key is a runtime expression) falls back to the name. - */ -function pathParameterSegment(name: string, parameter: NodeEntry): string { - const pathTemplate = String(enclosing(parameter, 'PathItem')?.key ?? ''); - const position = templateParameterNames(pathTemplate).indexOf(name); - return position === -1 ? `{path:${escapePointerFragment(name)}}` : `{path:${position}}`; -} - -function schemeNamesIdentity(requirement: NodeEntry): string | undefined { - const value = valueOf(requirement); - if (!isPlainObject(value)) return undefined; - return `{${Object.keys(value).sort().map(escapePointerFragment).join('+')}}`; -} - -function named(value: unknown): string | undefined { - return typeof value === 'string' ? `{${escapePointerFragment(value)}}` : undefined; -} diff --git a/packages/core/src/diff/types.ts b/packages/core/src/diff/types.ts index 0e7087d6ff..d0d51c3f83 100644 --- a/packages/core/src/diff/types.ts +++ b/packages/core/src/diff/types.ts @@ -1,69 +1,68 @@ import type { NodeEntry } from '../node-tree/types.js'; import type { SpecVersion } from '../oas-types.js'; import type { Location } from '../ref-utils.js'; +import type { NormalizedProblem } from '../walk.js'; export type Impact = 'patch' | 'minor' | 'major'; -/** Decides which side breaks: a request accepting less, or a response returning more. */ export type Direction = 'request' | 'response'; +export type Pair = { base?: NodeEntry; revision?: NodeEntry }; + /** One place in both documents; `base` or `revision` is missing where it was added or removed. */ export type DiffNode = { base?: NodeEntry; revision?: NodeEntry; parent: DiffNode | null; - children: DiffNode[]; - /** Segments from the root joined like a pointer; `#n` marks the n-th sibling with one segment. */ - label: string; + /** The places that reach this pair after `parent` did; it is compared under `parent` only. */ + referencedBy: DiffNode[]; }; -/** - * Per container type: the segment that identifies a child, where its key or position does not. - * It replaces the child's key when the two documents are matched up. - */ -export type Identities = Partial string | undefined>>; - -/** Per node type: which way the data below it travels; the nearest type that says it wins. */ -export type Directions = Partial< - Record Direction | undefined)> ->; - export type LocatedNode = { location: Location; value: unknown }; -/** `key` is the label of the node the change is on. */ export type Change = { key: string; node: DiffNode } & ( | { kind: 'added'; revision: LocatedNode } | { kind: 'removed'; base: LocatedNode } - | { kind: 'modified'; property: string; base: LocatedNode; revision: LocatedNode } + | { + kind: 'modified'; + property: string; + base: LocatedNode; + revision: LocatedNode; + } ); export type DiffRuleContext = { - /** `location` defaults to the changed side; a rule may point at a finer node. */ report: (report: { message: string; location?: Location }) => void; - /** Empty where the data travels neither way. */ - directions: Direction[]; + location: Location; + getDirections: () => Direction[]; specVersion: SpecVersion; }; export type DiffVisit = (change: Change, context: DiffRuleContext) => void; - -/** - * Keyed by node type and nested the way lint visitors are: `SchemaProperties: { Schema }` runs - * for a schema that is a member of a properties map. `any`, at the top only, runs for every change. - */ export type DiffVisitor = { [type: string]: DiffVisit | DiffVisitor }; export type DiffRule = () => DiffVisitor; -export type RuleVerdict = { ruleId: string; impact: Impact; message: string; location: Location }; +export type RuleVerdict = { + ruleId: string; + impact: Impact; + message: string; + location: Location; +}; export type JudgedChange = Change & { impact: Impact; verdicts: RuleVerdict[] }; export type DiffSummary = Record; export type DiffResult = { files: { base: string; revision: string }; - specVersions: { base: SpecVersion; revision: SpecVersion }; - /** The `info.version` each document declares, when it is a string. */ - infoVersions: { base?: string; revision?: string }; + specVersions: { + base: SpecVersion; + revision: SpecVersion; + }; + infoVersions: { + base?: string; + revision?: string; + }; summary: DiffSummary; bump?: Impact; changes: JudgedChange[]; + problems: NormalizedProblem[]; }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3fbb6e8707..04263a8678 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -142,7 +142,7 @@ export { } from './diff/types.js'; export { impactRank, impacts } from './diff/impact.js'; export { displaySide } from './diff/changes.js'; -export { typeOf } from './diff/diff-tree.js'; +export { nameOf, typeOf } from './diff/diff-node.js'; export { HandledError, AbortFlowError } from './utils/error.js'; export { isSupportedExtension } from './utils/is-supported-extension.js'; export { isBrowser } from './env.js'; diff --git a/packages/core/src/node-tree/__tests__/access.test.ts b/packages/core/src/node-tree/__tests__/access.test.ts new file mode 100644 index 0000000000..e2c99a9536 --- /dev/null +++ b/packages/core/src/node-tree/__tests__/access.test.ts @@ -0,0 +1,82 @@ +import { outdent } from 'outdent'; + +import { resolverWithFiles } from '../../../__tests__/utils.js'; +import { enclosing, keyKindOf, locationOf, valueOf } from '../access.js'; +import { buildTree, cafe, nodeAt } from './utils.js'; + +const menuItemDocument = () => + cafe(outdent` + paths: + /menu: + get: + parameters: + - { name: item, in: query, schema: { $ref: '#/components/schemas/MenuItem' } } + components: + schemas: + MenuItem: + type: object + properties: + name: { type: string } + `); + +describe('node access', () => { + it('should give what a $ref points at, or else the node itself, for its value and location', async () => { + const { root } = await buildTree(menuItemDocument()); + const menuItemRef = nodeAt(root, '#/paths/~1menu/get/parameters/0/schema')!; + const menuItem = nodeAt(root, '#/components/schemas/MenuItem')!; + + expect(valueOf(menuItemRef)).toBe(menuItem.value); + expect(locationOf(menuItemRef)).toBe(menuItem.location); + expect(valueOf(menuItem)).toBe(menuItem.value); + expect(locationOf(menuItem)).toBe(menuItem.location); + }); + + it('should tell a field of the type from a map entry and a list item', async () => { + const { root } = await buildTree(menuItemDocument()); + + expect(keyKindOf(nodeAt(root, '#/paths')!)).toBe('field'); + expect(keyKindOf(nodeAt(root, '#/paths/~1menu')!)).toBe('entry'); + expect(keyKindOf(nodeAt(root, '#/paths/~1menu/get/parameters/0')!)).toBe('item'); + }); + + it('should climb from a node in another file to the $ref that reaches it', async () => { + const externalRefResolver = resolverWithFiles({ 'menu.yaml': 'get: {}' }); + const { root } = await buildTree( + cafe(outdent` + paths: + /menu: { $ref: menu.yaml } + `), + externalRefResolver + ); + const getOperation = nodeAt(root, '#/paths/~1menu')!.resolved!.children[0]; + + expect(enclosing(getOperation, 'Paths')).toBe(nodeAt(root, '#/paths')); + expect(enclosing(getOperation, 'Components')).toBeUndefined(); + }); + + it('should end the climb where a $ref leads back into the node', async () => { + const externalRefResolver = resolverWithFiles({ + 'menu.yaml': '{ $ref: ./item.yaml, summary: Menu }', + 'item.yaml': outdent` + get: + responses: + '200': { description: OK } + callbacks: + onEvent: + '{$request.body#/url}': { $ref: '#' } + `, + }); + const { root } = await buildTree( + cafe(outdent` + paths: + /menu: { $ref: ./menu.yaml } + `), + externalRefResolver + ); + const item = nodeAt(root, '#/paths/~1menu')!.resolved!.resolved!; + const responses = nodeAt(item, '#/get/responses')!; + + expect(enclosing(responses, 'Paths')).toBe(nodeAt(root, '#/paths')); + expect(enclosing(responses, 'Components')).toBeUndefined(); + }); +}); diff --git a/packages/core/src/node-tree/__tests__/build.test.ts b/packages/core/src/node-tree/__tests__/build.test.ts deleted file mode 100644 index dbde6c7293..0000000000 --- a/packages/core/src/node-tree/__tests__/build.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { outdent } from 'outdent'; - -import { createConfig } from '../../config/index.js'; -import { detectSpec } from '../../detect-spec.js'; -import { getTypes } from '../../oas-types.js'; -import { makeDocumentFromString } from '../../resolve.js'; -import { normalizeTypes } from '../../types/index.js'; -import { buildNodeTree } from '../build.js'; - -async function buildTree(yaml: string) { - const document = makeDocumentFromString(yaml, 'cafe.yaml'); - const config = await createConfig({}); - const specVersion = detectSpec(document.parsed); - const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); - return buildNodeTree({ document, types, specVersion }); -} - -const cafe = (menuItemRef: string) => outdent` - openapi: 3.1.0 - info: { title: Cafe, version: 1.0.0 } - paths: - /menu: - get: - parameters: - - { name: limit, in: query } - responses: - '200': - description: OK - content: - application/json: - schema: { $ref: '${menuItemRef}' } - components: - schemas: - Menu Item: { type: object } -`; - -describe('buildNodeTree', () => { - it('should build the document as a tree of typed nodes with their keys, values and locations', async () => { - const { root } = await buildTree(cafe('#/components/schemas/Menu%20Item')); - const [info, paths, components] = root.children; - const [menu] = paths.children; - const [listMenu] = menu.children; - const [parameters] = listMenu.children; - const [limit] = parameters.children; - - expect(root.type).toBe('Root'); - expect(info.type).toBe('Info'); - expect(components.type).toBe('Components'); - expect(menu).toMatchObject({ type: 'PathItem', key: '/menu', parent: paths }); - expect(parameters).toMatchObject({ type: 'ParameterList', key: 'parameters' }); - expect(limit).toMatchObject({ - type: 'Parameter', - key: 0, - value: { name: 'limit', in: 'query' }, - parent: parameters, - }); - expect(limit.location.pointer).toBe('#/paths/~1menu/get/parameters/0'); - }); - - it('should make a $ref a node of the type its place expects, pointing at its target', async () => { - const { root, references } = await buildTree(cafe('#/components/schemas/Menu%20Item')); - const [, , components] = root.children; - const [schemas] = components.children; - const [menuItem] = schemas.children; - const [reference] = references; - - expect(references).toHaveLength(1); - expect(reference.from).toMatchObject({ - type: 'Schema', - key: 'schema', - value: { $ref: '#/components/schemas/Menu%20Item' }, - target: menuItem, - }); - expect(reference.to).toBe(menuItem); - }); - - it('should leave a $ref that does not resolve without a target', async () => { - const { references } = await buildTree(cafe('#/components/schemas/Dessert')); - - expect(references).toEqual([]); - }); -}); diff --git a/packages/core/src/node-tree/__tests__/index.test.ts b/packages/core/src/node-tree/__tests__/index.test.ts new file mode 100644 index 0000000000..7b5dc614b8 --- /dev/null +++ b/packages/core/src/node-tree/__tests__/index.test.ts @@ -0,0 +1,221 @@ +import { outdent } from 'outdent'; + +import { resolverWithFiles } from '../../../__tests__/utils.js'; +import { isRef } from '../../ref-utils.js'; +import type { NodeEntry } from '../types.js'; +import { buildTree, cafe, nodeAt } from './utils.js'; + +function refsIn(node: NodeEntry, seen = new Set()): NodeEntry[] { + if (seen.has(node)) return []; + seen.add(node); + const reached = node.resolved ? [...node.children, node.resolved] : node.children; + return [...(isRef(node.value) ? [node] : []), ...reached.flatMap((child) => refsIn(child, seen))]; +} + +describe('buildNodeTree', () => { + it('should record a node with its type, key and value under the node it is written in', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: + /menu: + get: + parameters: + - { name: limit, in: query } + `) + ); + + expect(nodeAt(root, '#/paths/~1menu/get/parameters/0')).toMatchObject({ + type: { name: 'Parameter' }, + key: 0, + value: { name: 'limit', in: 'query' }, + parent: nodeAt(root, '#/paths/~1menu/get/parameters'), + }); + }); + + it('should make a $ref a node of the type its place expects, pointing at its target', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: + /menu: + get: + parameters: + - { name: item, in: query, schema: { $ref: '#/components/schemas/MenuItem' } } + components: + schemas: + MenuItem: { type: string } + `) + ); + + expect(nodeAt(root, '#/paths/~1menu/get/parameters/0/schema')).toMatchObject({ + type: { name: 'Schema' }, + resolved: { location: { pointer: '#/components/schemas/MenuItem' } }, + }); + }); + + it('should place a target where it is written, not under the $ref that reaches it first', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: + /menu: + get: + parameters: + - { name: item, in: query, schema: { $ref: '#/components/schemas/MenuItem' } } + components: + schemas: + MenuItem: { type: string } + `) + ); + + expect(nodeAt(root, '#/components/schemas/MenuItem')).toMatchObject({ + key: 'MenuItem', + parent: nodeAt(root, '#/components/schemas'), + }); + }); + + it('should place a target where it is written when a $ref before it in the same map reaches it first', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: {} + components: + schemas: + Receipt: { $ref: '#/components/schemas/Order' } + Order: { type: object } + `) + ); + + expect(nodeAt(root, '#/components/schemas/Order')).toMatchObject({ + key: 'Order', + parent: nodeAt(root, '#/components/schemas'), + }); + }); + + it('should place a node inside a component where it is written when a $ref reaches it first', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: + /orders: + get: + parameters: + - { name: total, in: query, schema: { $ref: '#/components/schemas/Order/properties/total' } } + components: + schemas: + Order: + properties: + total: { type: number } + `) + ); + + expect(nodeAt(root, '#/components/schemas/Order/properties/total')).toMatchObject({ + key: 'total', + parent: nodeAt(root, '#/components/schemas/Order/properties'), + }); + }); + + it('should leave a $ref that does not resolve without a target and report it', async () => { + const { root, problems } = await buildTree( + cafe(outdent` + paths: + /menu: + get: + parameters: + - { name: item, in: query, schema: { $ref: '#/components/schemas/Dessert' } } + `) + ); + const dessertRef = '#/paths/~1menu/get/parameters/0/schema'; + + expect(nodeAt(root, dessertRef)!.resolved).toBeUndefined(); + expect(problems).toMatchObject([ + { ruleId: 'no-unresolved-refs', location: [{ pointer: dessertRef }] }, + ]); + }); + + it('should keep a target in another file without a parent, named by the $ref that reaches it', async () => { + const externalRefResolver = resolverWithFiles({ 'menu.yaml': 'get: {}' }); + + const { root } = await buildTree( + cafe(outdent` + paths: + /menu: { $ref: menu.yaml } + `), + externalRefResolver + ); + + const menuRef = nodeAt(root, '#/paths/~1menu')!; + expect(menuRef.resolved).toMatchObject({ key: '/menu', parent: null }); + expect(menuRef.resolved!.referencedFrom).toBe(menuRef); + }); + + it('should give the root no way up when a $ref inside it points at it', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: {} + components: + schemas: + Everything: { $ref: '#/' } + `) + ); + + expect(root.referencedFrom).toBeUndefined(); + }); + + it('should link every $ref that meets its target before the target is built', async () => { + const externalRefResolver = resolverWithFiles({ + 'menu.yaml': '{ $ref: ./item.yaml, summary: Menu }', + 'item.yaml': outdent` + get: + callbacks: + onEvent: + '{$request.body#/url}': { $ref: ./menu.yaml } + `, + }); + + const { root } = await buildTree( + cafe(outdent` + paths: + /menu: { $ref: ./menu.yaml } + /other: { $ref: ./item.yaml } + `), + externalRefResolver + ); + + const refs = refsIn(root); + expect(refs).toHaveLength(4); + expect(refs.filter((ref) => !ref.resolved).map((ref) => ref.location.pointer)).toEqual([]); + }); + + it('should record the keys written next to a $ref under that $ref', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: {} + components: + schemas: + Drink: { type: object } + Coffee: + $ref: '#/components/schemas/Drink' + properties: + roast: { type: string } + `) + ); + + expect(nodeAt(root, '#/components/schemas/Coffee')!.children).toMatchObject([ + { key: 'properties' }, + ]); + }); + + it('should point a $ref at a $ref with keys written next to it, not at the end of the chain', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: {} + components: + schemas: + DailySpecial: { $ref: '#/components/schemas/Latte' } + Latte: { $ref: '#/components/schemas/Coffee', description: Coffee with milk } + Coffee: { type: object } + `) + ); + + expect(nodeAt(root, '#/components/schemas/DailySpecial')!.resolved).toBe( + nodeAt(root, '#/components/schemas/Latte') + ); + }); +}); diff --git a/packages/core/src/node-tree/__tests__/utils.ts b/packages/core/src/node-tree/__tests__/utils.ts new file mode 100644 index 0000000000..960feb2611 --- /dev/null +++ b/packages/core/src/node-tree/__tests__/utils.ts @@ -0,0 +1,40 @@ +import { detectSpec } from '../../detect-spec.js'; +import { getTypes } from '../../oas-types.js'; +import { + BaseResolver, + makeDocumentFromString, + resolveDocument, + type Document, +} from '../../resolve.js'; +import { normalizeTypes } from '../../types/index.js'; +import type { WalkContext } from '../../walk.js'; +import { buildNodeTree } from '../index.js'; +import type { NodeEntry } from '../types.js'; + +export const cafe = (description: string) => + makeDocumentFromString( + `openapi: 3.1.0\ninfo: { title: Cafe, version: 1.0.0 }\n${description}`, + 'cafe.yaml' + ); + +export async function buildTree(document: Document, externalRefResolver = new BaseResolver()) { + const specVersion = detectSpec(document.parsed); + const types = normalizeTypes(getTypes(specVersion)); + const resolvedRefMap = await resolveDocument({ + rootDocument: document, + rootType: types.Root, + externalRefResolver, + }); + const ctx: WalkContext = { problems: [], specVersion, visitorsData: {} }; + const root = buildNodeTree({ document, types, resolvedRefMap, ctx }); + return { root, problems: ctx.problems }; +} + +export function nodeAt(node: NodeEntry, pointer: string): NodeEntry | undefined { + if (node.location.pointer === pointer) return node; + for (const child of node.children) { + const found = nodeAt(child, pointer); + if (found) return found; + } + return undefined; +} diff --git a/packages/core/src/node-tree/access.ts b/packages/core/src/node-tree/access.ts index 632061a0de..09a8789aa0 100644 --- a/packages/core/src/node-tree/access.ts +++ b/packages/core/src/node-tree/access.ts @@ -1,20 +1,39 @@ +import type { Location } from '../ref-utils.js'; import { isPlainObject } from '../utils/is-plain-object.js'; -import type { NodeEntry } from './types.js'; +import type { NodeEntry, NodeValue } from './types.js'; /** The nearest node of that type, the node itself included. */ -export function enclosing(node: NodeEntry | null, type: string): NodeEntry | undefined { - for (let current = node; current; current = current.parent) { - if (current.type === type) return current; +export function enclosing(node: NodeEntry, typeName: string): NodeEntry | undefined { + // A `$ref` inside a file can lead back to the file's root, so each node is climbed once. + const climbed = new Set(); + let current: NodeEntry | undefined = node; + + while (current && !climbed.has(current)) { + if (current.type.name === typeName) return current; + climbed.add(current); + current = current.parent ?? current.referencedFrom; } + return undefined; } -/** What the node stands for: a `$ref` stands for the node it points at. */ -export function valueOf(node: NodeEntry): NodeEntry['value'] { - return node.target?.value ?? node.value; +// A `$ref` stands for the node it points at. +export function valueOf(node: NodeEntry): NodeValue { + return (node.resolved ?? node).value; +} + +export function locationOf(node: NodeEntry): Location { + return (node.resolved ?? node).location; } export function fieldOf(node: NodeEntry | undefined, name: string): unknown { const value = node && valueOf(node); return isPlainObject(value) ? value[name] : undefined; } + +export function keyKindOf(node: NodeEntry): 'field' | 'entry' | 'item' { + if (typeof node.key === 'number') return 'item'; + + const isField = !!node.parent && Object.hasOwn(node.parent.type.properties, node.key); + return isField ? 'field' : 'entry'; +} diff --git a/packages/core/src/node-tree/build.ts b/packages/core/src/node-tree/build.ts deleted file mode 100644 index e441e1b9cc..0000000000 --- a/packages/core/src/node-tree/build.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { SpecVersion } from '../oas-types.js'; -import { escapePointerFragment, joinPointer, parseRef } from '../ref-utils.js'; -import type { Document } from '../resolve.js'; -import type { NormalizedNodeType } from '../types/index.js'; -import { isPlainObject } from '../utils/is-plain-object.js'; -import { normalizeVisitors } from '../visitors.js'; -import { walkDocument, type UserContext, type WalkContext } from '../walk.js'; -import type { NodeEntry, Reference } from './types.js'; - -export type NodeTree = { root: NodeEntry; references: Reference[] }; - -export function buildNodeTree(opts: { - document: Document; - types: Record; - specVersion: SpecVersion; -}): NodeTree { - const { document, types, specVersion } = opts; - const nodes = new Map(); - const refs: Array<{ entry: NodeEntry; ref: string }> = []; - - const add = (value: NodeEntry['value'], { location, type, key }: UserContext): NodeEntry => { - const parent = nodes.get(parentPointer(location.pointer)) ?? null; - const entry: NodeEntry = { type: type.name, key, location, value, parent, children: [] }; - parent?.children.push(entry); - nodes.set(location.pointer, entry); - return entry; - }; - - const visitor = { - any(node: unknown, context: UserContext) { - if (isPlainObject(node) || Array.isArray(node)) add(node, context); - }, - // A `$ref` is a node of the type its place expects; the node it points at may come later. - ref(node: { $ref: string }, context: UserContext) { - refs.push({ entry: add(node, context), ref: node.$ref }); - }, - }; - - const walkContext: WalkContext = { problems: [], specVersion, visitorsData: {} }; - - walkDocument({ - document, - rootType: types.Root, - normalizedVisitors: normalizeVisitors( - [{ severity: 'warn', ruleId: 'node-tree', visitor }], - types - ), - // An empty map leaves every `$ref` unresolved, so each node is visited once, where it is written. - resolvedRefMap: new Map(), - ctx: walkContext, - }); - - const references: Reference[] = []; - for (const { entry, ref } of refs) { - entry.target = nodes.get(pointerOf(ref)); - if (entry.target) references.push({ from: entry, to: entry.target }); - } - - return { root: nodes.get('#/')!, references }; -} - -// A `$ref` may percent-encode its pointer (`Pet%20Name`), while a location spells it as written. -function pointerOf(ref: string): string { - return parseRef(ref).pointer.reduce( - (pointer, segment) => joinPointer(pointer, escapePointerFragment(segment)), - '#/' - ); -} - -function parentPointer(pointer: string): string { - const lastSlash = pointer.lastIndexOf('/'); - return lastSlash <= 1 ? '#/' : pointer.slice(0, lastSlash); -} diff --git a/packages/core/src/node-tree/index.ts b/packages/core/src/node-tree/index.ts new file mode 100644 index 0000000000..0aaa8d4102 --- /dev/null +++ b/packages/core/src/node-tree/index.ts @@ -0,0 +1,106 @@ +import type { Document, ResolvedRefMap } from '../resolve.js'; +import { NoUnresolvedRefs } from '../rules/common/no-unresolved-refs.js'; +import type { NormalizedNodeType } from '../types/index.js'; +import { isPlainObject } from '../utils/is-plain-object.js'; +import { + normalizeVisitors, + type BaseVisitor, + type NestedVisitObject, + type RuleInstanceConfig, +} from '../visitors.js'; +import { walkDocument, type UserContext, type WalkContext } from '../walk.js'; +import type { NodeEntry, NodeValue } from './types.js'; + +export function buildNodeTree(opts: { + document: Document; + types: Record; + resolvedRefMap: ResolvedRefMap; + ctx: WalkContext; +}): NodeEntry { + const { document, types, resolvedRefMap, ctx } = opts; + + const nodes = new Map(); + const waitingRefs = new Map(); + + const add = (value: NodeValue, { type, key, location }: UserContext, parent?: NodeEntry) => { + const node: NodeEntry = { + type, + key, + location, + value, + parent: parent ?? null, + children: [], + }; + + parent?.children.push(node); + nodes.set(value, node); + + for (const waiting of waitingRefs.get(value) ?? []) waiting.resolved = node; + + return node; + }; + + const visitor: BaseVisitor = { + any: { + enter(value: unknown, context) { + const isContainer = isPlainObject(value) || Array.isArray(value); + + if (!isContainer || nodes.has(value)) return; + + const reachedFrom = nodes.get(context.rawNode); + + if (reachedFrom) { + add(value, context).referencedFrom = reachedFrom; + } else { + add(value, context, nodes.get(context.parent)); + } + }, + // A target the walker reached through a `$ref` before its own place joins it here + leave(value: unknown) { + const container = nodes.get(value); + + if (!container) return; + + for (const [key, childValue] of Object.entries(container.value)) { + const child = nodes.get(childValue); + + if (!child || child.parent) continue; + + child.parent = container; + child.key = Array.isArray(container.value) ? Number(key) : key; + container.children.push(child); + } + }, + }, + ref(value: { $ref: string }, context, resolved) { + if (nodes.has(value)) return; + + const ref = add(value, context, nodes.get(context.parent)); + const target = resolved.chain?.[0]?.node ?? resolved.node; + const resolvedNode = nodes.get(target); + + if (resolvedNode) { + ref.resolved = resolvedNode; + } else { + waitingRefs.set(target, [...(waitingRefs.get(target) ?? []), ref]); + } + }, + }; + + const visitors: Array< + RuleInstanceConfig & { visitor: NestedVisitObject } + > = [ + { severity: 'warn', ruleId: 'node-tree', visitor }, + { severity: 'error', ruleId: 'no-unresolved-refs', visitor: NoUnresolvedRefs({}) }, + ]; + + walkDocument({ + document, + rootType: types.Root, + normalizedVisitors: normalizeVisitors(visitors, types), + resolvedRefMap, + ctx, + }); + + return nodes.get(document.parsed)!; +} diff --git a/packages/core/src/node-tree/types.ts b/packages/core/src/node-tree/types.ts index 207b43ad16..6c31f8429a 100644 --- a/packages/core/src/node-tree/types.ts +++ b/packages/core/src/node-tree/types.ts @@ -1,16 +1,18 @@ import type { Location } from '../ref-utils.js'; +import type { NormalizedNodeType } from '../types/index.js'; export type NodeValue = Record | unknown[]; export type NodeEntry = { - type: string; + type: NormalizedNodeType; + /** The root of another file takes the key of the `$ref` that reaches it. */ key: string | number; location: Location; value: NodeValue; parent: NodeEntry | null; children: NodeEntry[]; - /** For a `$ref`, the node it points at; absent when it does not resolve. */ - target?: NodeEntry; + /** The target of a `$ref`. */ + resolved?: NodeEntry; + /** For a node with no parent, such as the root of another file: the `$ref` it was reached by. */ + referencedFrom?: NodeEntry; }; - -export type Reference = { from: NodeEntry; to: NodeEntry }; diff --git a/packages/core/src/utils/__tests__/is-scalar.test.ts b/packages/core/src/utils/__tests__/is-scalar.test.ts index aba787b442..b48c34752a 100644 --- a/packages/core/src/utils/__tests__/is-scalar.test.ts +++ b/packages/core/src/utils/__tests__/is-scalar.test.ts @@ -1,10 +1,12 @@ import { isScalarArray } from '../is-scalar.js'; describe('isScalarArray', () => { - 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.each([ + [[], true], + [['coffee', 1, true, null], true], + [[{ name: 'coffee' }], false], + ['coffee', false], + ])('tells whether %j is an array of scalars', (value, isArrayOfScalars) => { + expect(isScalarArray(value)).toBe(isArrayOfScalars); }); }); diff --git a/packages/core/src/utils/__tests__/levenshtein.test.ts b/packages/core/src/utils/__tests__/levenshtein.test.ts new file mode 100644 index 0000000000..8a8cab8106 --- /dev/null +++ b/packages/core/src/utils/__tests__/levenshtein.test.ts @@ -0,0 +1,24 @@ +import { levenshteinDistance, levenshteinSimilarity } from '../levenshtein.js'; + +describe('levenshteinDistance', () => { + it.each([ + ['latte', 'latte', 0], + ['latte', 'lattes', 1], + ['kitten', 'sitting', 3], + ['', 'mocha', 5], + ['', '', 0], + ])('turns "%s" into "%s" in %i edits', (left, right, distance) => { + expect(levenshteinDistance(left, right)).toBe(distance); + }); +}); + +describe('levenshteinSimilarity', () => { + it.each([ + ['latte', 'latte', 1], + ['tea', 'cup', 0], + ['limit', 'limits', 5 / 6], + ['', '', 1], + ])('rates "%s" and "%s" as %d alike', (left, right, similarity) => { + expect(levenshteinSimilarity(left, right)).toBe(similarity); + }); +}); diff --git a/packages/core/src/utils/is-scalar.ts b/packages/core/src/utils/is-scalar.ts index d5a67ca7e3..89b990c03c 100644 --- a/packages/core/src/utils/is-scalar.ts +++ b/packages/core/src/utils/is-scalar.ts @@ -7,8 +7,6 @@ export function isScalar(value: unknown): 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); + return Array.isArray(value) && value.every(isScalar); } diff --git a/packages/core/src/utils/items-not-in.ts b/packages/core/src/utils/items-not-in.ts new file mode 100644 index 0000000000..fdc301d56c --- /dev/null +++ b/packages/core/src/utils/items-not-in.ts @@ -0,0 +1,6 @@ +/** The items of `list` that `other` does not have. */ +export function itemsNotIn(list: unknown, other: unknown): unknown[] { + if (!Array.isArray(list)) return []; + const otherItems = Array.isArray(other) ? other : []; + return list.filter((item) => !otherItems.includes(item)); +} diff --git a/packages/core/src/utils/levenshtein.ts b/packages/core/src/utils/levenshtein.ts new file mode 100644 index 0000000000..0095168af9 --- /dev/null +++ b/packages/core/src/utils/levenshtein.ts @@ -0,0 +1,30 @@ +/** The number of single-character insertions, deletions and substitutions that turn `left` into `right`. */ +export function levenshteinDistance(left: string, right: string): number { + let previousRow = Array.from({ length: right.length + 1 }, (_, index) => index); + + for (let leftIndex = 1; leftIndex <= left.length; leftIndex++) { + const row = [leftIndex]; + + for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) { + const substitution = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1; + row[rightIndex] = Math.min( + previousRow[rightIndex] + 1, + row[rightIndex - 1] + 1, + previousRow[rightIndex - 1] + substitution + ); + } + + previousRow = row; + } + + return previousRow[right.length]; +} + +/** + * How alike two strings are, from 0 (nothing in common) to 1 (equal): the share of the longer + * string that the edit leaves untouched. Two empty strings are equal. + */ +export function levenshteinSimilarity(left: string, right: string): number { + const longest = Math.max(left.length, right.length); + return longest === 0 ? 1 : 1 - levenshteinDistance(left, right) / longest; +} diff --git a/packages/core/src/utils/path-template.ts b/packages/core/src/utils/path-template.ts deleted file mode 100644 index 6fc2d08292..0000000000 --- a/packages/core/src/utils/path-template.ts +++ /dev/null @@ -1,12 +0,0 @@ -const TEMPLATE_EXPRESSION = /\{[^}]+\}/g; - -/** The names of the template expressions in a path, in the order they appear. */ -export function templateParameterNames(template: string): string[] { - return [...template.matchAll(TEMPLATE_EXPRESSION)].map(([expression]) => expression.slice(1, -1)); -} - -/** The path with its template names replaced by their positions: `/pets/{id}` → `/pets/{0}`. */ -export function templateShape(template: string): string { - let position = 0; - return template.replace(TEMPLATE_EXPRESSION, () => `{${position++}}`); -} diff --git a/tests/e2e/diff/asyncapi3/snapshot.txt b/tests/e2e/diff/asyncapi3/snapshot.txt index c924105d84..b1aaee1d31 100644 --- a/tests/e2e/diff/asyncapi3/snapshot.txt +++ b/tests/e2e/diff/asyncapi3/snapshot.txt @@ -5,10 +5,10 @@ channel menuChanged channel orderPlaced ✖ major modified address - Channel `orderPlaced` address changed from 'orders/placed' to 'orders/new'. (channel-address-changed) + `address` of channel `orderPlaced` changed from 'orders/placed' to 'orders/new'. (channel-address-changed) at revision.yaml:7:14 ✖ major modified messages/orderPlaced · contentType - Message `orderPlaced` content type changed from 'application/json' to 'application/avro'. (message-content-type-changed) + `contentType` of message `orderPlaced` changed from 'application/json' to 'application/avro'. (message-content-type-changed) at revision.yaml:10:22 ✖ major modified messages/orderPlaced/payload · required Properties became required: `items`. (required-properties-added) @@ -26,7 +26,7 @@ operation sendMenuChanged operation sendOrderReady ✖ major modified action - Operation `sendOrderReady` action changed from 'send' to 'receive'. (operation-action-changed) + `action` of operation `sendOrderReady` changed from 'send' to 'receive'. (operation-action-changed) at revision.yaml:27:13 server staging diff --git a/tests/e2e/diff/config-file/snapshot.txt b/tests/e2e/diff/config-file/snapshot.txt index da5857be3f..672bbb19f4 100644 --- a/tests/e2e/diff/config-file/snapshot.txt +++ b/tests/e2e/diff/config-file/snapshot.txt @@ -3,7 +3,7 @@ info at revision.yaml:2:31 GET /menu - ✔ minor modified parameters/{query:limit} · required + ✔ minor modified parameters/0 · required `limit` query parameter became required. (parameter-became-required) at revision.yaml:8:47 · patch modified summary diff --git a/tests/e2e/diff/configuration/snapshot-skip-rule.txt b/tests/e2e/diff/configuration/snapshot-skip-rule.txt index 67bdfe1ac3..723dcc321a 100644 --- a/tests/e2e/diff/configuration/snapshot-skip-rule.txt +++ b/tests/e2e/diff/configuration/snapshot-skip-rule.txt @@ -1,30 +1,20 @@ -components - ✖ major modified schemas/Order · required - Properties of `Order` became required: `pickupAt`. (required-properties-added) - at revision.yaml:58:17 - ✖ major modified schemas/Order/properties/note · maxLength - `maxLength` of `note` changed from '200' to '100'. (string-constraint-changed) - at revision.yaml:61:42 - ✔ minor added schemas/Order/properties/pickupAt - at revision.yaml:62:19 - · patch removed schemas/MenuItem/properties/price - at base.yaml:58:16 - · patch modified schemas/MenuItem/properties/status · enum - at revision.yaml:55:39 - info · patch modified version at revision.yaml:2:31 GET /menu - ✖ major modified parameters/{query:limit} · required + ✖ major modified parameters/0 · required `limit` query parameter became required. (parameter-became-required) at revision.yaml:12:47 - ✖ major removed parameters/{query:search} + ✖ major removed parameters/1 `search` query parameter was removed. (parameter-removed) at base.yaml:13:11 · patch modified summary at revision.yaml:10:16 + · patch removed responses/200/content/application/json/schema/items/properties/price + at base.yaml:58:16 + · patch modified responses/200/content/application/json/schema/items/properties/status · enum + at revision.yaml:55:39 /menu/{menuItemId} · patch modified key @@ -35,10 +25,18 @@ DELETE /menu/{menuItemId} at base.yaml:27:7 GET /menu/{menuItemId} - · patch modified parameters/{path:0} · name + · patch modified parameters/0 · name at revision.yaml:22:19 POST /orders + ✖ major modified requestBody/content/application/json/schema · required + Properties of `Order` became required: `pickupAt`. (required-properties-added) + at revision.yaml:58:17 + ✖ major modified requestBody/content/application/json/schema/properties/note · maxLength + `maxLength` of `note` changed from '200' to '100'. (schema-constraint-changed) + at revision.yaml:61:42 + ✔ minor added requestBody/content/application/json/schema/properties/pickupAt + at revision.yaml:62:19 · patch removed responses/201/headers All headers of response `201` were removed. (response-header-removed) at base.yaml:39:13 diff --git a/tests/e2e/diff/configuration/snapshot.txt b/tests/e2e/diff/configuration/snapshot.txt index 190f470330..deed73bd93 100644 --- a/tests/e2e/diff/configuration/snapshot.txt +++ b/tests/e2e/diff/configuration/snapshot.txt @@ -1,31 +1,21 @@ -components - ✖ major removed schemas/MenuItem/properties/price - Property `price` of `MenuItem` was removed. (property-removed) - at base.yaml:58:16 - ✖ major modified schemas/Order · required - Properties of `Order` became required: `pickupAt`. (required-properties-added) - at revision.yaml:58:17 - ✖ major modified schemas/Order/properties/note · maxLength - `maxLength` of `note` changed from '200' to '100'. (string-constraint-changed) - at revision.yaml:61:42 - ✔ minor added schemas/Order/properties/pickupAt - at revision.yaml:62:19 - · patch modified schemas/MenuItem/properties/status · enum - at revision.yaml:55:39 - info · patch modified version at revision.yaml:2:31 GET /menu - ✖ major modified parameters/{query:limit} · required + ✖ major modified parameters/0 · required `limit` query parameter became required. (parameter-became-required) at revision.yaml:12:47 - ✖ major removed parameters/{query:search} + ✖ major removed parameters/1 `search` query parameter was removed. (parameter-removed) at base.yaml:13:11 + ✖ major removed responses/200/content/application/json/schema/items/properties/price + Property `price` of `MenuItem` was removed. (property-removed) + at base.yaml:58:16 · patch modified summary at revision.yaml:10:16 + · patch modified responses/200/content/application/json/schema/items/properties/status · enum + at revision.yaml:55:39 /menu/{menuItemId} · patch modified key @@ -37,10 +27,18 @@ DELETE /menu/{menuItemId} at base.yaml:27:7 GET /menu/{menuItemId} - · patch modified parameters/{path:0} · name + · patch modified parameters/0 · name at revision.yaml:22:19 POST /orders + ✖ major modified requestBody/content/application/json/schema · required + Properties of `Order` became required: `pickupAt`. (required-properties-added) + at revision.yaml:58:17 + ✖ major modified requestBody/content/application/json/schema/properties/note · maxLength + `maxLength` of `note` changed from '200' to '100'. (schema-constraint-changed) + at revision.yaml:61:42 + ✔ minor added requestBody/content/application/json/schema/properties/pickupAt + at revision.yaml:62:19 · patch removed responses/201/headers All headers of response `201` were removed. (response-header-removed) at base.yaml:39:13 diff --git a/tests/e2e/diff/diff.test.ts b/tests/e2e/diff/diff.test.ts index ed85d6b637..b79b9b55ac 100644 --- a/tests/e2e/diff/diff.test.ts +++ b/tests/e2e/diff/diff.test.ts @@ -140,6 +140,14 @@ describe('diff', () => { await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); }); + test('should point each change at the file and line that has it', async () => { + const testPath = join(__dirname, 'multi-file'); + const args = getParams(indexEntryPoint, ['diff', 'base/openapi.yaml', 'revision/openapi.yaml']); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + test('should compare two APIs by their aliases and judge them with the rules of redocly.yaml', async () => { const testPath = join(__dirname, 'configuration'); const args = getParams(indexEntryPoint, ['diff', 'cafe@v1', 'cafe@v2']); diff --git a/tests/e2e/diff/json-and-next-version/snapshot-json.txt b/tests/e2e/diff/json-and-next-version/snapshot-json.txt index 6ad1170cc9..28fc2ccab6 100644 --- a/tests/e2e/diff/json-and-next-version/snapshot-json.txt +++ b/tests/e2e/diff/json-and-next-version/snapshot-json.txt @@ -64,7 +64,7 @@ } }, { - "key": "#/paths/~1menu/get/parameters/{query:limit}", + "key": "#/paths/~1menu/get/parameters/0", "typeName": "Parameter", "kind": "modified", "impact": "major", @@ -97,7 +97,7 @@ } }, { - "key": "#/paths/~1orders~1{0}", + "key": "#/paths/~1orders~1{orderId}", "typeName": "PathItem", "kind": "removed", "impact": "major", diff --git a/tests/e2e/diff/markdown-and-html/snapshot-html.txt b/tests/e2e/diff/markdown-and-html/snapshot-html.txt index e5e7d507ba..7ff0ff15c2 100644 --- a/tests/e2e/diff/markdown-and-html/snapshot-html.txt +++ b/tests/e2e/diff/markdown-and-html/snapshot-html.txt @@ -83,7 +83,7 @@ major modified - #/paths/~1menu/get/parameters/{query:limit} · required + #/paths/~1menu/get/parameters/0 · required `limit` query parameter became required. parameter-became-required
{
@@ -107,7 +107,7 @@
       
         major
         removed
-        #/paths/~1orders~1{0}
+        #/paths/~1orders~1{orderId}
         Path `/orders/{orderId}` was removed. path-removed
       
       
{
diff --git a/tests/e2e/diff/markdown-and-html/snapshot-markdown.txt b/tests/e2e/diff/markdown-and-html/snapshot-markdown.txt
index b4287526fc..7d1a39ced3 100644
--- a/tests/e2e/diff/markdown-and-html/snapshot-markdown.txt
+++ b/tests/e2e/diff/markdown-and-html/snapshot-markdown.txt
@@ -6,6 +6,6 @@
 | --- | --- | --- | --- | --- |
 | ⚪ patch | modified | `#/info · version` | revision.yaml:2:31 |  |
 | ⚪ patch | modified | `#/paths/~1menu/get · summary` | revision.yaml:6:16 |  |
-| 🔴 major | modified | `#/paths/~1menu/get/parameters/{query:limit} · required` | revision.yaml:8:47 | \`limit\` query parameter became required. `parameter-became-required` |
-| 🔴 major | removed | `#/paths/~1orders~1{0}` | base.yaml:12:5 | Path \`/orders/{orderId}\` was removed. `path-removed` |
+| 🔴 major | modified | `#/paths/~1menu/get/parameters/0 · required` | revision.yaml:8:47 | \`limit\` query parameter became required. `parameter-became-required` |
+| 🔴 major | removed | `#/paths/~1orders~1{orderId}` | base.yaml:12:5 | Path \`/orders/{orderId}\` was removed. `path-removed` |
 | 🟢 minor | added | `#/paths/~1specials` | revision.yaml:12:5 |  |
\ No newline at end of file
diff --git a/tests/e2e/diff/multi-file/base/openapi.yaml b/tests/e2e/diff/multi-file/base/openapi.yaml
new file mode 100644
index 0000000000..93260314ac
--- /dev/null
+++ b/tests/e2e/diff/multi-file/base/openapi.yaml
@@ -0,0 +1,11 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.0.0 }
+paths:
+  /menu:
+    get:
+      responses:
+        '200':
+          description: The menu
+          content:
+            application/json:
+              schema: { $ref: ./schemas/item.yaml }
diff --git a/tests/e2e/diff/multi-file/base/schemas/item.yaml b/tests/e2e/diff/multi-file/base/schemas/item.yaml
new file mode 100644
index 0000000000..a360c5d1e8
--- /dev/null
+++ b/tests/e2e/diff/multi-file/base/schemas/item.yaml
@@ -0,0 +1,4 @@
+type: object
+properties:
+  name: { type: string }
+  price: { type: number }
diff --git a/tests/e2e/diff/multi-file/revision/openapi.yaml b/tests/e2e/diff/multi-file/revision/openapi.yaml
new file mode 100644
index 0000000000..93260314ac
--- /dev/null
+++ b/tests/e2e/diff/multi-file/revision/openapi.yaml
@@ -0,0 +1,11 @@
+openapi: 3.1.0
+info: { title: Cafe, version: 1.0.0 }
+paths:
+  /menu:
+    get:
+      responses:
+        '200':
+          description: The menu
+          content:
+            application/json:
+              schema: { $ref: ./schemas/item.yaml }
diff --git a/tests/e2e/diff/multi-file/revision/schemas/item.yaml b/tests/e2e/diff/multi-file/revision/schemas/item.yaml
new file mode 100644
index 0000000000..98029bf67d
--- /dev/null
+++ b/tests/e2e/diff/multi-file/revision/schemas/item.yaml
@@ -0,0 +1,3 @@
+type: object
+properties:
+  name: { type: integer }
diff --git a/tests/e2e/diff/multi-file/snapshot.txt b/tests/e2e/diff/multi-file/snapshot.txt
new file mode 100644
index 0000000000..5decd4d0af
--- /dev/null
+++ b/tests/e2e/diff/multi-file/snapshot.txt
@@ -0,0 +1,14 @@
+GET /menu
+  ✖ major  modified  responses/200/content/application/json/schema/properties/name · type
+      Type of `name` widened from 'string' to 'integer'. (schema-type-changed)
+      at revision/schemas/item.yaml:3:17
+  ✖ major  removed   responses/200/content/application/json/schema/properties/price
+      Property `price` was removed. (property-removed)
+      at base/schemas/item.yaml:4:10
+
+2 major, 0 minor, 0 patch.
+
+
+base/openapi.yaml vs revision/openapi.yaml: diff processed in ms
+
+❌ Diff failed with 2 major changes.
diff --git a/tests/e2e/diff/openapi-3-0-to-3-1/snapshot.txt b/tests/e2e/diff/openapi-3-0-to-3-1/snapshot.txt
index 494bf49e27..e34cbdda4d 100644
--- a/tests/e2e/diff/openapi-3-0-to-3-1/snapshot.txt
+++ b/tests/e2e/diff/openapi-3-0-to-3-1/snapshot.txt
@@ -2,10 +2,10 @@ document
   · patch  modified  openapi
       at revision.yaml:1:10
 
-components
-  · patch  modified  schemas/Order/properties/note · nullable
+POST /orders
+  · patch  modified  requestBody/content/application/json/schema/properties/note · nullable
       at revision.yaml:21:15
-  · patch  modified  schemas/Order/properties/note · type
+  · patch  modified  requestBody/content/application/json/schema/properties/note · type
       at revision.yaml:21:23
 
 0 major, 0 minor, 3 patch.
diff --git a/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt b/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt
index d6c0d54cf7..60fa49eab4 100644
--- a/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt
+++ b/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt
@@ -1,30 +1,20 @@
-components
-  ✖ major  removed   schemas/MenuItem/properties/price
-      Property `price` of `MenuItem` was removed. (property-removed)
-      at base.yaml:58:16
-  ✖ major  modified  schemas/MenuItem/properties/status · enum
-      Enum of `status` gained values: 'seasonal'. (enum-values-added)
-      at revision.yaml:55:39
-  ✖ major  modified  schemas/Order · required
-      Properties of `Order` became required: `pickupAt`. (required-properties-added)
-      at revision.yaml:58:17
-  ✖ major  modified  schemas/Order/properties/note · maxLength
-      `maxLength` of `note` changed from '200' to '100'. (string-constraint-changed)
-      at revision.yaml:61:42
-  ✔ minor  added     schemas/Order/properties/pickupAt
-      at revision.yaml:62:19
-
 info
   · patch  modified  version
       at revision.yaml:2:31
 
 GET /menu
-  ✖ major  modified  parameters/{query:limit} · required
+  ✖ major  modified  parameters/0 · required
       `limit` query parameter became required. (parameter-became-required)
       at revision.yaml:12:47
-  ✖ major  removed   parameters/{query:search}
+  ✖ major  removed   parameters/1
       `search` query parameter was removed. (parameter-removed)
       at base.yaml:13:11
+  ✖ major  removed   responses/200/content/application/json/schema/items/properties/price
+      Property `price` of `MenuItem` was removed. (property-removed)
+      at base.yaml:58:16
+  ✖ major  modified  responses/200/content/application/json/schema/items/properties/status · enum
+      Enum of `status` gained values: 'seasonal'. (enum-values-added)
+      at revision.yaml:55:39
   · patch  modified  summary
       at revision.yaml:10:16
 
@@ -38,13 +28,21 @@ DELETE /menu/{menuItemId}
       at base.yaml:27:7
 
 GET /menu/{menuItemId}
-  · patch  modified  parameters/{path:0} · name
+  · patch  modified  parameters/0 · name
       at revision.yaml:22:19
 
 POST /orders
+  ✖ major  modified  requestBody/content/application/json/schema · required
+      Properties of `Order` became required: `pickupAt`. (required-properties-added)
+      at revision.yaml:58:17
+  ✖ major  modified  requestBody/content/application/json/schema/properties/note · maxLength
+      `maxLength` of `note` changed from '200' to '100'. (schema-constraint-changed)
+      at revision.yaml:61:42
   ✖ major  removed   responses/201/headers
       All headers of response `201` were removed. (response-header-removed)
       at base.yaml:39:13
+  ✔ minor  added     requestBody/content/application/json/schema/properties/pickupAt
+      at revision.yaml:62:19
 
 /specials
   ✔ minor  added
diff --git a/tests/e2e/diff/openapi-breaking-changes/snapshot.txt b/tests/e2e/diff/openapi-breaking-changes/snapshot.txt
index 785692be77..eb5410a943 100644
--- a/tests/e2e/diff/openapi-breaking-changes/snapshot.txt
+++ b/tests/e2e/diff/openapi-breaking-changes/snapshot.txt
@@ -1,30 +1,20 @@
-components
-  ✖ major  removed   schemas/MenuItem/properties/price
-      Property `price` of `MenuItem` was removed. (property-removed)
-      at base.yaml:58:16
-  ✖ major  modified  schemas/MenuItem/properties/status · enum
-      Enum of `status` gained values: 'seasonal'. (enum-values-added)
-      at revision.yaml:55:39
-  ✖ major  modified  schemas/Order · required
-      Properties of `Order` became required: `pickupAt`. (required-properties-added)
-      at revision.yaml:58:17
-  ✖ major  modified  schemas/Order/properties/note · maxLength
-      `maxLength` of `note` changed from '200' to '100'. (string-constraint-changed)
-      at revision.yaml:61:42
-  ✔ minor  added     schemas/Order/properties/pickupAt
-      at revision.yaml:62:19
-
 info
   · patch  modified  version
       at revision.yaml:2:31
 
 GET /menu
-  ✖ major  modified  parameters/{query:limit} · required
+  ✖ major  modified  parameters/0 · required
       `limit` query parameter became required. (parameter-became-required)
       at revision.yaml:12:47
-  ✖ major  removed   parameters/{query:search}
+  ✖ major  removed   parameters/1
       `search` query parameter was removed. (parameter-removed)
       at base.yaml:13:11
+  ✖ major  removed   responses/200/content/application/json/schema/items/properties/price
+      Property `price` of `MenuItem` was removed. (property-removed)
+      at base.yaml:58:16
+  ✖ major  modified  responses/200/content/application/json/schema/items/properties/status · enum
+      Enum of `status` gained values: 'seasonal'. (enum-values-added)
+      at revision.yaml:55:39
   · patch  modified  summary
       at revision.yaml:10:16
 
@@ -38,13 +28,21 @@ DELETE /menu/{menuItemId}
       at base.yaml:27:7
 
 GET /menu/{menuItemId}
-  · patch  modified  parameters/{path:0} · name
+  · patch  modified  parameters/0 · name
       at revision.yaml:22:19
 
 POST /orders
+  ✖ major  modified  requestBody/content/application/json/schema · required
+      Properties of `Order` became required: `pickupAt`. (required-properties-added)
+      at revision.yaml:58:17
+  ✖ major  modified  requestBody/content/application/json/schema/properties/note · maxLength
+      `maxLength` of `note` changed from '200' to '100'. (schema-constraint-changed)
+      at revision.yaml:61:42
   ✖ major  removed   responses/201/headers
       All headers of response `201` were removed. (response-header-removed)
       at base.yaml:39:13
+  ✔ minor  added     requestBody/content/application/json/schema/properties/pickupAt
+      at revision.yaml:62:19
 
 /specials
   ✔ minor  added
diff --git a/tests/e2e/diff/openapi-compatible-changes/snapshot-fail-on-minor.txt b/tests/e2e/diff/openapi-compatible-changes/snapshot-fail-on-minor.txt
index bc633d0d12..2542b0714f 100644
--- a/tests/e2e/diff/openapi-compatible-changes/snapshot-fail-on-minor.txt
+++ b/tests/e2e/diff/openapi-compatible-changes/snapshot-fail-on-minor.txt
@@ -3,7 +3,7 @@ info
       at revision.yaml:2:31
 
 GET /menu
-  ✔ minor  added     parameters/{query:category}
+  ✔ minor  added     parameters/2
       at revision.yaml:14:11
 
 /specials
diff --git a/tests/e2e/diff/openapi-compatible-changes/snapshot.txt b/tests/e2e/diff/openapi-compatible-changes/snapshot.txt
index dee03a0009..9704fdfc3b 100644
--- a/tests/e2e/diff/openapi-compatible-changes/snapshot.txt
+++ b/tests/e2e/diff/openapi-compatible-changes/snapshot.txt
@@ -3,7 +3,7 @@ info
       at revision.yaml:2:31
 
 GET /menu
-  ✔ minor  added     parameters/{query:category}
+  ✔ minor  added     parameters/2
       at revision.yaml:14:11
 
 /specials

From fb7100594de2d46dd2d407a20249dac090a8bea6 Mon Sep 17 00:00:00 2001
From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com>
Date: Mon, 12 Oct 2026 18:48:50 +0300
Subject: [PATCH 54/57] perf(diff): pair renamed and changed parts with
 fastest-levenshtein, which replaces js-levenshtein in core

---
 package-lock.json                             | 19 +++++++-----
 packages/core/package.json                    |  3 +-
 packages/core/src/diff/pair-children.ts       | 11 +++++--
 packages/core/src/rules/utils.ts              |  2 +-
 .../src/utils/__tests__/levenshtein.test.ts   | 24 ---------------
 packages/core/src/utils/levenshtein.ts        | 30 -------------------
 6 files changed, 21 insertions(+), 68 deletions(-)
 delete mode 100644 packages/core/src/utils/__tests__/levenshtein.test.ts
 delete mode 100644 packages/core/src/utils/levenshtein.ts

diff --git a/package-lock.json b/package-lock.json
index da00fbcbbc..979868a72a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4455,12 +4455,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/@types/js-levenshtein": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.1.tgz",
-      "integrity": "sha512-qC4bCqYGy1y/NP7dDVr7KJarn+PbX1nSpwA7JXdu0HxT3QYjO8MJ+cntENtHFVy2dRAyBV23OZ6MxsW1AM1L8g==",
-      "dev": true
-    },
     "node_modules/@types/js-yaml": {
       "version": "4.0.9",
       "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
@@ -7039,6 +7033,15 @@
         "fxparser": "src/cli/cli.js"
       }
     },
+    "node_modules/fastest-levenshtein": {
+      "version": "1.0.16",
+      "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
+      "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4.9.1"
+      }
+    },
     "node_modules/fastq": {
       "version": "1.20.1",
       "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -7950,6 +7953,7 @@
       "version": "1.1.6",
       "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
       "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
+      "dev": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -13688,8 +13692,8 @@
         "ajv": "npm:@redocly/ajv@^8.18.3",
         "ajv-formats": "^3.0.1",
         "colorette": "^1.2.0",
+        "fastest-levenshtein": "^1.0.16",
         "graphql": "^16.14.1",
-        "js-levenshtein": "^1.1.6",
         "js-yaml": "^5.2.2",
         "json-schema-to-ts": "^3.1.0",
         "jsonpath-rfc9535": "1.3.0",
@@ -13698,7 +13702,6 @@
         "yaml-ast-parser": "0.0.43"
       },
       "devDependencies": {
-        "@types/js-levenshtein": "^1.1.0",
         "@types/picomatch": "^4.0.2",
         "@types/pluralize": "^0.0.29",
         "typescript": "6.0.2"
diff --git a/packages/core/package.json b/packages/core/package.json
index e0efa2a23d..db13ae7cb5 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -57,8 +57,8 @@
     "ajv": "npm:@redocly/ajv@^8.18.3",
     "ajv-formats": "^3.0.1",
     "colorette": "^1.2.0",
+    "fastest-levenshtein": "^1.0.16",
     "graphql": "^16.14.1",
-    "js-levenshtein": "^1.1.6",
     "js-yaml": "^5.2.2",
     "json-schema-to-ts": "^3.1.0",
     "jsonpath-rfc9535": "1.3.0",
@@ -67,7 +67,6 @@
     "yaml-ast-parser": "0.0.43"
   },
   "devDependencies": {
-    "@types/js-levenshtein": "^1.1.0",
     "@types/picomatch": "^4.0.2",
     "@types/pluralize": "^0.0.29",
     "typescript": "6.0.2"
diff --git a/packages/core/src/diff/pair-children.ts b/packages/core/src/diff/pair-children.ts
index 0b4794d43f..95095ade6e 100644
--- a/packages/core/src/diff/pair-children.ts
+++ b/packages/core/src/diff/pair-children.ts
@@ -1,7 +1,8 @@
+import { distance } from 'fastest-levenshtein';
+
 import { keyKindOf, valueOf } from '../node-tree/access.js';
 import type { NodeEntry } from '../node-tree/types.js';
 import { isPlainObject } from '../utils/is-plain-object.js';
-import { levenshteinSimilarity } from '../utils/levenshtein.js';
 import type { Pair } from './types.js';
 
 // Git counts a file as renamed when at least half of it is unchanged; the same share pairs a
@@ -93,11 +94,15 @@ function findMostAlike(
   return mostAlike;
 }
 
-// A field pairs only with the same field.
+// The share of the longer of the two that the edit leaves untouched. A field pairs only with the
+// same field.
 function similarity(child: NodeEntry, other: NodeEntry, known: Map): number {
   if (keyKindOf(child) === 'field') return child.key === other.key ? 1 : 0;
 
-  return levenshteinSimilarity(known.get(child)!, known.get(other)!);
+  const childName = known.get(child)!;
+  const otherName = known.get(other)!;
+  const longest = Math.max(childName.length, otherName.length);
+  return longest === 0 ? 1 : 1 - distance(childName, otherName) / longest;
 }
 
 function jsonWithSortedKeys(value: unknown): string {
diff --git a/packages/core/src/rules/utils.ts b/packages/core/src/rules/utils.ts
index a79f983724..ec0e2314de 100644
--- a/packages/core/src/rules/utils.ts
+++ b/packages/core/src/rules/utils.ts
@@ -1,5 +1,5 @@
 import type { Context as AjvContext } from '@redocly/ajv/dist/2020.js';
-import { default as levenshtein } from 'js-levenshtein';
+import { distance as levenshtein } from 'fastest-levenshtein';
 
 import { isRef, Location } from '../ref-utils.js';
 import type { ResolvedRefChainHop } from '../resolve.js';
diff --git a/packages/core/src/utils/__tests__/levenshtein.test.ts b/packages/core/src/utils/__tests__/levenshtein.test.ts
deleted file mode 100644
index 8a8cab8106..0000000000
--- a/packages/core/src/utils/__tests__/levenshtein.test.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { levenshteinDistance, levenshteinSimilarity } from '../levenshtein.js';
-
-describe('levenshteinDistance', () => {
-  it.each([
-    ['latte', 'latte', 0],
-    ['latte', 'lattes', 1],
-    ['kitten', 'sitting', 3],
-    ['', 'mocha', 5],
-    ['', '', 0],
-  ])('turns "%s" into "%s" in %i edits', (left, right, distance) => {
-    expect(levenshteinDistance(left, right)).toBe(distance);
-  });
-});
-
-describe('levenshteinSimilarity', () => {
-  it.each([
-    ['latte', 'latte', 1],
-    ['tea', 'cup', 0],
-    ['limit', 'limits', 5 / 6],
-    ['', '', 1],
-  ])('rates "%s" and "%s" as %d alike', (left, right, similarity) => {
-    expect(levenshteinSimilarity(left, right)).toBe(similarity);
-  });
-});
diff --git a/packages/core/src/utils/levenshtein.ts b/packages/core/src/utils/levenshtein.ts
deleted file mode 100644
index 0095168af9..0000000000
--- a/packages/core/src/utils/levenshtein.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/** The number of single-character insertions, deletions and substitutions that turn `left` into `right`. */
-export function levenshteinDistance(left: string, right: string): number {
-  let previousRow = Array.from({ length: right.length + 1 }, (_, index) => index);
-
-  for (let leftIndex = 1; leftIndex <= left.length; leftIndex++) {
-    const row = [leftIndex];
-
-    for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) {
-      const substitution = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
-      row[rightIndex] = Math.min(
-        previousRow[rightIndex] + 1,
-        row[rightIndex - 1] + 1,
-        previousRow[rightIndex - 1] + substitution
-      );
-    }
-
-    previousRow = row;
-  }
-
-  return previousRow[right.length];
-}
-
-/**
- * How alike two strings are, from 0 (nothing in common) to 1 (equal): the share of the longer
- * string that the edit leaves untouched. Two empty strings are equal.
- */
-export function levenshteinSimilarity(left: string, right: string): number {
-  const longest = Math.max(left.length, right.length);
-  return longest === 0 ? 1 : 1 - levenshteinDistance(left, right) / longest;
-}

From f3d6233ebb97f5b2f43e45d6362560e4e0acd13b Mon Sep 17 00:00:00 2001
From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com>
Date: Mon, 12 Oct 2026 22:05:45 +0300
Subject: [PATCH 55/57] fix(diff): judge a whole enum set or removed as the
 values it takes away or adds

---
 docs/@v2/rules/diff-rules.md                  |   4 +-
 .../rules/__tests__/enum-values-added.test.ts | 101 ++++++++++++++++++
 .../__tests__/enum-values-removed.test.ts     | 100 +++++++++++++++++
 .../core/src/diff/rules/enum-values-added.ts  |  12 ++-
 .../src/diff/rules/enum-values-removed.ts     |  15 ++-
 5 files changed, 228 insertions(+), 4 deletions(-)

diff --git a/docs/@v2/rules/diff-rules.md b/docs/@v2/rules/diff-rules.md
index e9b4d0642e..e6ed498126 100644
--- a/docs/@v2/rules/diff-rules.md
+++ b/docs/@v2/rules/diff-rules.md
@@ -107,8 +107,8 @@ These rules apply to OpenAPI only.
 | `property-removed`            | A removed or renamed property in a response.                                                                                                                                            |
 | `required-properties-added`   | Properties that became required in a request.                                                                                                                                           |
 | `required-properties-removed` | Properties that are no longer required in a response.                                                                                                                                   |
-| `enum-values-removed`         | Removed `enum` values in a request.                                                                                                                                                     |
-| `enum-values-added`           | New `enum` values in a response.                                                                                                                                                        |
+| `enum-values-removed`         | Removed `enum` values, or a new `enum`, in a request.                                                                                                                                   |
+| `enum-values-added`           | New `enum` values, or a removed `enum`, in a response.                                                                                                                                  |
 | `schema-type-changed`         | A changed `type` or `nullable` that accepts fewer types in a request, or more types in a response.                                                                                      |
 | `schema-constraint-changed`   | A changed constraint that accepts fewer values in a request, or more values in a response.                                                                                              |
 | `schema-combinator-changed`   | An added or removed subschema of `allOf`, `anyOf`, or `oneOf`, or an added or removed list of them, that accepts fewer values in a request, or more values in a response. OpenAPI only. |
diff --git a/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts b/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts
index 15f1545f42..8f1fb0faff 100644
--- a/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts
+++ b/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts
@@ -90,4 +90,105 @@ describe('enum-values-added', () => {
       ]
     `);
   });
+
+  it('should report an enum removed from a response, not an enum set on one', async () => {
+    const order = (status: string, size: string) =>
+      cafe(`{ type: object, properties: { status: ${status}, size: ${size} } }`);
+
+    const result = await diffDocuments({
+      base: makeDocumentFromString(
+        order('{ type: string, enum: [placed, ready] }', '{ type: string }'),
+        'base.yaml'
+      ),
+      revision: makeDocumentFromString(
+        order('{ type: string }', '{ type: string, enum: [small, large] }'),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'enum-values-added': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status/enum",
+            "value": [
+              "placed",
+              "ready",
+            ],
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status",
+          "kind": "modified",
+          "property": "enum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status",
+            "value": undefined,
+          },
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size",
+            "value": undefined,
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size",
+          "kind": "modified",
+          "property": "enum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size/enum",
+            "value": [
+              "small",
+              "large",
+            ],
+          },
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status/enum",
+            "value": [
+              "placed",
+              "ready",
+            ],
+          },
+          "impact": "major",
+          "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status",
+          "kind": "modified",
+          "property": "enum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status",
+            "value": undefined,
+          },
+          "verdicts": [
+            {
+              "impact": "major",
+              "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status",
+              "message": "Enum of \`status\` was removed.",
+              "ruleId": "enum-values-added",
+            },
+          ],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size",
+            "value": undefined,
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size",
+          "kind": "modified",
+          "property": "enum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size/enum",
+            "value": [
+              "small",
+              "large",
+            ],
+          },
+          "verdicts": [],
+        },
+      ]
+    `);
+  });
 });
diff --git a/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts b/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts
index d3a517adac..6fa31a89a0 100644
--- a/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts
+++ b/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts
@@ -168,4 +168,104 @@ describe('enum-values-removed', () => {
       ]
     `);
   });
+
+  it('should report an enum set on a request, not an enum removed from one', async () => {
+    const order = (status: string, size: string) =>
+      cafe(`{ type: object, properties: { status: ${status}, size: ${size} } }`);
+    const result = await diffDocuments({
+      base: makeDocumentFromString(
+        order('{ type: string }', '{ type: string, enum: [small, large] }'),
+        'base.yaml'
+      ),
+      revision: makeDocumentFromString(
+        order('{ type: string, enum: [placed, ready] }', '{ type: string }'),
+        'revision.yaml'
+      ),
+      config: await createConfig({ diff: { 'enum-values-removed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status",
+            "value": undefined,
+          },
+          "impact": "major",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status",
+          "kind": "modified",
+          "property": "enum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status/enum",
+            "value": [
+              "placed",
+              "ready",
+            ],
+          },
+          "verdicts": [
+            {
+              "impact": "major",
+              "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status/enum",
+              "message": "Enum of \`status\` was set to 'placed', 'ready'.",
+              "ruleId": "enum-values-removed",
+            },
+          ],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size/enum",
+            "value": [
+              "small",
+              "large",
+            ],
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size",
+          "kind": "modified",
+          "property": "enum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size",
+            "value": undefined,
+          },
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status",
+            "value": undefined,
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status",
+          "kind": "modified",
+          "property": "enum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status/enum",
+            "value": [
+              "placed",
+              "ready",
+            ],
+          },
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size/enum",
+            "value": [
+              "small",
+              "large",
+            ],
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size",
+          "kind": "modified",
+          "property": "enum",
+          "revision": {
+            "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size",
+            "value": undefined,
+          },
+          "verdicts": [],
+        },
+      ]
+    `);
+  });
 });
diff --git a/packages/core/src/diff/rules/enum-values-added.ts b/packages/core/src/diff/rules/enum-values-added.ts
index 8206b408b9..8b7c245bbc 100644
--- a/packages/core/src/diff/rules/enum-values-added.ts
+++ b/packages/core/src/diff/rules/enum-values-added.ts
@@ -7,7 +7,17 @@ export const EnumValuesAdded: DiffRule = () => ({
     if (change.kind !== 'modified' || change.property !== 'enum') return;
     if (!getDirections().includes('response')) return;
 
-    const added = itemsNotIn(change.revision.value, change.base.value);
+    const { value: before } = change.base;
+    const { value: after } = change.revision;
+
+    if (before === undefined) return;
+
+    if (after === undefined) {
+      report({ message: `Enum${ofNamedSchema(change.node)} was removed.` });
+      return;
+    }
+
+    const added = itemsNotIn(after, before);
     if (!added.length) return;
 
     const values = added.map((value) => `'${value}'`).join(', ');
diff --git a/packages/core/src/diff/rules/enum-values-removed.ts b/packages/core/src/diff/rules/enum-values-removed.ts
index 726dea08f7..bf86b12083 100644
--- a/packages/core/src/diff/rules/enum-values-removed.ts
+++ b/packages/core/src/diff/rules/enum-values-removed.ts
@@ -7,7 +7,20 @@ export const EnumValuesRemoved: DiffRule = () => ({
     if (change.kind !== 'modified' || change.property !== 'enum') return;
     if (!getDirections().includes('request')) return;
 
-    const removed = itemsNotIn(change.base.value, change.revision.value);
+    const { value: before } = change.base;
+    const { value: after } = change.revision;
+
+    if (after === undefined) return;
+
+    if (before === undefined) {
+      const listed = Array.isArray(after) ? after : [];
+      const values = listed.map((value) => `'${value}'`).join(', ');
+
+      report({ message: `Enum${ofNamedSchema(change.node)} was set to ${values}.` });
+      return;
+    }
+
+    const removed = itemsNotIn(before, after);
     if (!removed.length) return;
 
     const values = removed.map((value) => `'${value}'`).join(', ');

From d6fd51a76792408350dd2c2b99e890211ac6a126 Mon Sep 17 00:00:00 2001
From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com>
Date: Mon, 12 Oct 2026 22:14:58 +0300
Subject: [PATCH 56/57] fix(diff): report a property removed from a request
 whose schema rejects unlisted properties

---
 docs/@v2/rules/diff-rules.md                  |  2 +-
 .../rules/__tests__/property-removed.test.ts  | 66 +++++++++++++++++++
 .../core/src/diff/rules/property-removed.ts   | 30 +++++++--
 3 files changed, 92 insertions(+), 6 deletions(-)

diff --git a/docs/@v2/rules/diff-rules.md b/docs/@v2/rules/diff-rules.md
index e6ed498126..62b842e58b 100644
--- a/docs/@v2/rules/diff-rules.md
+++ b/docs/@v2/rules/diff-rules.md
@@ -104,7 +104,7 @@ These rules apply to OpenAPI only.
 
 | Rule                          | Reports                                                                                                                                                                                 |
 | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `property-removed`            | A removed or renamed property in a response.                                                                                                                                            |
+| `property-removed`            | A removed or renamed property in a response, or in a request whose schema rejects unlisted properties (`additionalProperties: false` or `unevaluatedProperties: false`).                |
 | `required-properties-added`   | Properties that became required in a request.                                                                                                                                           |
 | `required-properties-removed` | Properties that are no longer required in a response.                                                                                                                                   |
 | `enum-values-removed`         | Removed `enum` values, or a new `enum`, in a request.                                                                                                                                   |
diff --git a/packages/core/src/diff/rules/__tests__/property-removed.test.ts b/packages/core/src/diff/rules/__tests__/property-removed.test.ts
index ef3fc8105b..0b20c5fbfe 100644
--- a/packages/core/src/diff/rules/__tests__/property-removed.test.ts
+++ b/packages/core/src/diff/rules/__tests__/property-removed.test.ts
@@ -327,4 +327,70 @@ describe('property-removed', () => {
       { message: 'Property `extra` was removed.' },
     ]);
   });
+
+  it('should report a property a request no longer takes only where its schema rejects unlisted properties', async () => {
+    const order = (properties: string, extras: string) => outdent`
+      openapi: 3.1.0
+      info: { title: Cafe, version: 1.0.0 }
+      paths:
+        /orders:
+          post:
+            requestBody:
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    additionalProperties: false
+                    properties:
+                      menuItemId: { type: string }
+                      ${properties}
+                      extras: { type: object, properties: ${extras} }
+            responses:
+              '201': { description: Created }
+    `;
+    const result = await diffDocuments({
+      base: makeDocumentFromString(
+        order('note: { type: string }', '{ sugar: { type: boolean }, milk: { type: boolean } }'),
+        'base.yaml'
+      ),
+      revision: makeDocumentFromString(order('', '{ sugar: { type: boolean } }'), 'revision.yaml'),
+      config: await createConfig({ diff: { 'property-removed': 'major' } }),
+    });
+
+    expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`
+      [
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/extras/properties/milk",
+            "value": {
+              "type": "boolean",
+            },
+          },
+          "impact": "patch",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/extras/properties/milk",
+          "kind": "removed",
+          "verdicts": [],
+        },
+        {
+          "base": {
+            "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note",
+            "value": {
+              "type": "string",
+            },
+          },
+          "impact": "major",
+          "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note",
+          "kind": "removed",
+          "verdicts": [
+            {
+              "impact": "major",
+              "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note",
+              "message": "Property \`note\` was removed.",
+              "ruleId": "property-removed",
+            },
+          ],
+        },
+      ]
+    `);
+  });
 });
diff --git a/packages/core/src/diff/rules/property-removed.ts b/packages/core/src/diff/rules/property-removed.ts
index 5e4e81f2e9..4785a3ac0b 100644
--- a/packages/core/src/diff/rules/property-removed.ts
+++ b/packages/core/src/diff/rules/property-removed.ts
@@ -1,13 +1,17 @@
+import { fieldOf } from '../../node-tree/access.js';
 import { nameOf } from '../diff-node.js';
-import type { DiffRule } from '../types.js';
+import type { DiffNode, DiffRule, Direction } from '../types.js';
 import { ofNamedSchema } from './utils.js';
 
 export const PropertyRemoved: DiffRule = () => ({
   SchemaProperties: {
     Schema(change, { report, getDirections }) {
-      if (!getDirections().includes('response')) return;
+      const objectSchema = change.node.parent?.parent;
+      const directions = getDirections();
 
-      const owner = ofNamedSchema(change.node.parent?.parent);
+      if (!removalBreaksClients(objectSchema, directions)) return;
+
+      const owner = ofNamedSchema(objectSchema);
 
       if (change.kind === 'removed') {
         report({ message: `Property \`${nameOf(change.node)}\`${owner} was removed.` });
@@ -21,9 +25,25 @@ export const PropertyRemoved: DiffRule = () => ({
   },
   Schema: {
     SchemaProperties(change, { report, getDirections }) {
-      if (change.kind === 'removed' && getDirections().includes('response')) {
-        report({ message: `All properties${ofNamedSchema(change.node.parent)} were removed.` });
+      const objectSchema = change.node.parent;
+      const directions = getDirections();
+
+      if (change.kind === 'removed' && removalBreaksClients(objectSchema, directions)) {
+        report({ message: `All properties${ofNamedSchema(objectSchema)} were removed.` });
       }
     },
   },
 });
+
+function removalBreaksClients(
+  objectSchema: DiffNode | null | undefined,
+  directions: Direction[]
+): boolean {
+  if (directions.includes('response')) return true;
+  if (!directions.includes('request')) return false;
+
+  return (
+    fieldOf(objectSchema?.revision, 'additionalProperties') === false ||
+    fieldOf(objectSchema?.revision, 'unevaluatedProperties') === false
+  );
+}

From 822594a2c13006e3c1c53cc030172cd3aa7cb5d0 Mon Sep 17 00:00:00 2001
From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com>
Date: Tue, 13 Oct 2026 11:17:40 +0300
Subject: [PATCH 57/57] chore: update changeset

---
 .changeset/purple-onions-shave.md | 2 --
 1 file changed, 2 deletions(-)

diff --git a/.changeset/purple-onions-shave.md b/.changeset/purple-onions-shave.md
index 0fe296edc6..c197713c37 100644
--- a/.changeset/purple-onions-shave.md
+++ b/.changeset/purple-onions-shave.md
@@ -4,5 +4,3 @@
 ---
 
 Added an experimental `diff` command that compares two API descriptions, reports what was added, removed, and modified, and rates every change by the semver bump it requires (`patch`, `minor`, or `major`). `--fail-on` sets the impact that fails the run and `--check-version` verifies that `info.version` was bumped accordingly.
-
-Configure the impact per rule in `redocly.yaml` with `extends: [diff-recommended]` and a `diff` block, for example `diff: { operation-removed: minor }`. A `oas3_0Diff`, `oas3_1Diff`, `oas3_2Diff` or `async3Diff` block sets the impact for one specification, the way the per-specification rule blocks work for `lint`. `--skip-rule` turns a rule off for a single run.