feat(cli): add an experimental diff command - #2940
Draft
vadyvas wants to merge 20 commits into
Draft
Conversation
🦋 Changeset detectedLatest commit: 8e52378 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Contributor
Author
|
@RomanHotsiy flowchart LR
A["base + revision"] --> C["collect ×2<br/>(walkDocument → flat maps)"] --> D["compare<br/>(set diff)"] --> E["classify<br/>(polarity + rules)"] --> F["report<br/>(stylish/json/md/html)"]
C -. "$ref edges" .-> U[UsageIndex] -. polarity .-> E
Inputs for now are files or urls - resolved through the existing
redocly diff base.yaml revision.yaml
redocly diff base.yaml revision.yaml --fail-on=breaking --format=json |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… verdicts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ons, and path-param matching Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e case Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adapt to the new collectSpecData signature (it now takes the document, not its parsed value) and regenerate the e2e snapshots, which still held the original flat output: they predate the two-level compat model, the per-operation grouping, the location and verdict lines, and the fail-on summary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lint formatters in core already cover the formats CI tools expect, so map breaking changes onto lint problems and hand them to formatProblems instead of writing six more serializers. This adds codeframe, checkstyle, codeclimate, summary, github-actions, and junit to the diff command; with github-actions, every breaking change becomes an inline pull request annotation. A lint problem always carries a severity, so these formats describe breaking changes only — the full change list stays in the json format. They print to stdout, and --output now reports that clearly instead of writing nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One test per rule, each with its own minimal base/revision pair, asserting the rule id the command should attribute the change to. Thirteen of them fail today and describe the intended contract: request body required and removed, string and numeric constraint tightening, additionalProperties, oneOf narrowing, format, the three security cases, response headers, and parameter serialization. Two of the failures are false positives rather than gaps: widening a request type is reported as breaking, and 3.0 `nullable: true` compared against 3.1 `type: [.., 'null']` reports a change although both describe the same schema. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Polarity was inferred from the pointer text, which mistook a schema property named `responses` for the context of the same name and had to give up entirely under `callbacks` and `webhooks`. It now walks the ancestors the walker recorded and reads their node types, so the direction below a callback or a webhook is flipped rather than skipped, and a property can no longer pose as a context. That also uncovered a real defect: a usage edge named the `$ref` path, which is not a node and so could never be looked up; it now names the node holding the reference. The new rules cover request bodies becoming required or disappearing, numeric and string constraints, `format`, `additionalProperties`, `oneOf`/`allOf` membership, response headers, parameter serialization, and security schemes and requirements. They share one vocabulary: a constraint moves `tighter` or `looser`, and the engine's polarity decides which of the two breaks. Two false positives are gone with them. A type is now compared as the set of values it accepts, so widening a request type is no longer breaking, and 3.0's `nullable: true` folds into 3.1's `type: [..., 'null']` so the two spellings compare as equal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-rule fixtures now sit beside breaking-changes and follow the same shape — base.yaml, revision.yaml and a stylish snapshot — so each rule's report is reviewable as the output a reader actually sees. Every test still names the rule id it exercises, so a regenerated snapshot cannot quietly stop covering it. Reviewing the snapshots turned up a stray label: a change on the document root rendered with an empty name before the property. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vadyvas
force-pushed
the
feat/diff-command
branch
from
August 7, 2026 16:47
fe59519 to
7d66075
Compare
Contributor
Performance Benchmark (Lower is Faster)
|
Contributor
Follows up the code review on the diff command. Rules and engine: - Revert the `html` addition to core's `OutputFormat`. `formatProblems` has no `case 'html'` and no `default`, so it typechecked and silently did nothing. The diff command spells out its own formats instead. - Report `security-requirement-added` when an explicitly empty `security: []` list gets its first entry, and add `security-scopes-added`. - Register `parameter-removed` and `parameter-added-required` for `ParameterList` as well: the last parameter of an operation leaves with the whole `parameters` list, and the first one arrives with it. - Invert the direction once per nesting level under `callbacks`, so a callback inside a callback points the right way. - Add six AsyncAPI 3 rules (`channel-removed`, `channel-address-changed`, `message-removed`, `message-content-type-changed`, `operation-action-changed`, `server-removed`) and reuse the schema rules for payloads. AsyncAPI declares the direction with `action`, so it gets its own resolver; a channel takes its direction from the operations that reference it. - Drop `isScalarArray([])`, which reported an empty list twice. - Use core's `dequal` and `isAbsoluteUrl` instead of local equivalents. - Fix double escaping in the markdown report, which broke the rule-id code span. - Reject `--output` for stdout-only formats before bundling both documents. Tests: - One `__tests__` folder, as every other command in the package has. - Snapshot the collected map, the emitted changes, the verdicts and the reports, so the whole output stays visible. - Drop tests that asserted a dependency's behaviour or a one-line helper. - Move per-rule coverage to e2e: one fixture per rule, prefixed `oas3-` or `async3-`, asserted on the machine-readable report. Docs: rewrite the command page in simpler English and stop leaking the internal word "polarity" into user-facing text.
Comment on lines
+12
to
+14
| return value | ||
| .replace(/\|/g, '\\|') | ||
| .replace(/`/g, '\\`') |
Comment on lines
+12
to
+13
| return value | ||
| .replace(/\|/g, '\\|') |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What/Why/How?
Adds an experimental
redocly diff <base> <revision>command that compares two API descriptions and reports what was added, removed, and changed.openapi-coretype trees — no hand-written traversal per type.{ id, description, visit(change, ctx) }object; every rule runs, every verdict is kept, and the worst one decides the change. The scale is binary —breaking/non-breaking— because a verdict either breaks a consumer or it does not. What cannot be judged (a$refthat now points elsewhere) is reported as breaking.walkDocumentinto a flat map keyed by a stable pointer → compare the two maps in two passes → classify. Array indexes become identity keys (a parameter byin+name, a server byurl), so reordering is not a change. A$refis recorded as an attribute and never followed, so a shared component is compared once, at its own path.callbacksandwebhooksinvert it, and AsyncAPI 3 declares it outright withaction: send|receive— which is why an AsyncAPI payload is judged by the same schema rules as an OpenAPI one.stylish,json,markdown,html, plus thelintformats (codeframe,checkstyle,codeclimate,summary,github-actions,junit) for the breaking changes, so existing CI integrations accept the report as it is.--fail-on breaking|none(defaultbreaking) sets the exit code.Nothing is added to
openapi-core. The diff of this branch againstmaintouchespackages/cli,tests/e2e/diff,docs, and the changeset only. The command reuses what core already exports —bundle,walkDocument,normalizeVisitors,getTypes,detectSpec,getLineColLocation,formatProblems,dequal— and keeps everything of its own underpackages/cli/src/commands/diff.Comparison by URL or path is supported; comparison of two Git revisions is not wired up yet.
Reference
Documented in
docs/@v2/commands/diff.md: the options, how the direction is decided, the rule catalog for both specifications, and the limitations (component renames, positional matching insideallOf/oneOf/anyOf, and the AsyncAPI features that have no rules yet).The rule catalog in the docs is checked against the registries by a unit test, so a rule cannot ship without a row, and a row cannot outlive its rule.
Testing
Unit —
packages/cli/src/commands/diff/__tests__. The engine invariants that the terminal output cannot show: stable pointers and identity keys, subtree collapse in the comparison, path-rename matching and its ambiguous fallback, the direction for both specification families (including a payload that references itself), verdict aggregation, and the rendered reports as snapshots.e2e —
tests/e2e/diff. One fixture per rule, namedoas3-*orasync3-*, each abase.yaml/revision.yamlpair differing only in what it exercises. Each test reads the verdict from the machine-readable report and snapshots the terminal one. A rule is registered per node type, so only a real run catches a rule wired to a type the walker never reports — two such gaps in the parameter rules were found this way. Mirror fixtures cover the edits that must stay non-breaking, so a rule that stops reading the direction fails there instead of passing everywhere.Screenshots (optional)
Check yourself
Security