specshield 3.2.0 → 3.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specshield",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
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.",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
@@ -31,7 +31,12 @@ compare
31
31
  .option('--allow-breaking', 'Override fail-on-breaking behavior')
32
32
  .option('--config <path>', 'Path to .specshield.yml config file')
33
33
  .option('--ignore <change>', 'Ignore a specific change string (repeatable)', collect, [])
34
- .option('--severity <level>', 'Minimum severity level: info | warning | error', 'error')
34
+ // Default severity is 'info' so additions and modifications are visible
35
+ // by default. Customers expect a diff summary to list ALL changes, with
36
+ // severity coloring/filtering as an opt-in narrowing. The previous default
37
+ // of 'error' silently hid every non-breaking change, which read as "0
38
+ // additions / 0 modifications" even when both existed.
39
+ .option('--severity <level>', 'Minimum severity level: info | warning | error', 'info')
35
40
  .option('--remote', 'Use the SpecShield hosted compare API')
36
41
  .option('--api-key <key>', 'API key for hosted mode (overrides env and stored config)')
37
42
  .option('--remote-url <url>', 'Override the hosted API base URL')
@@ -326,6 +326,43 @@ function nonInteractiveFlow(detected, opts) {
326
326
  return answers;
327
327
  }
328
328
 
329
+ // ─── Preview flow ──────────────────────────────────────────────────────────
330
+
331
+ /**
332
+ * Used by `specshield init --print`. Behaves like `nonInteractiveFlow` but
333
+ * substitutes a "<replace-me>" placeholder for any required field the user
334
+ * didn't supply, instead of erroring out. The header comment printed
335
+ * alongside the YAML tells the user where to fill these in.
336
+ *
337
+ * Kind defaults to "provider" if a spec was detected (the most common
338
+ * case), else "skip" (local-compare-only).
339
+ */
340
+ function previewFlow(detected, opts) {
341
+ const PLACEHOLDER = '<replace-me>';
342
+ const kind = opts.kind || (detected.spec ? 'provider' : 'skip');
343
+
344
+ const answers = {
345
+ kind,
346
+ server: opts.server || DEFAULT_SERVER,
347
+ org: opts.org || (kind === 'skip' ? null : PLACEHOLDER),
348
+ environment: opts.env || detected.environment || 'staging',
349
+ };
350
+
351
+ if (kind === 'provider' || kind === 'both') {
352
+ answers.providerName = opts.provider || detected.serviceName || PLACEHOLDER;
353
+ answers.specPath = opts.spec || detected.spec || PLACEHOLDER;
354
+ }
355
+ if (kind === 'consumer' || kind === 'both') {
356
+ answers.consumerName = opts.consumer || detected.serviceName || PLACEHOLDER;
357
+ answers.consumerProvider = opts.consumerProvider || PLACEHOLDER;
358
+ answers.contractPath = opts.contract || PLACEHOLDER;
359
+ answers.contractFormat = opts.format || 'OPENAPI';
360
+ }
361
+
362
+ answers.writeWorkflow = !!opts.writeWorkflow;
363
+ return answers;
364
+ }
365
+
329
366
  // ─── Command ───────────────────────────────────────────────────────────────
330
367
 
331
368
  const initCommand = new Command('init')
@@ -349,12 +386,19 @@ const initCommand = new Command('init')
349
386
  const detected = detectAll(cwd);
350
387
 
351
388
  let answers;
