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 +1 -1
- package/src/commands/bdct.js +1 -1
- package/src/commands/compare.js +6 -1
- package/src/commands/init.js +153 -19
- package/src/core/classifyChanges.js +118 -0
- package/src/core/configWriter.js +30 -5
- package/src/core/diffEngine.js +99 -0
- package/src/core/normalizeSpec.js +12 -0
- package/src/core/parseSpec.js +30 -4
- package/src/core/projectConfig.js +19 -0
- package/src/core/projectDetect.js +8 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specshield",
|
|
3
|
-
"version": "3.2.
|
|
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": {
|
package/src/commands/bdct.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/compare.js
CHANGED
|
@@ -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
|
-
|
|
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')
|
package/src/commands/init.js
CHANGED
|
@@ -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',
|
|
162
|
-
|
|
163
|
-
initial: detected.
|
|
164
|
-
|
|
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',
|
|
230
|
+
{ type: 'text', name: 'consumerName',
|
|
231
|
+
message: `Consumer name ${skipHint}`,
|
|
175
232
|
initial: detected.serviceName },
|
|
176
|
-
{ type: 'text', name: 'consumerProvider',
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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',
|
|
247
|
-
|
|
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',
|
|
256
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
package/src/core/configWriter.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
+
` contract: ${contract}`,
|
|
200
225
|
' api-token: ${{ secrets.SPECSHIELD_API_KEY }}',
|
|
201
226
|
'',
|
|
202
227
|
);
|
package/src/core/diffEngine.js
CHANGED
|
@@ -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) {
|
package/src/core/parseSpec.js
CHANGED
|
@@ -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
|
-
|
|
19
|
+
parsed = parseJson(content);
|
|
16
20
|
} else if (ext === '.yaml' || ext === '.yml') {
|
|
17
|
-
|
|
21
|
+
parsed = parseYaml(content);
|
|
18
22
|
} else {
|
|
19
23
|
// Auto-detect: try JSON first, then YAML
|
|
20
|
-
|
|
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
|
|
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
|
|
108
|
-
|
|
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)) };
|