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 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
- # Temporary Holding Version
1
+ # SpecDiff
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/specdiff.svg)](https://www.npmjs.com/package/specdiff)
4
+ [![CI](https://github.com/yagnang-vyas/spec-diff/actions/workflows/ci.yml/badge.svg)](https://github.com/yagnang-vyas/spec-diff/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/specdiff.svg)](LICENSE)
6
+ [![node](https://img.shields.io/node/v/specdiff.svg)](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).