352
- if (opts.interactive === false) {
389
+ if (opts.print) {
390
+ // --print is documented as a dry-run that detects everything and writes
391
+ // a proposed YAML without prompting. Route it through the non-interactive
392
+ // flow (with relaxed validation — see previewFlow) so it truly never asks
393
+ // questions, even if the user didn't pass --no-interactive or all the
394
+ // required scripted-mode flags.
395
+ answers = previewFlow(detected, opts);
396
+ } else if (opts.interactive === false) {
353
397
  // In non-interactive mode, refuse to overwrite an existing config unless
354
398
  // --force is passed. Prevents a CI script from silently clobbering a
355
399
  // hand-edited .specshield.yml that has settings the wizard wouldn't
356
400
  // regenerate (custom branch, different provider name, etc.).
357
- if (detected.existing && !opts.force && !opts.print) {
401
+ if (detected.existing && !opts.force) {
358
402
  logger.error(
359
403
  '.specshield.yml already exists. Pass --force to overwrite, or remove the file first.');
360
404
  process.exit(2);
@@ -369,7 +413,15 @@ const initCommand = new Command('init')
369
413
  const yaml = render(cfg);
370
414
 
371
415
  if (opts.print) {
372
- process.stdout.write('\n' + yaml);
416
+ // Header comment makes it obvious this was a dry-run + flags any
417
+ // placeholders the user will need to fill in before committing.
418
+ process.stdout.write(
419
+ '\n# ─────────────────────────────────────────────────────────────\n' +
420
+ '# specshield init --print — DRY RUN. No files were written.\n' +
421
+ '# Review the YAML below; replace any "<replace-me>" placeholders\n' +
422
+ '# before running `specshield init` (without --print) for real.\n' +
423
+ '# ─────────────────────────────────────────────────────────────\n\n' +
424
+ yaml);
373
425
  return;
374
426
  }
375
427
 
@@ -21,6 +21,11 @@ const BREAKING_TYPES = new Set([
21
21
  'REQUEST_TYPE_CHANGED',
22
22
  'RESPONSE_TYPE_CHANGED',
23
23
  'SCHEMA_REMOVED',
24
+ // Constraint tightening: previously-valid values become invalid → breaking.
25
+ 'CONSTRAINT_TIGHTENED',
26
+ // Pattern changes are treated as breaking (semantic safety: we can't
27
+ // tell whether the new pattern accepts a superset of the old).
28
+ 'CONSTRAINT_PATTERN_CHANGED',
24
29
  ]);
25
30
 
26
31
  const ADDITION_TYPES = new Set([
@@ -36,6 +41,8 @@ const ADDITION_TYPES = new Set([
36
41
  const MODIFICATION_TYPES = new Set([
37
42
  'FIELD_BECAME_OPTIONAL',
38
43
  'PARAMETER_BECAME_OPTIONAL',
44
+ // Constraint relaxation: previously-rejected values now valid → safe change.
45
+ 'CONSTRAINT_RELAXED',
39
46
  ]);
40
47
 
41
48
  const WARNING_TYPES = new Set([
@@ -83,9 +90,120 @@ function classifyChanges(diffs) {
83
90
  }
84
91
  }
85
92
 
93
+ // Dedupe $ref-driven changes. When a schema property is added/removed/typed,
94
+ // the change appears once per endpoint that references the schema — which
95
+ // produces "6 breaking changes" reports when really one schema field was
96
+ // removed and it rippled through 4 endpoints. Group entries with the same
97
+ // (type, leafFieldName) and collapse them into one entry that names every
98
+ // affected endpoint. See `mergeDuplicateFieldChanges` for the rules.
99
+ result.breakingChanges = mergeDuplicateFieldChanges(result.breakingChanges);
100
+ result.additions = mergeDuplicateFieldChanges(result.additions);
101
+ result.modifications = mergeDuplicateFieldChanges(result.modifications);
102
+ result.warnings = mergeDuplicateFieldChanges(result.warnings);
103
+
86
104
  return result;
87
105
  }
88
106
 
107
+ // Types whose multi-counting is almost always caused by a $ref'd component
108
+ // schema being inlined into many endpoint responses/requests. Safe to dedupe.
109
+ const FIELD_DEDUPE_TYPES = new Set([
110
+ 'RESPONSE_FIELD_REMOVED',
111
+ 'RESPONSE_FIELD_ADDED',
112
+ 'RESPONSE_FIELD_TYPE_CHANGED',
113
+ 'REQUEST_FIELD_REMOVED',
114
+ 'REQUEST_FIELD_ADDED',
115
+ 'REQUEST_FIELD_TYPE_CHANGED',
116
+ 'REQUEST_REQUIRED_FIELD_ADDED',
117
+ 'FIELD_BECAME_REQUIRED',
118
+ 'FIELD_BECAME_OPTIONAL',
119
+ 'ENUM_VALUE_REMOVED',
120
+ ]);
121
+
122
+ /**
123
+ * Returns the leaf field name from a dotted/bracketed field path so we can
124
+ * dedupe by component-property name rather than full positional path.
125
+ *
126
+ * responses.200.data[items].legacy_id → legacy_id
127
+ * responses.201.legacy_id → legacy_id
128
+ * requestBody.email → email
129
+ *
130
+ * Both rows above share leaf "legacy_id", so they're recognised as the same
131
+ * schema-level change.
132
+ */
133
+ function leafFieldName(field) {
134
+ if (!field) return null;
135
+ const parts = field.split('.');
136
+ const last = parts[parts.length - 1];
137
+ // Strip trailing array marker like "data[items]" → "data"
138
+ return last.replace(/\[.*$/, '');
139
+ }
140
+
141
+ /**
142
+ * Collapses entries that have the same (type, leafFieldName) into one entry
143
+ * with an `affectedEndpoints` array of every `${METHOD} ${path}` it appeared
144
+ * under. The original first-seen entry is kept as the canonical record; its
145
+ * description is rewritten to lead with the field name and end with the
146
+ * affected-endpoint count.
147
+ *
148
+ * Non-field types (ENDPOINT_*, METHOD_*, SCHEMA_*, PARAMETER_*) pass through
149
+ * untouched — they're already at the right granularity.
150
+ */
151
+ function mergeDuplicateFieldChanges(entries) {
152
+ const groups = new Map();
153
+ const passthrough = [];
154
+
155
+ for (const change of entries) {
156
+ if (!FIELD_DEDUPE_TYPES.has(change.type) || !change.field) {
157
+ passthrough.push(change);
158
+ continue;
159
+ }
160
+ const leaf = leafFieldName(change.field);
161
+ const key = `${change.type}::${leaf}`;
162
+ if (!groups.has(key)) {
163
+ groups.set(key, { canonical: { ...change }, endpoints: [] });
164
+ }
165
+ if (change.path && change.method) {
166
+ groups.get(key).endpoints.push(`${change.method.toUpperCase()} ${change.path}`);
167
+ }
168
+ }
169
+
170
+ const merged = [];
171
+ for (const { canonical, endpoints } of groups.values()) {
172
+ if (endpoints.length <= 1) {
173
+ // Single occurrence — keep the original detailed description.
174
+ merged.push(canonical);
175
+ continue;
176
+ }
177
+ const leaf = leafFieldName(canonical.field);
178
+ canonical.affectedEndpoints = endpoints;
179
+ canonical.description = describeMergedChange(canonical.type, leaf, endpoints);
180
+ // Strip path/method from the canonical entry since it now applies to many.
181
+ canonical.path = null;
182
+ canonical.method = null;
183
+ merged.push(canonical);
184
+ }
185
+
186
+ return [...merged, ...passthrough];
187
+ }
188
+
189
+ function describeMergedChange(type, leaf, endpoints) {
190
+ const n = endpoints.length;
191
+ const VERBS = {
192
+ RESPONSE_FIELD_REMOVED: `Response field "${leaf}" was removed`,
193
+ RESPONSE_FIELD_ADDED: `Response field "${leaf}" was added`,
194
+ RESPONSE_FIELD_TYPE_CHANGED: `Response field "${leaf}" changed type`,
195
+ REQUEST_FIELD_REMOVED: `Request field "${leaf}" was removed`,
196
+ REQUEST_FIELD_ADDED: `Request field "${leaf}" was added`,
197
+ REQUEST_FIELD_TYPE_CHANGED: `Request field "${leaf}" changed type`,
198
+ REQUEST_REQUIRED_FIELD_ADDED: `Required request field "${leaf}" was added`,
199
+ FIELD_BECAME_REQUIRED: `Field "${leaf}" became required`,
200
+ FIELD_BECAME_OPTIONAL: `Field "${leaf}" became optional`,
201
+ ENUM_VALUE_REMOVED: `Enum value removed from "${leaf}"`,
202
+ };
203
+ const head = VERBS[type] || `Change in "${leaf}"`;
204
+ return `${head} (affects ${n} endpoint${n === 1 ? '' : 's'}: ${endpoints.join(', ')})`;
205
+ }
206
+
89
207
  /**
90
208
  * Filter a classified result to only include changes at or above minSeverity.
91
209
  * info < warning < error
@@ -124,6 +124,11 @@ function diffParameters(path, method, baseParams, targetParams, diffs) {
124
124
  description: `Parameter "${bp.name}" became ${tp.required ? 'required' : 'optional'} in ${method.toUpperCase()} ${path}`,
125
125
  });
126
126
  }
127
+
128
+ // Constraint changes on the parameter's schema (min/max, length,
129
+ // pattern, enum). Tightening = breaking; loosening = modification.
130
+ diffConstraints(
131
+ path, method, `parameters.${bp.name}`, bp.schema, tp.schema, diffs);
127
132
  }
128
133
  }
129
134
 
@@ -260,6 +265,9 @@ function diffSchemaNode(path, method, fieldPrefix, base, target, diffs, isReques
260
265
  // Enum changes on field
261
266
  diffEnums(path, method, fullField, bField.enum, tField.enum, diffs);
262
267
 
268
+ // Constraint changes on the field's schema (min/max, length, pattern).
269
+ diffConstraints(path, method, fullField, bField, tField, diffs);
270
+
263
271
  // Recurse into nested objects
264
272
  if (bField.properties || tField.properties) {
265
273
  diffSchemaNode(path, method, fullField, bField, tField, diffs, isRequest);
@@ -317,6 +325,97 @@ function diffSchemaNode(path, method, fieldPrefix, base, target, diffs, isReques
317
325
  }
318
326
  }
319
327
 
328
+ // ─── Constraints (min/max, length, pattern) ─────────────────────────────────
329
+
330
+ /**
331
+ * Detects changes to numeric/string constraint fields on a schema node.
332
+ * Classification is direction-aware:
333
+ *
334
+ * maximum increased / minimum decreased / maxLength increased / etc.
335
+ * → CONSTRAINT_RELAXED (modification — existing clients still valid)
336
+ *
337
+ * maximum decreased / minimum increased / maxLength decreased / etc.
338
+ * → CONSTRAINT_TIGHTENED (breaking — previously-valid values now rejected)
339
+ *
340
+ * pattern added/changed/removed
341
+ * → CONSTRAINT_PATTERN_CHANGED (breaking; semantic comparison is too hard
342
+ * to do safely so we treat any change as potentially restrictive)
343
+ *
344
+ * `null` on either side means "not constrained" — adding a constraint is
345
+ * tightening, removing one is relaxing.
346
+ */
347
+ function diffConstraints(path, method, fieldPrefix, base, target, diffs) {
348
+ if (!base || !target) return;
349
+
350
+ // Direction map: how to interpret a numeric change for each constraint.
351
+ // 'upper' constraints (maximum, maxLength, maxItems): higher = looser.
352
+ // 'lower' constraints (minimum, minLength, minItems): lower = looser.
353
+ const UPPER = ['maximum', 'maxLength', 'maxItems'];
354
+ const LOWER = ['minimum', 'minLength', 'minItems'];
355
+
356
+ for (const key of UPPER) {
357
+ pushNumericConstraint(path, method, fieldPrefix, key, base[key], target[key], 'upper', diffs);
358
+ }
359
+ for (const key of LOWER) {
360
+ pushNumericConstraint(path, method, fieldPrefix, key, base[key], target[key], 'lower', diffs);
361
+ }
362
+
363
+ // pattern: any change is treated as a tightening (breaking). Adding or
364
+ // removing a pattern also counts.
365
+ if ((base.pattern || null) !== (target.pattern || null)) {
366
+ diffs.push({
367
+ type: 'CONSTRAINT_PATTERN_CHANGED',
368
+ path, method, field: fieldPrefix,
369
+ oldValue: base.pattern || null,
370
+ newValue: target.pattern || null,
371
+ description: target.pattern
372
+ ? `Pattern constraint on "${fieldPrefix}" changed from ${base.pattern ? `/${base.pattern}/` : '(none)'} to /${target.pattern}/ in ${method.toUpperCase()} ${path}`
373
+ : `Pattern constraint on "${fieldPrefix}" was removed from ${method.toUpperCase()} ${path}`,
374
+ });
375
+ }
376
+ }
377
+
378
+ function pushNumericConstraint(path, method, field, key, oldVal, newVal, direction, diffs) {
379
+ // Treat null/undefined as "no constraint".
380
+ const had = oldVal !== null && oldVal !== undefined;
381
+ const has = newVal !== null && newVal !== undefined;
382
+
383
+ if (!had && !has) return;
384
+ if (had && has && oldVal === newVal) return;
385
+
386
+ // Adding a constraint where none existed → tightening (breaking).
387
+ if (!had && has) {
388
+ diffs.push({
389
+ type: 'CONSTRAINT_TIGHTENED',
390
+ path, method, field,
391
+ oldValue: null, newValue: String(newVal),
392
+ description: `${key} constraint added on "${field}" (now ${newVal}) — tightens "${method.toUpperCase()} ${path}"`,
393
+ });
394
+ return;
395
+ }
396
+ // Removing a constraint → relaxation (modification).
397
+ if (had && !has) {
398
+ diffs.push({
399
+ type: 'CONSTRAINT_RELAXED',
400
+ path, method, field,
401
+ oldValue: String(oldVal), newValue: null,
402
+ description: `${key} constraint removed from "${field}" — relaxes "${method.toUpperCase()} ${path}"`,
403
+ });
404
+ return;
405
+ }
406
+
407
+ // Both present, different value. Direction tells us whether higher = looser
408
+ // or higher = tighter for THIS constraint key.
409
+ const wentUp = newVal > oldVal;
410
+ const isRelaxation = (direction === 'upper' && wentUp) || (direction === 'lower' && !wentUp);
411
+ diffs.push({
412
+ type: isRelaxation ? 'CONSTRAINT_RELAXED' : 'CONSTRAINT_TIGHTENED',
413
+ path, method, field,
414
+ oldValue: String(oldVal), newValue: String(newVal),
415
+ description: `${key} on "${field}" changed from ${oldVal} to ${newVal} (${isRelaxation ? 'relaxed' : 'tightened'}) in ${method.toUpperCase()} ${path}`,
416
+ });
417
+ }
418
+
320
419
  function diffEnums(path, method, fieldPrefix, baseEnum, targetEnum, diffs) {
321
420
  if (!baseEnum || !targetEnum) return;
322
421
  for (const val of baseEnum) {
@@ -119,6 +119,18 @@ function resolveSchema(schema, schemas, depth = 0) {
119
119
  required: Array.isArray(schema.required) ? schema.required : [],
120
120
  properties: {},
121
121
  items: null,
122
+ // Constraint fields — preserved so the diff engine can detect changes
123
+ // to ranges/patterns/lengths (e.g. `maximum: 100` → `maximum: 250`).
124
+ // `undefined` (not present) and `null` are treated as "no constraint"
125
+ // by the diff engine; the explicit values flow through unchanged.
126
+ minimum: schema.minimum !== undefined ? schema.minimum : null,
127
+ maximum: schema.maximum !== undefined ? schema.maximum : null,
128
+ minLength: schema.minLength !== undefined ? schema.minLength : null,
129
+ maxLength: schema.maxLength !== undefined ? schema.maxLength : null,
130
+ minItems: schema.minItems !== undefined ? schema.minItems : null,
131
+ maxItems: schema.maxItems !== undefined ? schema.maxItems : null,
132
+ pattern: schema.pattern || null,
133
+ multipleOf: schema.multipleOf !== undefined ? schema.multipleOf : null,
122
134
  };
123
135
 
124
136
  if (schema.properties) {
@@ -5,23 +5,49 @@ const path = require('path');
5
5
 
6
6
  /**
7
7
  * Parse raw spec content (YAML or JSON) into a JavaScript object.
8
- * Detects format from file extension or content.
8
+ * Detects format from file extension or content. Validates that the parsed
9
+ * object looks like an OpenAPI 3.x or Swagger 2.x spec — otherwise a file
10
+ * containing arbitrary YAML/JSON would silently succeed with "No changes
11
+ * detected" instead of erroring out.
9
12
  */
10
13
  function parseSpec(content, filePath) {
11
14
  const ext = filePath ? path.extname(filePath).toLowerCase() : '';
12
15
 
16
+ let parsed;
13
17
  try {
14
18
  if (ext === '.json') {
15
- return parseJson(content);
19
+ parsed = parseJson(content);
16
20
  } else if (ext === '.yaml' || ext === '.yml') {
17
- return parseYaml(content);
21
+ parsed = parseYaml(content);
18
22
  } else {
19
23
  // Auto-detect: try JSON first, then YAML
20
- return autoDetect(content);
24
+ parsed = autoDetect(content);
21
25
  }
22
26
  } catch (err) {
23
27
  throw new Error(`Failed to parse spec "${filePath}": ${err.message}`);
24
28
  }
29
+
30
+ assertLooksLikeOpenApi(parsed, filePath);
31
+ return parsed;
32
+ }
33
+
34
+ /**
35
+ * Confirms the parsed object has a top-level `openapi: "3.x"` or `swagger: "..."`
36
+ * key — the minimum surface that defines an OpenAPI/Swagger document. Without
37
+ * this check, a stray YAML/JSON file would silently compare as identical to
38
+ * anything that doesn't share its incidental keys.
39
+ */
40
+ function assertLooksLikeOpenApi(parsed, filePath) {
41
+ if (!parsed || typeof parsed !== 'object') {
42
+ throw new Error(`"${filePath}" is not a valid OpenAPI/Swagger spec (parsed value was not an object)`);
43
+ }
44
+ const isOpenApi3 = typeof parsed.openapi === 'string' && parsed.openapi.startsWith('3.');
45
+ const isSwagger2 = typeof parsed.swagger === 'string';
46
+ if (!isOpenApi3 && !isSwagger2) {
47
+ throw new Error(
48
+ `"${filePath}" is not a valid OpenAPI/Swagger spec ` +
49
+ '(missing top-level "openapi: 3.x" or "swagger: ..." key)');
50
+ }
25
51
  }
26
52
 
27
53
  function parseJson(content) {
@@ -101,11 +101,16 @@ function detectServiceName(cwd) {
101
101
  const m = cargo.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
102
102
  if (m) return { source: 'Cargo.toml', name: m[1] };
103
103
  }
104
- // pom.xml — naive single-line artifactId match (good enough for detection)
104
+ // pom.xml — naive artifactId match. We strip <parent>...</parent> first
105
+ // because every Spring Boot project has <parent><artifactId>spring-boot-starter-parent
106
+ // </artifactId></parent> ABOVE the project's own <artifactId>, and a plain
107
+ // regex match would pick the parent's name. With the parent block removed,
108
+ // the first <artifactId> we find is the project's own.
105
109
  const pom = readText(path.join(cwd, 'pom.xml'));
106
110
  if (pom) {
107
- const m = pom.match(/<artifactId>([^<]+)<\/artifactId>/);
108
- if (m) return { source: 'pom.xml', name: m[1] };
111
+ const stripped = pom.replace(/<parent>[\s\S]*?<\/parent>/g, '');
112
+ const m = stripped.match(/<artifactId>([^<]+)<\/artifactId>/);
113
+ if (m) return { source: 'pom.xml', name: m[1].trim() };
109
114
  }
110
115
  // Fallback — directory name
111
116
  return { source: 'directory', name: path.basename(path.resolve(cwd)) };