specshield 3.2.7 → 3.2.9

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/README.md CHANGED
@@ -17,8 +17,8 @@
17
17
  **Commands — local & hosted compare**
18
18
  - [Local Compare](#local-compare) · [Remote Compare](#remote-compare) · [Comparison History](#comparison-history) · [Share a Comparison](#share-a-comparison) · [GitHub App PR Checks](#github-app--pr-checks)
19
19
 
20
- **Bi-Directional Contract Testing (BDCT)**
21
- - [BDCT overview & full command reference](#bi-directional-contract-testing-bdct)
20
+ **Contract Compatibility Testing — the `bdct` commands**
21
+ - [Overview & full command reference](#bi-directional-contract-testing-bdct)
22
22
  - [`bdct capture from-har` — HAR → consumer contract](#bdct-capture-from-har--turn-real-traffic-into-a-consumer-contract)
23
23
  - [Step 1 — record a HAR](#step-1--record-a-har-file) · [Step 2 — generate contract](#step-2--turn-the-har-into-a-consumer-contract) · [Step 3 — publish + gate](#step-3--publish--gate) · [Operational concerns](#operational-concerns-read-this-before-going-live)
24
24
  - [`bdct verify-provider` — spec-vs-production conformance](#bdct-verify-provider--does-your-live-provider-actually-match-its-spec)
@@ -34,21 +34,23 @@
34
34
 
35
35
  ---
36
36
 
37
- > **OpenAPI Diff · Breaking-Change Detection · Bi-Directional Contract Testing · `can-i-deploy` Gate · Pact-File Ingest · Live-Traffic Capture · Spec-vs-Production Conformance · GitHub PR Checks**
37
+ > **Contract Compatibility Testing · OpenAPI Diff · Breaking-Change Detection · `can-i-deploy` Deploy Gate · Pact-File Ingest · Live-Traffic Capture · Spec-vs-Production Conformance · GitHub PR Checks**
38
38
 
39
39
  ---
40
40
 
41
41
  ## Never ship a breaking change to your API consumers
42
42
 
43
- **SpecShield** is the one CLI that does four things to keep your API safe:
43
+ **SpecShield is contract compatibility testing for APIs** catch breaking changes before they reach your consumers, and gate every deploy with `can-i-deploy`. *(Contract compatibility testing is also known as bidirectional contract testing.)*
44
+
45
+ It's the one CLI that does four things to keep your API safe:
44
46
 
45
47
  1. **Diff** two OpenAPI specs and fail CI on breaking changes.
46
- 2. **Bi-directional contract testing** with `can-i-deploy` — block a deploy that would break a consumer.
48
+ 2. **Contract compatibility testing** with `can-i-deploy` — block a deploy that would break a consumer.
47
49
  3. **`bdct capture from-har`** — turn recorded traffic into an accurate consumer contract (no Pact DSL).
48
50
  4. **`bdct verify-provider`** — prove the running provider actually matches its OpenAPI spec.
49
51
 
50
52
  ```
51
- OpenAPI diff + BDCT + HAR → consumer contract + spec-vs-production conformance — in one CLI.
53
+ OpenAPI diff + contract compatibility checks + HAR → consumer contract + spec-vs-production conformance — in one CLI.
52
54
  ```
53
55
 
54
56
  No broker. No Pact DSL. Language-agnostic. Works in 30 seconds. Local mode never uploads your specs.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "specshield",
3
- "version": "3.2.7",
4
- "description": "CLI for OpenAPI breaking change detection and bi-directional contract verification with can-i-deploy gating, GitHub PR checks, and a first-run setup wizard.",
3
+ "version": "3.2.9",
4
+ "description": "Contract compatibility testing for APIs — catch breaking OpenAPI changes before they reach your consumers, with can-i-deploy deploy gating and GitHub PR checks. (a.k.a. bidirectional contract testing.)",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
7
7
  "specshield": "./bin/specshield.js"
@@ -21,6 +21,10 @@ const BREAKING_TYPES = new Set([
21
21
  'REQUEST_TYPE_CHANGED',
22
22
  'RESPONSE_TYPE_CHANGED',
23
23
  'SCHEMA_REMOVED',
24
+ // Dropping a union variant, or changing the discriminator, breaks consumers
25
+ // that relied on the removed shape / the old discriminator.
26
+ 'SCHEMA_VARIANT_REMOVED',
27
+ 'SCHEMA_DISCRIMINATOR_CHANGED',
24
28
  // Constraint tightening: previously-valid values become invalid → breaking.
25
29
  'CONSTRAINT_TIGHTENED',
26
30
  // Pattern changes are treated as breaking (semantic safety: we can't
@@ -36,6 +40,8 @@ const ADDITION_TYPES = new Set([
36
40
  'RESPONSE_FIELD_ADDED',
37
41
  'RESPONSE_ADDED',
38
42
  'SCHEMA_ADDED',
43
+ // Adding a union variant is backwards-compatible.
44
+ 'SCHEMA_VARIANT_ADDED',
39
45
  ]);
40
46
 
41
47
  const MODIFICATION_TYPES = new Set([
@@ -323,6 +323,70 @@ function diffSchemaNode(path, method, fieldPrefix, base, target, diffs, isReques
323
323
  });
324
324
  }
325
325
  }
326
+
327
+ // additionalProperties (free-form map): compare the value schema so a
328
+ // breaking change inside map values is caught. ".<*>" denotes "any key".
329
+ if (base.additionalProperties || target.additionalProperties) {
330
+ const apField = `${fieldPrefix}.<*>`;
331
+ if (base.additionalProperties && target.additionalProperties) {
332
+ diffSchemaNode(path, method, apField, base.additionalProperties, target.additionalProperties, diffs, isRequest);
333
+ } else if (base.additionalProperties && !target.additionalProperties) {
334
+ diffs.push({
335
+ type: isRequest ? 'REQUEST_FIELD_REMOVED' : 'RESPONSE_FIELD_REMOVED',
336
+ path,
337
+ method,
338
+ field: apField,
339
+ description: `Map value schema (additionalProperties) removed from "${fieldPrefix}" in ${method.toUpperCase()} ${path}`,
340
+ });
341
+ }
342
+ }
343
+
344
+ // Discriminator rename/removal — breaks consumers keyed on the old name.
345
+ if (base.discriminator && base.discriminator !== target.discriminator) {
346
+ diffs.push({
347
+ type: 'SCHEMA_DISCRIMINATOR_CHANGED',
348
+ path,
349
+ method,
350
+ field: `${fieldPrefix}.discriminator`,
351
+ oldValue: base.discriminator,
352
+ newValue: target.discriminator || null,
353
+ description: `Discriminator at "${fieldPrefix}" changed from "${base.discriminator}" to "${target.discriminator || '(none)'}" in ${method.toUpperCase()} ${path}`,
354
+ });
355
+ }
356
+
357
+ // oneOf / anyOf variant comparison (positional; field-level changes inside a
358
+ // matched variant are caught by recursion). Mirrors the backend engine.
359
+ diffVariants(path, method, `${fieldPrefix}.oneOf`, base.oneOf, target.oneOf, diffs, isRequest);
360
+ diffVariants(path, method, `${fieldPrefix}.anyOf`, base.anyOf, target.anyOf, diffs, isRequest);
361
+ }
362
+
363
+ function diffVariants(path, method, fieldPrefix, baseVariants, targetVariants, diffs, isRequest) {
364
+ const b = Array.isArray(baseVariants) ? baseVariants : [];
365
+ const t = Array.isArray(targetVariants) ? targetVariants : [];
366
+ if (b.length === 0 && t.length === 0) return;
367
+
368
+ const matched = Math.min(b.length, t.length);
369
+ for (let i = 0; i < matched; i++) {
370
+ diffSchemaNode(path, method, `${fieldPrefix}[${i}]`, b[i], t[i], diffs, isRequest);
371
+ }
372
+ for (let i = t.length; i < b.length; i++) {
373
+ diffs.push({
374
+ type: 'SCHEMA_VARIANT_REMOVED',
375
+ path,
376
+ method,
377
+ field: `${fieldPrefix}[${i}]`,
378
+ description: `A schema variant was removed from "${fieldPrefix}" in ${method.toUpperCase()} ${path}`,
379
+ });
380
+ }
381
+ for (let i = b.length; i < t.length; i++) {
382
+ diffs.push({
383
+ type: 'SCHEMA_VARIANT_ADDED',
384
+ path,
385
+ method,
386
+ field: `${fieldPrefix}[${i}]`,
387
+ description: `A schema variant was added to "${fieldPrefix}" in ${method.toUpperCase()} ${path}`,
388
+ });
389
+ }
326
390
  }
327
391
 
328
392
  // ─── Constraints (min/max, length, pattern) ─────────────────────────────────
@@ -102,20 +102,30 @@ function resolveSchema(schema, schemas, depth = 0) {
102
102
  return resolveSchema(resolved, schemas, depth + 1);
103
103
  }
104
104
 
105
- // Handle allOf / oneOf / anyOf by merging
105
+ // allOf = composition merge into one combined object.
106
106
  if (schema.allOf) {
107
107
  return mergeSchemas(schema.allOf, schemas, depth);
108
108
  }
109
- if (schema.oneOf || schema.anyOf) {
110
- const list = schema.oneOf || schema.anyOf;
111
- return mergeSchemas(list, schemas, depth);
109
+ // oneOf / anyOf are NOT merged — they're kept as separate variants below so
110
+ // the diff engine can compare variants individually (added/removed variants
111
+ // + per-variant field changes). This matches the backend's variant-aware
112
+ // model; merging would lose variant identity and over-mark `required`.
113
+
114
+ // OpenAPI 3.1 expresses nullability as a type array: `type: [x, "null"]`.
115
+ // Normalize that to a single base type + nullable flag so it compares cleanly
116
+ // against 3.0's `nullable: true` form and doesn't surface a bogus type change.
117
+ let resolvedType = schema.type;
118
+ let resolvedNullable = Boolean(schema.nullable);
119
+ if (Array.isArray(resolvedType)) {
120
+ if (resolvedType.includes('null')) resolvedNullable = true;
121
+ resolvedType = resolvedType.find((t) => t !== 'null') || 'object';
112
122
  }
113
123
 
114
124
  const node = {
115
- type: schema.type || 'object',
125
+ type: resolvedType || 'object',
116
126
  format: schema.format || null,
117
127
  enum: schema.enum || null,
118
- nullable: Boolean(schema.nullable),
128
+ nullable: resolvedNullable,
119
129
  required: Array.isArray(schema.required) ? schema.required : [],
120
130
  properties: {},
121
131
  items: null,
@@ -143,6 +153,23 @@ function resolveSchema(schema, schemas, depth = 0) {
143
153
  node.items = resolveSchema(schema.items, schemas, depth + 1);
144
154
  }
145
155
 
156
+ // additionalProperties as a schema = a free-form map/dictionary value type
157
+ // (e.g. Map<String, Metric>). Boolean true/false is not a value schema.
158
+ if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
159
+ node.additionalProperties = resolveSchema(schema.additionalProperties, schemas, depth + 1);
160
+ }
161
+
162
+ // oneOf / anyOf variants kept separate (variant-aware model).
163
+ if (Array.isArray(schema.oneOf)) {
164
+ node.oneOf = schema.oneOf.map((s) => resolveSchema(s, schemas, depth + 1));
165
+ }
166
+ if (Array.isArray(schema.anyOf)) {
167
+ node.anyOf = schema.anyOf.map((s) => resolveSchema(s, schemas, depth + 1));
168
+ }
169
+ if (schema.discriminator && schema.discriminator.propertyName) {
170
+ node.discriminator = schema.discriminator.propertyName;
171
+ }
172
+
146
173
  return node;
147
174
  }
148
175