From b449e4861852da6db7df5aca01bd1e5a9f0f78d0 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/14] 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 191d4ce95dabaa4a7673ac921059c6a0f7f828fe 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/14] 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 df4425aeebcca5bb16fd08f86b7f69e8413eccb3 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/14] 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 8c03043186c0c048351f1a2bf07d240dbad20bb1 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/14] 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 add90a21f3..eb5c388ff7 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]. - [`join`](join.md) Join API descriptions [experimental feature]. - [`score`](score.md) Score an API for integration simplicity and AI agent readiness. - [`split`](split.md) Split API description into a multi-file structure. diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index 0c06ce6aab..db9877694d 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: eject page: commands/eject.md - label: generate-arazzo 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 abfb940d9c..d4a1c3cd56 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 { handleEject, type EjectArgv } from './commands/eject.js'; import { handleGenerateArazzo, @@ -78,6 +79,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 63d5bde1bb4106db79b8f6eb4d13a005aae548d1 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 19:45:20 +0300 Subject: [PATCH 05/14] 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 95bf67510f3583a4fa30bc18fab3c155c4dce10d Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 19:50:20 +0300 Subject: [PATCH 06/14] 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 d4a1c3cd56..d84d934ad6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -108,9 +108,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 50901fd9dae41e43c9f0b6cd11a22131772eee0f Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 19:57:40 +0300 Subject: [PATCH 07/14] 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 ca0e273f6517587675d24e81119d10b9f7c4a7ed Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:03:08 +0300 Subject: [PATCH 08/14] 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 773d4b214c1484b4fb8363f4f9b90501f6dec453 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:07:07 +0300 Subject: [PATCH 09/14] 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 d84d934ad6..3f193f668f 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 { handleEject, type EjectArgv } from './commands/eject.js'; import { handleGenerateArazzo, @@ -96,9 +96,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 979543e40d7d1da2534e8354291ae3bdcdda83c8 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:11:37 +0300 Subject: [PATCH 10/14] 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 cc7b1e0376c73aeac95e4887e74c45b6be891b49 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:22:30 +0300 Subject: [PATCH 11/14] 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 aa4405572dfb42eb26dceb714a86361d10d60b1d Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:36:52 +0300 Subject: [PATCH 12/14] 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 02888c051bbc3f51c064a1cd712c6c0ade7dc367 Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:43:11 +0300 Subject: [PATCH 13/14] 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 fe59519bb559f44df70177a165a9ee76ae091b6c Mon Sep 17 00:00:00 2001 From: Andrew Tatomyr Date: Tue, 7 Jul 2026 20:55:49 +0300 Subject: [PATCH 14/14] 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; }