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 +1 -1
- package/src/commands/compare.js +6 -1
- package/src/commands/init.js +55 -3
- package/src/core/classifyChanges.js +118 -0
- package/src/core/diffEngine.js +99 -0
- package/src/core/normalizeSpec.js +12 -0
- package/src/core/parseSpec.js +30 -4
- 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.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": {
|
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
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
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) {
|
|
@@ -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)) };
|