From 649c795b5b3a7fe761b4117c74c502e2d0d85578 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/20] 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 b35c8796f1fb0e46271541af152709b7e5d2f7b5 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/20] 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 bae9999b31f05520fa32ecc158d99f10cce0cc07 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/20] 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 a8062de5dd00774c1beb4971940d32dc41aaf458 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/20] 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 | 40 ++++++ 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, 2395 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 3d4107239e..ae0b3d69bb 100644 --- a/docs/@v2/commands/index.md +++ b/docs/@v2/commands/index.md @@ -14,6 +14,7 @@ Documentation commands: API management commands: - [`bundle`](bundle.md) Bundle API description. +- [`diff`](diff.md) Compare two API descriptions and detect breaking changes [experimental feature]. - [`generate-client`](generate-client.md) Generate a typed TypeScript client from an OpenAPI description [experimental feature]. - [`join`](join.md) Join API descriptions [experimental feature]. - [`score`](score.md) Score an API for integration simplicity and AI agent readiness. diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index a7720f844e..673a7c4632 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -14,6 +14,8 @@ page: commands/bundle.md - label: check-config page: commands/check-config.md + - label: diff + page: commands/diff.md - label: drift page: commands/drift.md - label: eject diff --git a/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 894cb5d568..a177af4bdc 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -15,6 +15,7 @@ import { hideBin } from 'yargs/helpers'; import { handleLogin, handleLogout } from './commands/auth.js'; import type { BuildDocsArgv } from './commands/build-docs/types.js'; import { handleBundle } from './commands/bundle.js'; +import { handleDiff, type DiffArgv } from './commands/diff/index.js'; import type { ReportFormat } from './commands/drift/engine/reporter.js'; import { type DriftArgv } from './commands/drift/index.js'; import type { FindingSeverity, MatchMode, TrafficFormat } from './commands/drift/types/index.js'; @@ -87,6 +88,45 @@ yargs(hideBin(process.argv)) commandWrapper(handleStats)(argv); } ) + .command( + 'diff ', + 'Compare two API descriptions and detect breaking changes [experimental].', + (yargs) => + yargs + .env('REDOCLY_CLI_DIFF') + .positional('base', { type: 'string', demandOption: true }) + .positional('revision', { type: 'string', demandOption: true }) + .option({ + config: { description: 'Path to the config file.', type: 'string' }, + 'lint-config': { + description: 'Severity level for config file linting.', + choices: ['warn', 'error', 'off'] as ReadonlyArray, + default: 'warn' as RuleSeverity, + }, + format: { + description: 'Use a specific output format.', + choices: ['stylish', 'json', 'markdown', 'html'] 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 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 df0d63d0b7b920419b6ba7dff7511bb567dd49e2 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 19:45:20 +0300 Subject: [PATCH 05/20] 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 5a0353489627f66b3d5055be1117d3322d94e143 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 19:50:20 +0300 Subject: [PATCH 06/20] 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 a177af4bdc..1bc0d1489e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -117,9 +117,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 9945f46a9514f9c799da474223dbbd68bdd49686 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 19:57:40 +0300 Subject: [PATCH 07/20] 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 e875ff4b99f996c24f27a83aa0b9c3c629351c04 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:03:08 +0300 Subject: [PATCH 08/20] 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 429fd2a410efdddf892077cd4af1a45ee3ada3f4 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:07:07 +0300 Subject: [PATCH 09/20] 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 1bc0d1489e..30784e789e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -15,7 +15,7 @@ import { hideBin } from 'yargs/helpers'; import { handleLogin, handleLogout } from './commands/auth.js'; import type { BuildDocsArgv } from './commands/build-docs/types.js'; import { handleBundle } from './commands/bundle.js'; -import { handleDiff, type DiffArgv } from './commands/diff/index.js'; +import { handleDiff, type DiffArgv, type DiffOutputFormat } from './commands/diff/index.js'; import type { ReportFormat } from './commands/drift/engine/reporter.js'; import { type DriftArgv } from './commands/drift/index.js'; import type { FindingSeverity, MatchMode, TrafficFormat } from './commands/drift/types/index.js'; @@ -105,9 +105,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 1f2efae23f..d90434f741 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 25c8896fb052fb066280f17e9331d63618eec727 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:11:37 +0300 Subject: [PATCH 10/20] 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 07006f17242837202217ce4e48dcd481b72b8136 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:22:30 +0300 Subject: [PATCH 11/20] 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 c7b429cbf02354da49424a7da902ffc86f9ad540 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:36:52 +0300 Subject: [PATCH 12/20] 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 fac2bd36ca63b695cd43010062f118169d9c09a1 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:43:11 +0300 Subject: [PATCH 13/20] 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 75834bc59958ca0ad68d84e3c4ecc7811af26278 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:55:49 +0300 Subject: [PATCH 14/20] 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 3b96e23be716b344c6304c8e3129e3992bf158ea 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/20] 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 da1d2032c68c55b9aa6b1e6ca07f5f1320d96a34 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/20] 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 30784e789e..cc29ff6e94 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -105,7 +105,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 ba606a40dd256640069f9da2d60c0a01d3c961af 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/20] 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 05795abcc088cd61b260bb2c6bf8c593be6d46ef 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/20] 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 7d66075ba5a8115ea6370f97954237085eadf71b 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/20] 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 8e52378914a8b323cbc48ad22d8a366bad7f9a6e 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/20] 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('