specdiff 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +446 -2
- package/dist/cli.js +1628 -0
- package/package.json +67 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-11-01
|
|
8
|
+
|
|
9
|
+
First release.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Loading and validation of OpenAPI 3.0.x and 3.1.x documents, including relative file `$ref`s and
|
|
14
|
+
recursive schemas. `http(s)` references are not fetched.
|
|
15
|
+
- Normalization: path templates matched by shape, parameters keyed by `(in, name)` (path
|
|
16
|
+
parameters by position, header names case-insensitive), path-level parameters merged into
|
|
17
|
+
operations, and 3.0 `nullable` / 3.1 `type` arrays treated as the same thing.
|
|
18
|
+
- Direction-aware schema comparison with cycle detection: requests break when stricter, responses
|
|
19
|
+
break when looser or lossy.
|
|
20
|
+
- Rule registry with 49 rules covering paths, operations, parameters, request bodies, responses,
|
|
21
|
+
schemas, security requirements and unsupported constructs. See `SPEC.md`.
|
|
22
|
+
- `specdiff <base> <head>` CLI with `--format text|json|markdown`, `--fail-on`, `--include-safe`,
|
|
23
|
+
`--ignore`, `--version` and `--help`, and exit codes `0`, `1`, `2`, `130` and `141`.
|
|
24
|
+
- Deterministic text, JSON (`schemaVersion: 1`) and Markdown reports.
|
|
25
|
+
- One fixture per rule, normalization, recursion and cross-file tests, CLI and process tests, and a
|
|
26
|
+
golden end-to-end test on a 33-operation example (`examples/bookstore`).
|
|
27
|
+
- GitHub Actions workflow for the project and a documented recipe for gating a pull request's
|
|
28
|
+
OpenAPI file against its base branch.
|
|
29
|
+
- Publish-ready npm package: `specdiff` exposes a `specdiff` binary, ships only `dist/`, the README,
|
|
30
|
+
the LICENSE and this changelog, and builds and runs the full check before every publish.
|
|
31
|
+
- Landing page and in-browser Playground at <https://specdiff.netlify.app/>.
|
|
32
|
+
|
|
33
|
+
[0.1.0]: https://github.com/yagnang-vyas/spec-diff/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yagnang Vyas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,447 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SpecDiff
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/specdiff)
|
|
4
|
+
[](https://github.com/yagnang-vyas/spec-diff/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](package.json)
|
|
7
|
+
|
|
8
|
+
SpecDiff compares two OpenAPI 3.x documents and classifies every contract change as **breaking**,
|
|
9
|
+
**warning**, **safe** or **info**, so API changes can be gated in pull requests instead of being
|
|
10
|
+
discovered by mobile and partner clients after a deploy.
|
|
11
|
+
|
|
12
|
+
A pull request that removes a response field, makes a query parameter required or narrows an enum
|
|
13
|
+
is hard to spot in a large YAML diff. SpecDiff reads both documents, resolves every `$ref`
|
|
14
|
+
(including recursive schemas and relative files), and tells you which changes can break existing
|
|
15
|
+
clients, with the rule that fired and where in each document it happened.
|
|
16
|
+
|
|
17
|
+
- Direction-aware: request schemas break when they get stricter, response schemas break when they
|
|
18
|
+
get looser or lose data.
|
|
19
|
+
- OpenAPI 3.0.x and 3.1.x, compared against each other (`nullable: true` and `type: [T, "null"]`
|
|
20
|
+
are the same thing).
|
|
21
|
+
- Text, JSON and Markdown reports with deterministic ordering.
|
|
22
|
+
- Exit codes built for CI, configurable with `--fail-on`.
|
|
23
|
+
- Honest about its limits: constructs it does not analyze are reported, not ignored.
|
|
24
|
+
|
|
25
|
+
**[Live Playground](https://specdiff.netlify.app/)**: try SpecDiff in the browser, no install needed.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
SpecDiff needs Node.js 22.19 or newer. It is published on npm as
|
|
30
|
+
[`specdiff`](https://www.npmjs.com/package/specdiff).
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npm install --global specdiff # installs the `specdiff` command
|
|
34
|
+
specdiff --version
|
|
35
|
+
|
|
36
|
+
npx specdiff base.yaml head.yaml # or run it once without installing
|
|
37
|
+
npm install --save-dev specdiff # or pin it per project, then: npx specdiff ...
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
To try it without installing anything, use the
|
|
41
|
+
[Playground](https://specdiff.netlify.app/playground/).
|
|
42
|
+
|
|
43
|
+
To build from source instead:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
git clone https://github.com/yagnang-vyas/spec-diff.git
|
|
47
|
+
cd spec-diff
|
|
48
|
+
npm ci
|
|
49
|
+
npm run build
|
|
50
|
+
node dist/cli.js --help # or: npm link to get a `specdiff` command
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
specdiff <base> <head> [--format text|json|markdown] [--fail-on breaking|warning|never]
|
|
57
|
+
[--include-safe] [--ignore RULE_ID,...] [--version] [--help]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
| Option | Meaning |
|
|
61
|
+
| --------------------- | --------------------------------------------------------------------------------------------------- |
|
|
62
|
+
| `--format` | `text` (default), `json` or `markdown` |
|
|
63
|
+
| `--fail-on` | Exit `1` when a change at or above this severity exists: `breaking` (default), `warning` or `never` |
|
|
64
|
+
| `--include-safe` | List safe changes too (hidden by default; they are still counted in the summary) |
|
|
65
|
+
| `--ignore` | Drop rules by ID, comma separated or repeated. Unknown IDs are an error |
|
|
66
|
+
| `--version`, `--help` | Print the version or usage |
|
|
67
|
+
|
|
68
|
+
`info` notes (renamed path parameters, unsupported constructs) are shown by default and never affect
|
|
69
|
+
the exit code.
|
|
70
|
+
|
|
71
|
+
### Exit codes
|
|
72
|
+
|
|
73
|
+
| Code | Meaning |
|
|
74
|
+
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
75
|
+
| `0` | Nothing at or above the `--fail-on` threshold |
|
|
76
|
+
| `1` | Changes at or above the threshold |
|
|
77
|
+
| `2` | Usage or input error: bad flags, unreadable file, invalid or unsupported spec (the message is prefixed with the file name), or an unexpected internal error |
|
|
78
|
+
| `130` | Interrupted (SIGINT) |
|
|
79
|
+
| `141` | Output pipe closed early (for example when piping into `head -1`); exits quietly |
|
|
80
|
+
|
|
81
|
+
## Sample output
|
|
82
|
+
|
|
83
|
+
The repository ships an original, synthetic 33-operation bookstore API in
|
|
84
|
+
[`examples/bookstore`](examples/bookstore). `v2.yaml` mixes 60+ changes of every severity into
|
|
85
|
+
`v1.yaml`. Everything below is the real output of the commands shown, and the test suite fails if
|
|
86
|
+
it drifts.
|
|
87
|
+
|
|
88
|
+
### Text
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
specdiff examples/bookstore/v1.yaml examples/bookstore/v2.yaml
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
specdiff 0.1.0
|
|
96
|
+
base: examples/bookstore/v1.yaml (Lantern Books API 1.0.0, OpenAPI 3.0.3)
|
|
97
|
+
head: examples/bookstore/v2.yaml (Lantern Books API 2.0.0, OpenAPI 3.0.3)
|
|
98
|
+
|
|
99
|
+
POST /authors
|
|
100
|
+
WARNING REQ_CONSTRAINT_TIGHTENED request body application/json: pattern of "slug" changed from "^[a-z0-9-]+$" to "^[a-z0-9_-]+$"
|
|
101
|
+
|
|
102
|
+
PUT /authors/{authorId}
|
|
103
|
+
WARNING REQ_CONSTRAINT_TIGHTENED request body application/json: pattern of "slug" changed from "^[a-z0-9-]+$" to "^[a-z0-9_-]+$"
|
|
104
|
+
|
|
105
|
+
DELETE /authors/{authorId}
|
|
106
|
+
BREAKING OP_REMOVED operation DELETE /authors/{authorId} removed
|
|
107
|
+
|
|
108
|
+
GET /authors/{authorId}/books
|
|
109
|
+
WARNING RESP_ENUM_WIDENED 200 application/json: enum of "value[].format" widened: added "audiobook"
|
|
110
|
+
BREAKING RESP_PROPERTY_REMOVED 200 application/json: property "value[].subtitle" removed
|
|
111
|
+
|
|
112
|
+
GET /books
|
|
113
|
+
WARNING RESP_ENUM_WIDENED 200 application/json: enum of "items[].format" widened: added "audiobook"
|
|
114
|
+
BREAKING RESP_PROPERTY_REMOVED 200 application/json: property "items[].subtitle" removed
|
|
115
|
+
|
|
116
|
+
POST /books
|
|
117
|
+
BREAKING REQ_PROPERTY_REQUIRED_ADDED request body application/json: property "isbn13" added as required
|
|
118
|
+
WARNING RESP_ENUM_WIDENED 201 application/json: enum of "format" widened: added "audiobook"
|
|
119
|
+
BREAKING RESP_PROPERTY_REMOVED 201 application/json: property "subtitle" removed
|
|
120
|
+
|
|
121
|
+
GET /books/{bookId}
|
|
122
|
+
WARNING PARAM_REMOVED query parameter "include" removed
|
|
123
|
+
WARNING RESP_ENUM_WIDENED 200 application/json: enum of "format" widened: added "audiobook"
|
|
124
|
+
BREAKING RESP_PROPERTY_REMOVED 200 application/json: property "subtitle" removed
|
|
125
|
+
|
|
126
|
+
PUT /books/{bookId}
|
|
127
|
+
BREAKING REQ_PROPERTY_REQUIRED_ADDED request body application/json: property "isbn13" added as required
|
|
128
|
+
WARNING RESP_ENUM_WIDENED 200 application/json: enum of "format" widened: added "audiobook"
|
|
129
|
+
BREAKING RESP_PROPERTY_REMOVED 200 application/json: property "subtitle" removed
|
|
130
|
+
|
|
131
|
+
PATCH /books/{bookId}
|
|
132
|
+
BREAKING REQ_BODY_BECAME_REQUIRED request body is now required
|
|
133
|
+
WARNING RESP_ENUM_WIDENED 200 application/json: enum of "format" widened: added "audiobook"
|
|
134
|
+
BREAKING RESP_PROPERTY_REMOVED 200 application/json: property "subtitle" removed
|
|
135
|
+
|
|
136
|
+
POST /books/{bookId}/reviews
|
|
137
|
+
BREAKING REQ_CONSTRAINT_TIGHTENED request body application/json: maximum of "rating" tightened from 10 to 5
|
|
138
|
+
|
|
139
|
+
GET /customers
|
|
140
|
+
BREAKING RESP_BECAME_NULLABLE 200 application/json: "value[].email" can now be null
|
|
141
|
+
INFO UNSUPPORTED_CONSTRUCT 200 application/json: "oneOf" differs between versions at "value[].preferences.contact" (not analyzed, review manually)
|
|
142
|
+
|
|
143
|
+
POST /customers
|
|
144
|
+
INFO UNSUPPORTED_CONSTRUCT request body application/json: "oneOf" differs between versions at "preferences.contact" (not analyzed, review manually)
|
|
145
|
+
BREAKING RESP_BECAME_NULLABLE 201 application/json: "email" can now be null
|
|
146
|
+
INFO UNSUPPORTED_CONSTRUCT 201 application/json: "oneOf" differs between versions at "preferences.contact" (not analyzed, review manually)
|
|
147
|
+
|
|
148
|
+
GET /customers/{customerId}
|
|
149
|
+
BREAKING RESP_BECAME_NULLABLE 200 application/json: "email" can now be null
|
|
150
|
+
INFO UNSUPPORTED_CONSTRUCT 200 application/json: "oneOf" differs between versions at "preferences.contact" (not analyzed, review manually)
|
|
151
|
+
WARNING RESP_STATUS_REMOVED response status 404 removed
|
|
152
|
+
|
|
153
|
+
PUT /customers/{customerId}
|
|
154
|
+
INFO UNSUPPORTED_CONSTRUCT request body application/json: "oneOf" differs between versions at "preferences.contact" (not analyzed, review manually)
|
|
155
|
+
BREAKING RESP_BECAME_NULLABLE 200 application/json: "email" can now be null
|
|
156
|
+
INFO UNSUPPORTED_CONSTRUCT 200 application/json: "oneOf" differs between versions at "preferences.contact" (not analyzed, review manually)
|
|
157
|
+
|
|
158
|
+
GET /orders
|
|
159
|
+
BREAKING PARAM_BECAME_REQUIRED query parameter "status" is now required
|
|
160
|
+
WARNING RESP_ENUM_WIDENED 200 application/json: enum of "value[].status" widened: added "refunded"
|
|
161
|
+
BREAKING TYPE_CHANGED 200 application/json: type of "value[].total" changed from integer to number
|
|
162
|
+
|
|
163
|
+
POST /orders
|
|
164
|
+
BREAKING REQ_ENUM_NARROWED request body application/json: enum of "paymentMethod" narrowed: removed "cheque"
|
|
165
|
+
WARNING RESP_ENUM_WIDENED 201 application/json: enum of "status" widened: added "refunded"
|
|
166
|
+
BREAKING TYPE_CHANGED 201 application/json: type of "total" changed from integer to number
|
|
167
|
+
|
|
168
|
+
/orders/{id}/items
|
|
169
|
+
INFO PATH_PARAM_RENAMED path template renamed from /orders/{orderId}/items to /orders/{id}/items (same shape, treated as the same path)
|
|
170
|
+
|
|
171
|
+
GET /orders/{orderId}
|
|
172
|
+
WARNING RESP_ENUM_WIDENED 200 application/json: enum of "status" widened: added "refunded"
|
|
173
|
+
BREAKING TYPE_CHANGED 200 application/json: type of "total" changed from integer to number
|
|
174
|
+
|
|
175
|
+
POST /orders/{orderId}/cancel
|
|
176
|
+
WARNING OP_DEPRECATED operation is now deprecated
|
|
177
|
+
WARNING RESP_ENUM_WIDENED 200 application/json: enum of "status" widened: added "refunded"
|
|
178
|
+
BREAKING TYPE_CHANGED 200 application/json: type of "total" changed from integer to number
|
|
179
|
+
|
|
180
|
+
/publishers/{publisherId}
|
|
181
|
+
BREAKING PATH_REMOVED path /publishers/{publisherId} removed
|
|
182
|
+
|
|
183
|
+
GET /search
|
|
184
|
+
WARNING RESP_ENUM_WIDENED 200 application/json: enum of "value[].format" widened: added "audiobook"
|
|
185
|
+
BREAKING RESP_PROPERTY_REMOVED 200 application/json: property "value[].subtitle" removed
|
|
186
|
+
BREAKING SECURITY_ADDED security requirement became stricter (was none, now bearerAuth)
|
|
187
|
+
|
|
188
|
+
Summary: 24 breaking, 16 warning, 7 info (15 safe hidden, use --include-safe to list them)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Markdown
|
|
192
|
+
|
|
193
|
+
`--format markdown` produces a summary table and one section per operation, suitable for a pull
|
|
194
|
+
request comment or a job summary (first lines shown):
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
specdiff examples/bookstore/v1.yaml examples/bookstore/v2.yaml --format markdown
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
```markdown
|
|
201
|
+
## SpecDiff report
|
|
202
|
+
|
|
203
|
+
- Base: `examples/bookstore/v1.yaml (Lantern Books API 1.0.0, OpenAPI 3.0.3)`
|
|
204
|
+
- Head: `examples/bookstore/v2.yaml (Lantern Books API 2.0.0, OpenAPI 3.0.3)`
|
|
205
|
+
|
|
206
|
+
| Severity | Changes |
|
|
207
|
+
| --- | ---: |
|
|
208
|
+
| Breaking | 24 |
|
|
209
|
+
| Warning | 16 |
|
|
210
|
+
| Safe | 15 (hidden) |
|
|
211
|
+
| Info | 7 |
|
|
212
|
+
|
|
213
|
+
### `POST /authors`
|
|
214
|
+
|
|
215
|
+
| Severity | Rule | Change |
|
|
216
|
+
| --- | --- | --- |
|
|
217
|
+
| Warning | `REQ_CONSTRAINT_TIGHTENED` | request body application/json: pattern of "slug" changed from "^\[a-z0-9-\]+$" to "^\[a-z0-9\_-\]+$" |
|
|
218
|
+
|
|
219
|
+
### `PUT /authors/{authorId}`
|
|
220
|
+
|
|
221
|
+
| Severity | Rule | Change |
|
|
222
|
+
| --- | --- | --- |
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### JSON
|
|
226
|
+
|
|
227
|
+
`--format json` prints a stable document (`schemaVersion: 1`); keys and change order are
|
|
228
|
+
deterministic. Locations are JSON-pointer-like paths into each document (`null` when the element
|
|
229
|
+
does not exist on that side). The start of the output with `--include-safe`:
|
|
230
|
+
|
|
231
|
+
```sh
|
|
232
|
+
specdiff examples/bookstore/v1.yaml examples/bookstore/v2.yaml --format json --include-safe
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
```json
|
|
236
|
+
{
|
|
237
|
+
"schemaVersion": 1,
|
|
238
|
+
"tool": {
|
|
239
|
+
"name": "specdiff",
|
|
240
|
+
"version": "0.1.0"
|
|
241
|
+
},
|
|
242
|
+
"base": {
|
|
243
|
+
"file": "examples/bookstore/v1.yaml",
|
|
244
|
+
"openapi": "3.0.3",
|
|
245
|
+
"title": "Lantern Books API",
|
|
246
|
+
"version": "1.0.0"
|
|
247
|
+
},
|
|
248
|
+
"head": {
|
|
249
|
+
"file": "examples/bookstore/v2.yaml",
|
|
250
|
+
"openapi": "3.0.3",
|
|
251
|
+
"title": "Lantern Books API",
|
|
252
|
+
"version": "2.0.0"
|
|
253
|
+
},
|
|
254
|
+
"changes": [
|
|
255
|
+
{
|
|
256
|
+
"id": "PARAM_ADDED_OPTIONAL",
|
|
257
|
+
"severity": "safe",
|
|
258
|
+
"operation": "GET /authors",
|
|
259
|
+
"location": {
|
|
260
|
+
"base": null,
|
|
261
|
+
"head": "paths./authors.get.parameters.query.sort"
|
|
262
|
+
},
|
|
263
|
+
"message": "optional query parameter \"sort\" added"
|
|
264
|
+
},
|
|
265
|
+
...
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## How compatibility is decided
|
|
269
|
+
|
|
270
|
+
A schema describes data flowing in one direction, and what counts as a compatible change is
|
|
271
|
+
different in each direction.
|
|
272
|
+
|
|
273
|
+
**Why requests and responses differ.** The server _consumes_ requests, so a request change breaks
|
|
274
|
+
clients when the server accepts **less** than before: a new required field, a removed enum value,
|
|
275
|
+
a smaller `maxLength`. The server _produces_ responses, so a response change breaks clients when
|
|
276
|
+
the server may now send **more** than clients were promised or **less** than they relied on: a
|
|
277
|
+
removed field, a field that may now be `null`, a larger `maxLength`, a new enum value that an
|
|
278
|
+
exhaustive `switch` has never seen. The same edit is therefore safe in one direction and breaking
|
|
279
|
+
in the other: widening `integer` to `number` is fine in a request and breaking in a response.
|
|
280
|
+
|
|
281
|
+
SpecDiff compares dereferenced schemas, treats a pair of schemas that is already being compared
|
|
282
|
+
higher up the stack as equal (so recursive schemas terminate), and never reports a stricter
|
|
283
|
+
response or a looser request as breaking.
|
|
284
|
+
|
|
285
|
+
### Rule reference
|
|
286
|
+
|
|
287
|
+
Severity is the default; a few rules override it as noted. See [SPEC.md](SPEC.md) for the full
|
|
288
|
+
rationale and the numbered design decisions.
|
|
289
|
+
|
|
290
|
+
| Rule | Severity | Meaning |
|
|
291
|
+
| -------------------------------------- | -------- | ------------------------------------------------------------------- |
|
|
292
|
+
| `PATH_REMOVED` | breaking | path removed |
|
|
293
|
+
| `PATH_ADDED` | safe | path added |
|
|
294
|
+
| `PATH_PARAM_RENAMED` | info | path template renamed with the same shape |
|
|
295
|
+
| `OP_REMOVED` | breaking | HTTP method removed from a path that still exists |
|
|
296
|
+
| `OP_ADDED` | safe | HTTP method added |
|
|
297
|
+
| `OP_DEPRECATED` | warning | operation newly deprecated |
|
|
298
|
+
| `PARAM_ADDED_REQUIRED` | breaking | new required parameter |
|
|
299
|
+
| `PARAM_ADDED_OPTIONAL` | safe | new optional parameter |
|
|
300
|
+
| `PARAM_BECAME_REQUIRED` | breaking | parameter became required |
|
|
301
|
+
| `PARAM_BECAME_OPTIONAL` | safe | parameter became optional |
|
|
302
|
+
| `PARAM_REMOVED` | warning | parameter removed |
|
|
303
|
+
| `PARAM_LOCATION_CHANGED` | breaking | parameter moved to a different location |
|
|
304
|
+
| `PARAM_SERIALIZATION_CHANGED` | breaking | effective style/explode changed |
|
|
305
|
+
| `REQ_BODY_BECAME_REQUIRED` | breaking | request body became required, or a required body was added |
|
|
306
|
+
| `REQ_BODY_BECAME_OPTIONAL` | safe | request body became optional |
|
|
307
|
+
| `REQ_BODY_ADDED` | safe | optional request body added |
|
|
308
|
+
| `REQ_BODY_REMOVED` | warning | request body removed |
|
|
309
|
+
| `REQ_MEDIA_TYPE_REMOVED` | breaking | request content type removed |
|
|
310
|
+
| `REQ_MEDIA_TYPE_ADDED` | safe | request content type added |
|
|
311
|
+
| `REQ_PROPERTY_REQUIRED_ADDED` | breaking | property became required, or a required property was added |
|
|
312
|
+
| `REQ_PROPERTY_REQUIRED_REMOVED` | safe | property is no longer required |
|
|
313
|
+
| `REQ_PROPERTY_ADDED` | safe | optional property added |
|
|
314
|
+
| `REQ_PROPERTY_REMOVED` | warning | property removed |
|
|
315
|
+
| `REQ_ENUM_NARROWED` | breaking | enum value removed, or enum added to a free value |
|
|
316
|
+
| `REQ_ENUM_WIDENED` | safe | enum value added, or enum removed |
|
|
317
|
+
| `REQ_CONSTRAINT_TIGHTENED` | breaking | constraint tightened, or an opaque constraint added or changed |
|
|
318
|
+
| `REQ_CONSTRAINT_LOOSENED` | safe | constraint loosened or removed |
|
|
319
|
+
| `REQ_ADDITIONAL_PROPERTIES_DISALLOWED` | breaking | additionalProperties changed to false |
|
|
320
|
+
| `REQ_ADDITIONAL_PROPERTIES_ALLOWED` | safe | additionalProperties changed from false |
|
|
321
|
+
| `REQ_BECAME_NON_NULLABLE` | breaking | value that could be null no longer can |
|
|
322
|
+
| `REQ_BECAME_NULLABLE` | safe | value that could not be null now can |
|
|
323
|
+
| `RESP_STATUS_REMOVED` | breaking | documented status code removed (warning unless 2xx) |
|
|
324
|
+
| `RESP_STATUS_ADDED` | safe | status code added |
|
|
325
|
+
| `RESP_MEDIA_TYPE_REMOVED` | breaking | response content type removed |
|
|
326
|
+
| `RESP_MEDIA_TYPE_ADDED` | safe | response content type added |
|
|
327
|
+
| `RESP_PROPERTY_REMOVED` | breaking | property removed |
|
|
328
|
+
| `RESP_PROPERTY_ADDED` | safe | property added |
|
|
329
|
+
| `RESP_PROPERTY_BECAME_OPTIONAL` | breaking | property removed from required |
|
|
330
|
+
| `RESP_PROPERTY_BECAME_REQUIRED` | safe | property added to required |
|
|
331
|
+
| `RESP_BECAME_NULLABLE` | breaking | value can now be null |
|
|
332
|
+
| `RESP_BECAME_NON_NULLABLE` | safe | value can no longer be null |
|
|
333
|
+
| `RESP_ENUM_WIDENED` | warning | enum value added, or enum removed |
|
|
334
|
+
| `RESP_ENUM_NARROWED` | safe | enum value removed, or enum added to a free value |
|
|
335
|
+
| `RESP_CONSTRAINT_LOOSENED` | breaking | constraint loosened or removed (warning for opaque changes) |
|
|
336
|
+
| `RESP_CONSTRAINT_TIGHTENED` | safe | constraint tightened or added |
|
|
337
|
+
| `TYPE_CHANGED` | breaking | type changed (safe when the change is compatible in this direction) |
|
|
338
|
+
| `UNSUPPORTED_CONSTRUCT` | info | an unanalyzed keyword differs between versions |
|
|
339
|
+
| `SECURITY_ADDED` | breaking | security requirement became stricter |
|
|
340
|
+
| `SECURITY_RELAXED` | safe | security requirement became less strict |
|
|
341
|
+
|
|
342
|
+
## CI recipe (GitHub Actions)
|
|
343
|
+
|
|
344
|
+
Compare the pull request's `openapi.yaml` with the one on the base branch. The job needs full
|
|
345
|
+
history (`fetch-depth: 0`) so `origin/<base>` exists, writes the Markdown report to the job
|
|
346
|
+
summary, and fails when SpecDiff exits non-zero.
|
|
347
|
+
|
|
348
|
+
```yaml
|
|
349
|
+
name: API compatibility
|
|
350
|
+
on:
|
|
351
|
+
pull_request:
|
|
352
|
+
paths: [openapi.yaml]
|
|
353
|
+
permissions:
|
|
354
|
+
contents: read
|
|
355
|
+
concurrency:
|
|
356
|
+
group: specdiff-${{ github.ref }}
|
|
357
|
+
cancel-in-progress: true
|
|
358
|
+
jobs:
|
|
359
|
+
specdiff:
|
|
360
|
+
runs-on: ubuntu-latest
|
|
361
|
+
env:
|
|
362
|
+
SPECDIFF: ${{ github.workspace }}/.specdiff/dist/cli.js
|
|
363
|
+
steps:
|
|
364
|
+
- uses: actions/checkout@v4
|
|
365
|
+
with:
|
|
366
|
+
fetch-depth: 0
|
|
367
|
+
- uses: actions/setup-node@v4
|
|
368
|
+
with:
|
|
369
|
+
node-version: 22
|
|
370
|
+
- name: Install SpecDiff
|
|
371
|
+
run: |
|
|
372
|
+
git clone --depth 1 https://github.com/yagnang-vyas/spec-diff .specdiff
|
|
373
|
+
(cd .specdiff && npm ci && npm run build)
|
|
374
|
+
- name: Extract the base branch spec
|
|
375
|
+
run: git show origin/${{ github.base_ref }}:openapi.yaml > base.yaml
|
|
376
|
+
- name: Compare
|
|
377
|
+
run: |
|
|
378
|
+
node "$SPECDIFF" base.yaml openapi.yaml --format markdown --include-safe >> "$GITHUB_STEP_SUMMARY" || true
|
|
379
|
+
node "$SPECDIFF" base.yaml openapi.yaml
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
The job only needs read access. This recipe is exercised by the test suite against two commits of the bookstore example (see
|
|
383
|
+
`test/integration/ci-recipe.test.ts`). The `git clone` line tracks the default branch; pin it to a
|
|
384
|
+
commit once you have chosen one.
|
|
385
|
+
|
|
386
|
+
With the npm package you can skip the clone: replace the install step with
|
|
387
|
+
`npm install --global specdiff@<version>` (pinning a version) and call `specdiff` instead of
|
|
388
|
+
`node "$SPECDIFF"`. The behavior and exit codes are the same.
|
|
389
|
+
|
|
390
|
+
## Unsupported constructs
|
|
391
|
+
|
|
392
|
+
SpecDiff does not analyze these keywords. When one differs between the two documents it reports
|
|
393
|
+
`UNSUPPORTED_CONSTRUCT` (severity `info`) at the schema where it appears, so a human can review it:
|
|
394
|
+
|
|
395
|
+
`oneOf`, `anyOf`, `not`, `if` / `then` / `else`, `patternProperties`, `dependentSchemas`,
|
|
396
|
+
`dependentRequired`, `prefixItems`, `contains`, `propertyNames`, `unevaluatedProperties`,
|
|
397
|
+
`unevaluatedItems`.
|
|
398
|
+
|
|
399
|
+
## Limitations
|
|
400
|
+
|
|
401
|
+
- OpenAPI 3.0.x and 3.1.x only. Swagger 2.0, AsyncAPI and OpenAPI 3.2 are rejected (exit `2`).
|
|
402
|
+
- Regular expressions and `format` values are compared as text; a changed `pattern` is a warning
|
|
403
|
+
because containment is not analyzed.
|
|
404
|
+
- `allOf` is flattened shallowly: properties are merged and `required` is unioned, but conflicting
|
|
405
|
+
scalar keywords resolve to the last member.
|
|
406
|
+
- Not compared: response headers, links, callbacks, webhooks, servers, examples, `discriminator`,
|
|
407
|
+
security scheme definitions (only which schemes and scopes an operation requires).
|
|
408
|
+
- A response `additionalProperties` change between "forbidden" and "allowed" is not reported.
|
|
409
|
+
- Two paths in one document with the same template shape (`/a/{x}` and `/a/{y}`) are rejected.
|
|
410
|
+
- External `$ref`s must be local files; `http(s)` references are deliberately not fetched.
|
|
411
|
+
- Locations join keys with dots, so a path that itself contains a dot is ambiguous. They are a
|
|
412
|
+
navigation aid, not a grammar.
|
|
413
|
+
|
|
414
|
+
## Prior art
|
|
415
|
+
|
|
416
|
+
- [oasdiff](https://github.com/oasdiff/oasdiff) (Go) is comprehensive and the right choice when you
|
|
417
|
+
need broad coverage of OpenAPI.
|
|
418
|
+
- [openapi-diff](https://github.com/OpenAPITools/openapi-diff) (Java).
|
|
419
|
+
|
|
420
|
+
SpecDiff aims to be small, written in TypeScript, explicit about its direction-aware rules, and
|
|
421
|
+
honest about what it does not analyze.
|
|
422
|
+
|
|
423
|
+
## Roadmap
|
|
424
|
+
|
|
425
|
+
- Publish to npm and provide a ready-made GitHub Action.
|
|
426
|
+
- `oneOf` / `anyOf` analysis for the common discriminated-union cases.
|
|
427
|
+
- Response header and security scheme comparison.
|
|
428
|
+
- Configuration file for per-rule severity overrides.
|
|
429
|
+
- SARIF output for code-scanning integrations.
|
|
430
|
+
|
|
431
|
+
## Development
|
|
432
|
+
|
|
433
|
+
```sh
|
|
434
|
+
npm ci
|
|
435
|
+
npm run check # typecheck, lint, build, publint, tests with coverage (>= 90%)
|
|
436
|
+
UPDATE_GOLDEN=1 npm test # regenerate the bookstore golden files, then review the diff
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
See [SPEC.md](SPEC.md) for the specification and [AGENTS.md](AGENTS.md) for contributor notes.
|
|
440
|
+
|
|
441
|
+
The [`web/`](web) directory holds the project site and an in-browser Playground. It is a separate
|
|
442
|
+
static app with its own dependencies and is not part of the published package; see
|
|
443
|
+
[`web/README.md`](web/README.md).
|
|
444
|
+
|
|
445
|
+
## License
|
|
446
|
+
|
|
447
|
+
MIT, see [LICENSE](LICENSE).
|