specshield 3.2.0 → 3.2.2

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.2",
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": {
@@ -41,7 +41,7 @@ function withProjectDefaults(opts, command) {
41
41
  try {
42
42
  applyBdctDefaults(opts, command);
43
43
  } catch (err) {
44
- if (err.code === 'MISSING_REQUIRED_OPTIONS') {
44
+ if (err.code === 'MISSING_REQUIRED_OPTIONS' || err.code === 'UNRESOLVED_PLACEHOLDER') {
45
45
  logger.error(err.message);
46
46
  process.exit(2);
47
47
  }
@@ -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')
@@ -25,6 +25,17 @@ const DEFAULT_SERVER = 'https://specshield.io';
25
25
 
26
26
  // ─── Helpers ───────────────────────────────────────────────────────────────
27
27
 
28
+ const PLACEHOLDER = '<replace-me>';
29
+
30
+ // Skippable text fields in the interactive wizard. Empty values for these
31
+ // become PLACEHOLDER tokens in the generated config; non-skippable fields
32
+ // (kind, contractFormat, environment which has a default) are not in this list.
33
+ const PLACEHOLDER_FIELDS = [
34
+ 'providerName', 'specPath',
35
+ 'consumerName', 'consumerProvider', 'contractPath',
36
+ 'org',
37
+ ];
38
+
28
39
  function abortIfCancelled(answers, keys) {
29
40
  // `prompts` returns undefined values when the user hits Ctrl-C.
30
41
  for (const k of keys) {
@@ -35,6 +46,42 @@ function abortIfCancelled(answers, keys) {
35
46
  }
36
47
  }
37
48
 
49
+ /**
50
+ * Replace empty answers with the PLACEHOLDER token so the generated config
51
+ * makes the gap visible (vs writing an empty string the user might miss).
52
+ * Only operates on the fields listed in PLACEHOLDER_FIELDS — required
53
+ * non-text choices like `kind` are left untouched.
54
+ */
55
+ function fillPlaceholders(answers) {
56
+ for (const f of PLACEHOLDER_FIELDS) {
57
+ if (answers[f] === '' || answers[f] === null || answers[f] === undefined) continue;
58
+ // Keep the value the user entered.
59
+ }
60
+ for (const f of PLACEHOLDER_FIELDS) {
61
+ if (answers[f] === '' || answers[f] === null) answers[f] = PLACEHOLDER;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Walk a built config object and return a list of `{ path, value }` for every
67
+ * leaf whose value equals PLACEHOLDER. The `path` is the dotted YAML path
68
+ * (e.g. `bdct.org`, `bdct.provider.spec`) — same form the user sees when
69
+ * editing the .specshield.yml file.
70
+ */
71
+ function collectPlaceholders(cfg) {
72
+ const out = [];
73
+ const walk = (obj, prefix) => {
74
+ if (!obj || typeof obj !== 'object') return;
75
+ for (const [k, v] of Object.entries(obj)) {
76
+ const p = prefix ? `${prefix}.${k}` : k;
77
+ if (v === PLACEHOLDER) out.push({ path: p, value: v });
78
+ else if (v && typeof v === 'object') walk(v, p);
79
+ }
80
+ };
81
+ walk(cfg, '');
82
+ return out;
83
+ }
84
+
38
85
  async function validateApiKey(server, key) {
39
86
  try {
40
87
  const res = await axios.post(`${server.replace(/\/$/, '')}/auth/validate-api-key`,
@@ -156,12 +203,21 @@ async function interactiveFlow(detected, opts) {
156
203
  const wantsProvider = k.kind === 'provider' || k.kind === 'both';
157
204
  const wantsConsumer = k.kind === 'consumer' || k.kind === 'both';
158
205
 
206
+ // Optional-by-default prompts. Pressing Enter on any text field is OK
207
+ // and produces a "<replace-me>" placeholder in the generated config —
208
+ // surfaced at the end of the wizard and refused by any bdct command
209
+ // that later tries to use it. Lets a user explore the wizard without
210
+ // having to know their org key or provider name up front.
211
+ const skipHint = chalk.gray('(press Enter to skip — fill in later)');
212
+
159
213
  if (wantsProvider) {
160
214
  const provQs = [
161
- { type: 'text', name: 'providerName', message: 'Provider name', initial: detected.serviceName },
162
- { type: 'text', name: 'specPath', message: 'Path to provider OpenAPI spec',
163
- initial: detected.spec || 'api/openapi.yaml',
164
- validate: (v) => v ? true : 'Required',
215
+ { type: 'text', name: 'providerName',
216
+ message: `Provider name ${skipHint}`,
217
+ initial: detected.serviceName },
218
+ { type: 'text', name: 'specPath',
219
+ message: `Path to provider OpenAPI spec ${skipHint}`,
220
+ initial: detected.spec || 'openapi.yaml',
165
221
  },
166
222
  ];
167
223
  const r = await prompts(provQs);
@@ -171,15 +227,14 @@ async function interactiveFlow(detected, opts) {
171
227
 
172
228
  if (wantsConsumer) {
173
229
  const consQs = [
174
- { type: 'text', name: 'consumerName', message: 'Consumer name',
230
+ { type: 'text', name: 'consumerName',
231
+ message: `Consumer name ${skipHint}`,
175
232
  initial: detected.serviceName },
176
- { type: 'text', name: 'consumerProvider', message: 'Provider this consumer talks to',
177
- validate: (v) => v ? true : 'Required',
178
- },
179
- { type: 'text', name: 'contractPath', message: 'Path to consumer contract',
180
- initial: 'contracts/contract.yaml',
181
- validate: (v) => v ? true : 'Required',
182
- },
233
+ { type: 'text', name: 'consumerProvider',
234
+ message: `Provider this consumer talks to ${skipHint}` },
235
+ { type: 'text', name: 'contractPath',
236
+ message: `Path to consumer contract ${skipHint}`,
237
+ initial: 'contracts/contract.yaml' },
183
238
  { type: 'select', name: 'contractFormat', message: 'Contract format',
184
239
  choices: [
185
240
  { title: 'OpenAPI', value: 'OPENAPI' },
@@ -243,8 +298,8 @@ async function interactiveFlow(detected, opts) {
243
298
  });
244
299
  abortIfCancelled(r, ['org']);
245
300
  if (r.org === '__manual__') {
246
- const m = await prompts({ type: 'text', name: 'org', message: 'Org key',
247
- validate: (v) => v ? true : 'Required' });
301
+ const m = await prompts({ type: 'text', name: 'org',
302
+ message: `Org key ${skipHint}` });
248
303
  abortIfCancelled(m, ['org']);
249
304
  answers.org = m.org;
250
305
  } else {
@@ -252,8 +307,8 @@ async function interactiveFlow(detected, opts) {
252
307
  }
253
308
  } else {
254
309
  const r = await prompts({
255
- type: 'text', name: 'org', message: 'Org key',
256
- validate: (v) => v ? true : 'Required',
310
+ type: 'text', name: 'org',
311
+ message: `Org key ${skipHint}`,
257
312
  });
258
313
  abortIfCancelled(r, ['org']);
259
314
  answers.org = r.org;
@@ -326,6 +381,43 @@ function nonInteractiveFlow(detected, opts) {
326
381
  return answers;
327
382
  }
328
383
 
384
+ // ─── Preview flow ──────────────────────────────────────────────────────────
385
+
386
+ /**
387
+ * Used by `specshield init --print`. Behaves like `nonInteractiveFlow` but
388
+ * substitutes a "<replace-me>" placeholder for any required field the user
389
+ * didn't supply, instead of erroring out. The header comment printed
390
+ * alongside the YAML tells the user where to fill these in.
391
+ *
392
+ * Kind defaults to "provider" if a spec was detected (the most common
393
+ * case), else "skip" (local-compare-only).
394
+ */
395
+ function previewFlow(detected, opts) {
396
+ const PLACEHOLDER = '<replace-me>';
397
+ const kind = opts.kind || (detected.spec ? 'provider' : 'skip');
398
+
399
+ const answers = {
400
+ kind,
401
+ server: opts.server || DEFAULT_SERVER,
402
+ org: opts.org || (kind === 'skip' ? null : PLACEHOLDER),
403
+ environment: opts.env || detected.environment || 'staging',
404
+ };
405
+
406
+ if (kind === 'provider' || kind === 'both') {
407
+ answers.providerName = opts.provider || detected.serviceName || PLACEHOLDER;
408
+ answers.specPath = opts.spec || detected.spec || PLACEHOLDER;
409
+ }
410
+ if (kind === 'consumer' || kind === 'both') {
411
+ answers.consumerName = opts.consumer || detected.serviceName || PLACEHOLDER;
412
+ answers.consumerProvider = opts.consumerProvider || PLACEHOLDER;
413
+ answers.contractPath = opts.contract || PLACEHOLDER;
414
+ answers.contractFormat = opts.format || 'OPENAPI';
415
+ }
416
+
417
+ answers.writeWorkflow = !!opts.writeWorkflow;
418
+ return answers;
419
+ }
420
+
329
421
  // ─── Command ───────────────────────────────────────────────────────────────
330
422
 
331
423
  const initCommand = new Command('init')
@@ -349,12 +441,19 @@ const initCommand = new Command('init')
349
441
  const detected = detectAll(cwd);
350
442
 
351
443
  let answers;
352
- if (opts.interactive === false) {
444
+ if (opts.print) {
445
+ // --print is documented as a dry-run that detects everything and writes
446
+ // a proposed YAML without prompting. Route it through the non-interactive
447
+ // flow (with relaxed validation — see previewFlow) so it truly never asks
448
+ // questions, even if the user didn't pass --no-interactive or all the
449
+ // required scripted-mode flags.
450
+ answers = previewFlow(detected, opts);
451
+ } else if (opts.interactive === false) {
353
452
  // In non-interactive mode, refuse to overwrite an existing config unless
354
453
  // --force is passed. Prevents a CI script from silently clobbering a
355
454
  // hand-edited .specshield.yml that has settings the wizard wouldn't
356
455
  // regenerate (custom branch, different provider name, etc.).
357
- if (detected.existing && !opts.force && !opts.print) {
456
+ if (detected.existing && !opts.force) {
358
457
  logger.error(
359
458
  '.specshield.yml already exists. Pass --force to overwrite, or remove the file first.');
360
459
  process.exit(2);
@@ -365,11 +464,24 @@ const initCommand = new Command('init')
365
464
  if (answers === null) return; // user said "don't overwrite"
366
465
  }
367
466
 
467
+ // Empty answers from the interactive flow → "<replace-me>" placeholders.
468
+ // The end-of-wizard summary lists every placeholder by path so users
469
+ // don't accidentally commit them.
470
+ fillPlaceholders(answers);
471
+
368
472
  const cfg = buildConfig(answers, detected);
369
473
  const yaml = render(cfg);
370
474
 
371
475
  if (opts.print) {
372
- process.stdout.write('\n' + yaml);
476
+ // Header comment makes it obvious this was a dry-run + flags any
477
+ // placeholders the user will need to fill in before committing.
478
+ process.stdout.write(
479
+ '\n# ─────────────────────────────────────────────────────────────\n' +
480
+ '# specshield init --print — DRY RUN. No files were written.\n' +
481
+ '# Review the YAML below; replace any "<replace-me>" placeholders\n' +
482
+ '# before running `specshield init` (without --print) for real.\n' +
483
+ '# ─────────────────────────────────────────────────────────────\n\n' +
484
+ yaml);
373
485
  return;
374
486
  }
375
487
 
@@ -383,10 +495,29 @@ const initCommand = new Command('init')
383
495
  providerName: answers.providerName,
384
496
  consumerName: answers.consumerName,
385
497
  providerForConsumer: answers.consumerProvider,
498
+ org: answers.org,
499
+ specPath: answers.specPath,
500
+ contractPath: answers.contractPath,
501
+ environment: answers.environment,
386
502
  }, cwd);
387
503
  ok(`Wrote ${chalk.white(path.relative(cwd, workflowPath))}`);
388
504
  }
389
505
 
506
+ // Placeholders summary — if the user pressed Enter on any prompt,
507
+ // surface what's still missing so they can fill it in before running
508
+ // any bdct command. Pre-flight checks in bdct.js will block commands
509
+ // that hit a literal "<replace-me>" value at runtime.
510
+ const placeholders = collectPlaceholders(cfg);
511
+ if (placeholders.length > 0) {
512
+ fmtSection('Placeholders to fill in');
513
+ placeholders.forEach(p =>
514
+ warn(`${chalk.yellow(p.path)} = ${chalk.gray('<replace-me>')}`));
515
+ warn(chalk.yellow(
516
+ `Edit ${path.relative(cwd, target) || '.specshield.yml'} ` +
517
+ `to replace ${placeholders.length} placeholder${placeholders.length === 1 ? '' : 's'} ` +
518
+ `before running bdct commands.`));
519
+ }
520
+
390
521
  fmtSection('Next steps');
391
522
  if (answers.kind !== 'skip') {
392
523
  info(`Try: ${chalk.white('specshield bdct list-providers')}`);
@@ -397,3 +528,6 @@ const initCommand = new Command('init')
397
528
  });
398
529
 
399
530
  module.exports = initCommand;
531
+
532
+ // Exposed for unit tests — keep usage internal to the init module otherwise.
533
+ module.exports.__test__ = { fillPlaceholders, collectPlaceholders, PLACEHOLDER };
@@ -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
@@ -137,10 +137,32 @@ function writeProjectConfig(config, cwd = process.cwd()) {
137
137
  /**
138
138
  * Render a starter GitHub Actions workflow that uses
139
139
  * `specshield26/bdct-action@v1`. Optional output of the wizard.
140
+ *
141
+ * Every input forwarded to the action MUST be present in the rendered YAML
142
+ * — including org. A missing org renders `--org ""` on the CLI invocation
143
+ * and the action fails with a cryptic "expected value" error from commander.
144
+ *
145
+ * spec/contract paths come from detection (or the user's --spec / --contract
146
+ * flag). They are NOT hardcoded — a previous version assumed every project
147
+ * stored its spec at `api/openapi.yaml`, which broke every project that
148
+ * keeps the spec at the repo root.
140
149
  */
141
- function renderWorkflow({ kind, providerName, consumerName, providerForConsumer }) {
150
+ function renderWorkflow({
151
+ kind,
152
+ providerName,
153
+ consumerName,
154
+ providerForConsumer,
155
+ org,
156
+ specPath,
157
+ contractPath,
158
+ environment,
159
+ }) {
142
160
  const isProvider = kind === 'provider' || kind === 'both';
143
161
  const isConsumer = kind === 'consumer' || kind === 'both';
162
+ const env = environment || 'production';
163
+ const orgLine = org ? ` org: ${org}` : ' org: <replace-me>';
164
+ const spec = specPath || 'openapi.yaml';
165
+ const contract = contractPath || 'contracts/contract.yaml';
144
166
 
145
167
  const lines = [
146
168
  '# .github/workflows/specshield-bdct.yml',
@@ -163,10 +185,11 @@ function renderWorkflow({ kind, providerName, consumerName, providerForConsumer
163
185
  ' - uses: specshield26/bdct-action@v1',
164
186
  ' with:',
165
187
  ' command: publish-provider',
188
+ orgLine,
166
189
  ` provider: ${providerName}`,
167
190
  ' version: ${{ github.sha }}',
168
- ' spec: api/openapi.yaml',
169
- ' env: production',
191
+ ` spec: ${spec}`,
192
+ ` env: ${env}`,
170
193
  ' api-token: ${{ secrets.SPECSHIELD_API_KEY }}',
171
194
  '',
172
195
  ' gate:',
@@ -176,9 +199,10 @@ function renderWorkflow({ kind, providerName, consumerName, providerForConsumer
176
199
  ' - uses: specshield26/bdct-action@v1',
177
200
  ' with:',
178
201
  ' command: can-i-deploy',
202
+ orgLine,
179
203
  ` service: ${providerName}`,
180
204
  ' version: ${{ github.sha }}',
181
- ' env: production',
205
+ ` env: ${env}`,
182
206
  ' api-token: ${{ secrets.SPECSHIELD_API_KEY }}',
183
207
  '',
184
208
  );
@@ -193,10 +217,11 @@ function renderWorkflow({ kind, providerName, consumerName, providerForConsumer
193
217
  ' - uses: specshield26/bdct-action@v1',
194
218
  ' with:',
195
219
  ' command: publish-consumer',
220
+ orgLine,
196
221
  ` consumer: ${consumerName}`,
197
222
  ` provider: ${providerForConsumer}`,
198
223
  ' version: ${{ github.sha }}',
199
- ' contract: contracts/contract.yaml',
224
+ ` contract: ${contract}`,
200
225
  ' api-token: ${{ secrets.SPECSHIELD_API_KEY }}',
201
226
  '',
202
227
  );
@@ -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) {
@@ -157,6 +157,25 @@ function applyBdctDefaults(opts, command, { cwd = process.cwd() } = {}) {
157
157
  opts[f] = (f === 'spec' || f === 'contract') ? resolvePath(def) : def;
158
158
  }
159
159
 
160
+ // Placeholder check — `<replace-me>` is the marker `specshield init` writes
161
+ // for fields the user skipped during the wizard. Letting it flow through
162
+ // would send the literal string to the backend, producing confusing 4xx
163
+ // errors. Refuse with a message that points at the file and field.
164
+ const placeholders = FIELDS
165
+ .filter(f => opts[f] === '<replace-me>')
166
+ .map(f => '--' + f.replace(/[A-Z]/g, m => '-' + m.toLowerCase()));
167
+ if (placeholders.length > 0) {
168
+ const where = cfg._file ? cfg._file : '(no config file found)';
169
+ const err = new Error(
170
+ `The following value${placeholders.length === 1 ? ' is' : 's are'} still set to ` +
171
+ `the "<replace-me>" placeholder written by \`specshield init\`: ` +
172
+ placeholders.join(', ') + '\n' +
173
+ `Edit ${where} (or pass real values as CLI flags) before re-running.`);
174
+ err.code = 'UNRESOLVED_PLACEHOLDER';
175
+ err.placeholders = placeholders;
176
+ throw err;
177
+ }
178
+
160
179
  // Required-field check.
161
180
  const required = REQUIRED_FIELDS[command] || [];
162
181
  const missing = required.filter(k => !opts[k]);
@@ -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)) };