@contractual/governance 0.1.0-dev.0
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/LICENSE +21 -0
- package/dist/differs/index.d.ts +11 -0
- package/dist/differs/index.d.ts.map +1 -0
- package/dist/differs/index.js +11 -0
- package/dist/differs/index.js.map +1 -0
- package/dist/differs/json-schema/index.d.ts +8 -0
- package/dist/differs/json-schema/index.d.ts.map +1 -0
- package/dist/differs/json-schema/index.js +9 -0
- package/dist/differs/json-schema/index.js.map +1 -0
- package/dist/differs/openapi-diff.d.ts +22 -0
- package/dist/differs/openapi-diff.d.ts.map +1 -0
- package/dist/differs/openapi-diff.js +113 -0
- package/dist/differs/openapi-diff.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/linters/index.d.ts +9 -0
- package/dist/linters/index.d.ts.map +1 -0
- package/dist/linters/index.js +11 -0
- package/dist/linters/index.js.map +1 -0
- package/dist/linters/json-schema-ajv.d.ts +42 -0
- package/dist/linters/json-schema-ajv.d.ts.map +1 -0
- package/dist/linters/json-schema-ajv.js +146 -0
- package/dist/linters/json-schema-ajv.js.map +1 -0
- package/dist/linters/json-schema-rules.d.ts +42 -0
- package/dist/linters/json-schema-rules.d.ts.map +1 -0
- package/dist/linters/json-schema-rules.js +747 -0
- package/dist/linters/json-schema-rules.js.map +1 -0
- package/dist/linters/openapi-redocly.d.ts +15 -0
- package/dist/linters/openapi-redocly.d.ts.map +1 -0
- package/dist/linters/openapi-redocly.js +69 -0
- package/dist/linters/openapi-redocly.js.map +1 -0
- package/dist/registry.d.ts +48 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +178 -0
- package/dist/registry.js.map +1 -0
- package/dist/runner.d.ts +32 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +321 -0
- package/dist/runner.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/package.json +68 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema Linting Rules
|
|
3
|
+
*
|
|
4
|
+
* Native TypeScript implementation of linting rules for JSON Schema.
|
|
5
|
+
* Based on best practices from Sourcemeta, JSON Schema org, and community discussions.
|
|
6
|
+
*
|
|
7
|
+
* @see https://github.com/sourcemeta/jsonschema
|
|
8
|
+
* @see https://github.com/orgs/json-schema-org/discussions/323
|
|
9
|
+
*/
|
|
10
|
+
import { isSchemaObject } from '@contractual/differs.json-schema';
|
|
11
|
+
/**
|
|
12
|
+
* Keywords that only apply to specific types
|
|
13
|
+
*/
|
|
14
|
+
const TYPE_SPECIFIC_KEYWORDS = {
|
|
15
|
+
string: ['minLength', 'maxLength', 'pattern', 'format'],
|
|
16
|
+
number: ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf'],
|
|
17
|
+
integer: ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf'],
|
|
18
|
+
array: [
|
|
19
|
+
'items',
|
|
20
|
+
'additionalItems',
|
|
21
|
+
'prefixItems',
|
|
22
|
+
'contains',
|
|
23
|
+
'minItems',
|
|
24
|
+
'maxItems',
|
|
25
|
+
'uniqueItems',
|
|
26
|
+
'minContains',
|
|
27
|
+
'maxContains',
|
|
28
|
+
],
|
|
29
|
+
object: [
|
|
30
|
+
'properties',
|
|
31
|
+
'additionalProperties',
|
|
32
|
+
'required',
|
|
33
|
+
'minProperties',
|
|
34
|
+
'maxProperties',
|
|
35
|
+
'patternProperties',
|
|
36
|
+
'propertyNames',
|
|
37
|
+
],
|
|
38
|
+
boolean: [],
|
|
39
|
+
null: [],
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Known JSON Schema format values
|
|
43
|
+
*/
|
|
44
|
+
const KNOWN_FORMATS = new Set([
|
|
45
|
+
// Dates and times (RFC 3339)
|
|
46
|
+
'date-time',
|
|
47
|
+
'date',
|
|
48
|
+
'time',
|
|
49
|
+
'duration',
|
|
50
|
+
// Email (RFC 5321/5322)
|
|
51
|
+
'email',
|
|
52
|
+
'idn-email',
|
|
53
|
+
// Hostnames (RFC 1123/5891)
|
|
54
|
+
'hostname',
|
|
55
|
+
'idn-hostname',
|
|
56
|
+
// IP addresses (RFC 2673/4291)
|
|
57
|
+
'ipv4',
|
|
58
|
+
'ipv6',
|
|
59
|
+
// URIs (RFC 3986/3987)
|
|
60
|
+
'uri',
|
|
61
|
+
'uri-reference',
|
|
62
|
+
'iri',
|
|
63
|
+
'iri-reference',
|
|
64
|
+
'uri-template',
|
|
65
|
+
// JSON Pointer (RFC 6901)
|
|
66
|
+
'json-pointer',
|
|
67
|
+
'relative-json-pointer',
|
|
68
|
+
// Regex (ECMA 262)
|
|
69
|
+
'regex',
|
|
70
|
+
// UUID (RFC 4122)
|
|
71
|
+
'uuid',
|
|
72
|
+
]);
|
|
73
|
+
/**
|
|
74
|
+
* All built-in linting rules
|
|
75
|
+
*/
|
|
76
|
+
export const LINT_RULES = [
|
|
77
|
+
// ==========================================
|
|
78
|
+
// Schema Declaration Rules
|
|
79
|
+
// ==========================================
|
|
80
|
+
{
|
|
81
|
+
id: 'missing-schema',
|
|
82
|
+
description: 'Root schema should declare $schema',
|
|
83
|
+
severity: 'warning',
|
|
84
|
+
check: (schema, path, root) => {
|
|
85
|
+
// Only check at root
|
|
86
|
+
if (path !== '/' || schema !== root)
|
|
87
|
+
return [];
|
|
88
|
+
if (!schema.$schema) {
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
path: '/',
|
|
92
|
+
message: 'No $schema declared. Consider adding "$schema": "https://json-schema.org/draft/2020-12/schema"',
|
|
93
|
+
rule: 'missing-schema',
|
|
94
|
+
severity: 'warning',
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
return [];
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: 'schema-not-at-root',
|
|
103
|
+
description: '$schema should only appear at resource root (with $id) or document root',
|
|
104
|
+
severity: 'warning',
|
|
105
|
+
check: (schema, path, root) => {
|
|
106
|
+
if (path === '/' || schema === root)
|
|
107
|
+
return [];
|
|
108
|
+
if (schema.$schema && !schema.$id) {
|
|
109
|
+
return [
|
|
110
|
+
{
|
|
111
|
+
path,
|
|
112
|
+
message: '$schema without $id in non-root location. $schema should only appear at resource roots.',
|
|
113
|
+
rule: 'schema-not-at-root',
|
|
114
|
+
severity: 'warning',
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
return [];
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
// ==========================================
|
|
122
|
+
// Metadata Rules
|
|
123
|
+
// ==========================================
|
|
124
|
+
{
|
|
125
|
+
id: 'missing-title',
|
|
126
|
+
description: 'Root schema should have a title',
|
|
127
|
+
severity: 'warning',
|
|
128
|
+
check: (schema, path, root) => {
|
|
129
|
+
if (path !== '/' || schema !== root)
|
|
130
|
+
return [];
|
|
131
|
+
if (!schema.title) {
|
|
132
|
+
return [
|
|
133
|
+
{
|
|
134
|
+
path: '/',
|
|
135
|
+
message: 'Root schema has no title. Consider adding one for documentation.',
|
|
136
|
+
rule: 'missing-title',
|
|
137
|
+
severity: 'warning',
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
return [];
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: 'missing-description',
|
|
146
|
+
description: 'Root schema should have a description',
|
|
147
|
+
severity: 'warning',
|
|
148
|
+
check: (schema, path, root) => {
|
|
149
|
+
if (path !== '/' || schema !== root)
|
|
150
|
+
return [];
|
|
151
|
+
if (!schema.description) {
|
|
152
|
+
return [
|
|
153
|
+
{
|
|
154
|
+
path: '/',
|
|
155
|
+
message: 'Root schema has no description. Consider adding one for documentation.',
|
|
156
|
+
rule: 'missing-description',
|
|
157
|
+
severity: 'warning',
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
return [];
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
// ==========================================
|
|
165
|
+
// Enum/Const Rules
|
|
166
|
+
// ==========================================
|
|
167
|
+
{
|
|
168
|
+
id: 'enum-to-const',
|
|
169
|
+
description: 'An enum with a single value should use const instead',
|
|
170
|
+
severity: 'warning',
|
|
171
|
+
check: (schema, path) => {
|
|
172
|
+
if (schema.enum && Array.isArray(schema.enum) && schema.enum.length === 1) {
|
|
173
|
+
return [
|
|
174
|
+
{
|
|
175
|
+
path,
|
|
176
|
+
message: `An 'enum' with a single value can be expressed as 'const'. Use: "const": ${JSON.stringify(schema.enum[0])}`,
|
|
177
|
+
rule: 'enum-to-const',
|
|
178
|
+
severity: 'warning',
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
return [];
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'enum-with-type',
|
|
187
|
+
description: 'Using type with enum is redundant when enum values are all the same type',
|
|
188
|
+
severity: 'warning',
|
|
189
|
+
check: (schema, path) => {
|
|
190
|
+
if (!schema.enum || !schema.type)
|
|
191
|
+
return [];
|
|
192
|
+
const enumValues = schema.enum;
|
|
193
|
+
if (!Array.isArray(enumValues) || enumValues.length === 0)
|
|
194
|
+
return [];
|
|
195
|
+
// Determine types of all enum values
|
|
196
|
+
const enumTypes = new Set(enumValues.map((v) => {
|
|
197
|
+
if (v === null)
|
|
198
|
+
return 'null';
|
|
199
|
+
if (Array.isArray(v))
|
|
200
|
+
return 'array';
|
|
201
|
+
return typeof v;
|
|
202
|
+
}));
|
|
203
|
+
// Map JS types to JSON Schema types
|
|
204
|
+
const jsToSchema = {
|
|
205
|
+
string: 'string',
|
|
206
|
+
number: 'number',
|
|
207
|
+
boolean: 'boolean',
|
|
208
|
+
object: 'object',
|
|
209
|
+
null: 'null',
|
|
210
|
+
array: 'array',
|
|
211
|
+
};
|
|
212
|
+
const schemaTypes = new Set([...enumTypes].map((t) => jsToSchema[t] || t));
|
|
213
|
+
const declaredType = new Set(Array.isArray(schema.type) ? schema.type : [schema.type]);
|
|
214
|
+
// Check if type declaration matches enum value types exactly
|
|
215
|
+
const typesMatch = schemaTypes.size === declaredType.size &&
|
|
216
|
+
[...schemaTypes].every((t) => declaredType.has(t));
|
|
217
|
+
if (typesMatch) {
|
|
218
|
+
return [
|
|
219
|
+
{
|
|
220
|
+
path,
|
|
221
|
+
message: "'type' is redundant when 'enum' values already constrain the type",
|
|
222
|
+
rule: 'enum-with-type',
|
|
223
|
+
severity: 'warning',
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
return [];
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
id: 'const-with-type',
|
|
232
|
+
description: 'Using type with const is redundant when const value determines the type',
|
|
233
|
+
severity: 'warning',
|
|
234
|
+
check: (schema, path) => {
|
|
235
|
+
if (schema.const === undefined || !schema.type)
|
|
236
|
+
return [];
|
|
237
|
+
const constValue = schema.const;
|
|
238
|
+
let constType;
|
|
239
|
+
if (constValue === null) {
|
|
240
|
+
constType = 'null';
|
|
241
|
+
}
|
|
242
|
+
else if (Array.isArray(constValue)) {
|
|
243
|
+
constType = 'array';
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
constType = typeof constValue;
|
|
247
|
+
}
|
|
248
|
+
// Map JS type to JSON Schema type
|
|
249
|
+
const jsToSchema = {
|
|
250
|
+
string: 'string',
|
|
251
|
+
number: 'number',
|
|
252
|
+
boolean: 'boolean',
|
|
253
|
+
object: 'object',
|
|
254
|
+
null: 'null',
|
|
255
|
+
array: 'array',
|
|
256
|
+
};
|
|
257
|
+
const schemaConstType = jsToSchema[constType] || constType;
|
|
258
|
+
const declaredType = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
259
|
+
if (declaredType.length === 1 && declaredType[0] === schemaConstType) {
|
|
260
|
+
return [
|
|
261
|
+
{
|
|
262
|
+
path,
|
|
263
|
+
message: "'type' is redundant when 'const' value already determines the type",
|
|
264
|
+
rule: 'const-with-type',
|
|
265
|
+
severity: 'warning',
|
|
266
|
+
},
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
return [];
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
// ==========================================
|
|
273
|
+
// Conditional Schema Rules
|
|
274
|
+
// ==========================================
|
|
275
|
+
{
|
|
276
|
+
id: 'if-without-then-else',
|
|
277
|
+
description: 'if without then or else is unnecessary',
|
|
278
|
+
severity: 'warning',
|
|
279
|
+
check: (schema, path) => {
|
|
280
|
+
if (schema.if && !schema.then && !schema.else) {
|
|
281
|
+
return [
|
|
282
|
+
{
|
|
283
|
+
path,
|
|
284
|
+
message: "'if' without 'then' or 'else' has no effect and can be removed",
|
|
285
|
+
rule: 'if-without-then-else',
|
|
286
|
+
severity: 'warning',
|
|
287
|
+
},
|
|
288
|
+
];
|
|
289
|
+
}
|
|
290
|
+
return [];
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
id: 'then-else-without-if',
|
|
295
|
+
description: 'then or else without if is unnecessary',
|
|
296
|
+
severity: 'warning',
|
|
297
|
+
check: (schema, path) => {
|
|
298
|
+
const issues = [];
|
|
299
|
+
if (schema.then && !schema.if) {
|
|
300
|
+
issues.push({
|
|
301
|
+
path,
|
|
302
|
+
message: "'then' without 'if' has no effect and can be removed",
|
|
303
|
+
rule: 'then-else-without-if',
|
|
304
|
+
severity: 'warning',
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
if (schema.else && !schema.if) {
|
|
308
|
+
issues.push({
|
|
309
|
+
path,
|
|
310
|
+
message: "'else' without 'if' has no effect and can be removed",
|
|
311
|
+
rule: 'then-else-without-if',
|
|
312
|
+
severity: 'warning',
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return issues;
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
// ==========================================
|
|
319
|
+
// Array Constraint Rules
|
|
320
|
+
// ==========================================
|
|
321
|
+
{
|
|
322
|
+
id: 'additional-items-redundant',
|
|
323
|
+
description: 'additionalItems is ignored when items is a schema (not tuple)',
|
|
324
|
+
severity: 'warning',
|
|
325
|
+
check: (schema, path) => {
|
|
326
|
+
// additionalItems only matters when items is an array (tuple validation)
|
|
327
|
+
// When items is a schema, additionalItems is ignored
|
|
328
|
+
if (schema.additionalItems !== undefined && schema.items && !Array.isArray(schema.items)) {
|
|
329
|
+
return [
|
|
330
|
+
{
|
|
331
|
+
path,
|
|
332
|
+
message: "'additionalItems' is ignored when 'items' is a schema (not a tuple). Remove 'additionalItems' or use tuple validation.",
|
|
333
|
+
rule: 'additional-items-redundant',
|
|
334
|
+
severity: 'warning',
|
|
335
|
+
},
|
|
336
|
+
];
|
|
337
|
+
}
|
|
338
|
+
return [];
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
id: 'contains-required',
|
|
343
|
+
description: 'minContains or maxContains without contains is unnecessary',
|
|
344
|
+
severity: 'warning',
|
|
345
|
+
check: (schema, path) => {
|
|
346
|
+
const issues = [];
|
|
347
|
+
if (schema.minContains !== undefined && !schema.contains) {
|
|
348
|
+
issues.push({
|
|
349
|
+
path,
|
|
350
|
+
message: "'minContains' without 'contains' has no effect",
|
|
351
|
+
rule: 'contains-required',
|
|
352
|
+
severity: 'warning',
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
if (schema.maxContains !== undefined && !schema.contains) {
|
|
356
|
+
issues.push({
|
|
357
|
+
path,
|
|
358
|
+
message: "'maxContains' without 'contains' has no effect",
|
|
359
|
+
rule: 'contains-required',
|
|
360
|
+
severity: 'warning',
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return issues;
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
// ==========================================
|
|
367
|
+
// Type Compatibility Rules
|
|
368
|
+
// ==========================================
|
|
369
|
+
{
|
|
370
|
+
id: 'type-incompatible-keywords',
|
|
371
|
+
description: 'Validation keywords that do not apply to the declared type',
|
|
372
|
+
severity: 'warning',
|
|
373
|
+
check: (schema, path) => {
|
|
374
|
+
if (!schema.type || Array.isArray(schema.type))
|
|
375
|
+
return [];
|
|
376
|
+
const declaredType = schema.type;
|
|
377
|
+
const applicableKeywords = TYPE_SPECIFIC_KEYWORDS[declaredType] || [];
|
|
378
|
+
const issues = [];
|
|
379
|
+
// Check for keywords that apply to other types
|
|
380
|
+
for (const [type, keywords] of Object.entries(TYPE_SPECIFIC_KEYWORDS)) {
|
|
381
|
+
if (type === declaredType)
|
|
382
|
+
continue;
|
|
383
|
+
for (const keyword of keywords) {
|
|
384
|
+
if (schema[keyword] !== undefined && !applicableKeywords.includes(keyword)) {
|
|
385
|
+
issues.push({
|
|
386
|
+
path,
|
|
387
|
+
message: `'${keyword}' applies to type '${type}' but schema declares type '${declaredType}'`,
|
|
388
|
+
rule: 'type-incompatible-keywords',
|
|
389
|
+
severity: 'warning',
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return issues;
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
// ==========================================
|
|
398
|
+
// Range Validation Rules
|
|
399
|
+
// ==========================================
|
|
400
|
+
{
|
|
401
|
+
id: 'invalid-numeric-range',
|
|
402
|
+
description: 'maximum should be greater than or equal to minimum',
|
|
403
|
+
severity: 'error',
|
|
404
|
+
check: (schema, path) => {
|
|
405
|
+
const issues = [];
|
|
406
|
+
if (typeof schema.minimum === 'number' && typeof schema.maximum === 'number') {
|
|
407
|
+
if (schema.maximum < schema.minimum) {
|
|
408
|
+
issues.push({
|
|
409
|
+
path,
|
|
410
|
+
message: `'maximum' (${schema.maximum}) is less than 'minimum' (${schema.minimum})`,
|
|
411
|
+
rule: 'invalid-numeric-range',
|
|
412
|
+
severity: 'error',
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Handle exclusive bounds (draft-06+ where they are numbers)
|
|
417
|
+
if (typeof schema.exclusiveMinimum === 'number' &&
|
|
418
|
+
typeof schema.exclusiveMaximum === 'number') {
|
|
419
|
+
if (schema.exclusiveMaximum <= schema.exclusiveMinimum) {
|
|
420
|
+
issues.push({
|
|
421
|
+
path,
|
|
422
|
+
message: `'exclusiveMaximum' (${schema.exclusiveMaximum}) is not greater than 'exclusiveMinimum' (${schema.exclusiveMinimum})`,
|
|
423
|
+
rule: 'invalid-numeric-range',
|
|
424
|
+
severity: 'error',
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return issues;
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
id: 'invalid-length-range',
|
|
433
|
+
description: 'maxLength should be greater than or equal to minLength',
|
|
434
|
+
severity: 'error',
|
|
435
|
+
check: (schema, path) => {
|
|
436
|
+
if (typeof schema.minLength === 'number' && typeof schema.maxLength === 'number') {
|
|
437
|
+
if (schema.maxLength < schema.minLength) {
|
|
438
|
+
return [
|
|
439
|
+
{
|
|
440
|
+
path,
|
|
441
|
+
message: `'maxLength' (${schema.maxLength}) is less than 'minLength' (${schema.minLength})`,
|
|
442
|
+
rule: 'invalid-length-range',
|
|
443
|
+
severity: 'error',
|
|
444
|
+
},
|
|
445
|
+
];
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return [];
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
id: 'invalid-items-range',
|
|
453
|
+
description: 'maxItems should be greater than or equal to minItems',
|
|
454
|
+
severity: 'error',
|
|
455
|
+
check: (schema, path) => {
|
|
456
|
+
if (typeof schema.minItems === 'number' && typeof schema.maxItems === 'number') {
|
|
457
|
+
if (schema.maxItems < schema.minItems) {
|
|
458
|
+
return [
|
|
459
|
+
{
|
|
460
|
+
path,
|
|
461
|
+
message: `'maxItems' (${schema.maxItems}) is less than 'minItems' (${schema.minItems})`,
|
|
462
|
+
rule: 'invalid-items-range',
|
|
463
|
+
severity: 'error',
|
|
464
|
+
},
|
|
465
|
+
];
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return [];
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
id: 'invalid-properties-range',
|
|
473
|
+
description: 'maxProperties should be greater than or equal to minProperties',
|
|
474
|
+
severity: 'error',
|
|
475
|
+
check: (schema, path) => {
|
|
476
|
+
if (typeof schema.minProperties === 'number' && typeof schema.maxProperties === 'number') {
|
|
477
|
+
if (schema.maxProperties < schema.minProperties) {
|
|
478
|
+
return [
|
|
479
|
+
{
|
|
480
|
+
path,
|
|
481
|
+
message: `'maxProperties' (${schema.maxProperties}) is less than 'minProperties' (${schema.minProperties})`,
|
|
482
|
+
rule: 'invalid-properties-range',
|
|
483
|
+
severity: 'error',
|
|
484
|
+
},
|
|
485
|
+
];
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return [];
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
// ==========================================
|
|
492
|
+
// Format Rules
|
|
493
|
+
// ==========================================
|
|
494
|
+
{
|
|
495
|
+
id: 'unknown-format',
|
|
496
|
+
description: 'Unknown format value may not be validated',
|
|
497
|
+
severity: 'warning',
|
|
498
|
+
check: (schema, path) => {
|
|
499
|
+
if (typeof schema.format === 'string' && !KNOWN_FORMATS.has(schema.format)) {
|
|
500
|
+
return [
|
|
501
|
+
{
|
|
502
|
+
path,
|
|
503
|
+
message: `Unknown format '${schema.format}'. Custom formats may not be validated by all implementations.`,
|
|
504
|
+
rule: 'unknown-format',
|
|
505
|
+
severity: 'warning',
|
|
506
|
+
},
|
|
507
|
+
];
|
|
508
|
+
}
|
|
509
|
+
return [];
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
// ==========================================
|
|
513
|
+
// Empty Schema Rules
|
|
514
|
+
// ==========================================
|
|
515
|
+
{
|
|
516
|
+
id: 'empty-enum',
|
|
517
|
+
description: 'Empty enum matches nothing and is likely an error',
|
|
518
|
+
severity: 'error',
|
|
519
|
+
check: (schema, path) => {
|
|
520
|
+
if (schema.enum && Array.isArray(schema.enum) && schema.enum.length === 0) {
|
|
521
|
+
return [
|
|
522
|
+
{
|
|
523
|
+
path,
|
|
524
|
+
message: "Empty 'enum' array will never validate any value",
|
|
525
|
+
rule: 'empty-enum',
|
|
526
|
+
severity: 'error',
|
|
527
|
+
},
|
|
528
|
+
];
|
|
529
|
+
}
|
|
530
|
+
return [];
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
id: 'empty-required',
|
|
535
|
+
description: 'Empty required array is unnecessary',
|
|
536
|
+
severity: 'warning',
|
|
537
|
+
check: (schema, path) => {
|
|
538
|
+
if (schema.required && Array.isArray(schema.required) && schema.required.length === 0) {
|
|
539
|
+
return [
|
|
540
|
+
{
|
|
541
|
+
path,
|
|
542
|
+
message: "Empty 'required' array can be removed",
|
|
543
|
+
rule: 'empty-required',
|
|
544
|
+
severity: 'warning',
|
|
545
|
+
},
|
|
546
|
+
];
|
|
547
|
+
}
|
|
548
|
+
return [];
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
id: 'empty-allof-anyof-oneof',
|
|
553
|
+
description: 'Empty allOf/anyOf/oneOf is likely an error',
|
|
554
|
+
severity: 'error',
|
|
555
|
+
check: (schema, path) => {
|
|
556
|
+
const issues = [];
|
|
557
|
+
for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
|
|
558
|
+
const value = schema[keyword];
|
|
559
|
+
if (value && Array.isArray(value) && value.length === 0) {
|
|
560
|
+
issues.push({
|
|
561
|
+
path,
|
|
562
|
+
message: `Empty '${keyword}' array ${keyword === 'anyOf' || keyword === 'oneOf' ? 'will never validate' : 'is redundant'}`,
|
|
563
|
+
rule: 'empty-allof-anyof-oneof',
|
|
564
|
+
severity: keyword === 'allOf' ? 'warning' : 'error',
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return issues;
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
// ==========================================
|
|
572
|
+
// Required Properties Rules
|
|
573
|
+
// ==========================================
|
|
574
|
+
{
|
|
575
|
+
id: 'required-undefined-property',
|
|
576
|
+
description: 'Required property is not defined in properties',
|
|
577
|
+
severity: 'warning',
|
|
578
|
+
check: (schema, path) => {
|
|
579
|
+
if (!schema.required || !schema.properties)
|
|
580
|
+
return [];
|
|
581
|
+
const issues = [];
|
|
582
|
+
const definedProps = new Set(Object.keys(schema.properties));
|
|
583
|
+
for (const requiredProp of schema.required) {
|
|
584
|
+
if (!definedProps.has(requiredProp)) {
|
|
585
|
+
issues.push({
|
|
586
|
+
path,
|
|
587
|
+
message: `Required property '${requiredProp}' is not defined in 'properties'`,
|
|
588
|
+
rule: 'required-undefined-property',
|
|
589
|
+
severity: 'warning',
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return issues;
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
id: 'duplicate-required',
|
|
598
|
+
description: 'Duplicate entries in required array',
|
|
599
|
+
severity: 'warning',
|
|
600
|
+
check: (schema, path) => {
|
|
601
|
+
if (!schema.required || !Array.isArray(schema.required))
|
|
602
|
+
return [];
|
|
603
|
+
const seen = new Set();
|
|
604
|
+
const duplicates = new Set();
|
|
605
|
+
for (const prop of schema.required) {
|
|
606
|
+
if (seen.has(prop)) {
|
|
607
|
+
duplicates.add(prop);
|
|
608
|
+
}
|
|
609
|
+
seen.add(prop);
|
|
610
|
+
}
|
|
611
|
+
if (duplicates.size > 0) {
|
|
612
|
+
return [
|
|
613
|
+
{
|
|
614
|
+
path,
|
|
615
|
+
message: `Duplicate entries in 'required': ${[...duplicates].join(', ')}`,
|
|
616
|
+
rule: 'duplicate-required',
|
|
617
|
+
severity: 'warning',
|
|
618
|
+
},
|
|
619
|
+
];
|
|
620
|
+
}
|
|
621
|
+
return [];
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
];
|
|
625
|
+
/**
|
|
626
|
+
* Run all lint rules against a schema, recursively walking all subschemas
|
|
627
|
+
*/
|
|
628
|
+
export function runLintRules(schema, enabledRules, disabledRules) {
|
|
629
|
+
const issues = [];
|
|
630
|
+
const activeRules = LINT_RULES.filter((rule) => {
|
|
631
|
+
if (disabledRules?.has(rule.id))
|
|
632
|
+
return false;
|
|
633
|
+
if (enabledRules && !enabledRules.has(rule.id))
|
|
634
|
+
return false;
|
|
635
|
+
return true;
|
|
636
|
+
});
|
|
637
|
+
walkSchema(schema, '/', schema, (node, path, root) => {
|
|
638
|
+
for (const rule of activeRules) {
|
|
639
|
+
const ruleIssues = rule.check(node, path, root);
|
|
640
|
+
issues.push(...ruleIssues);
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
return issues;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Walk all subschemas in a JSON Schema document
|
|
647
|
+
*/
|
|
648
|
+
function walkSchema(schema, path, root, visitor) {
|
|
649
|
+
if (!isSchemaObject(schema))
|
|
650
|
+
return;
|
|
651
|
+
visitor(schema, path, root);
|
|
652
|
+
// Properties
|
|
653
|
+
if (schema.properties) {
|
|
654
|
+
for (const [key, value] of Object.entries(schema.properties)) {
|
|
655
|
+
if (isSchemaObject(value)) {
|
|
656
|
+
walkSchema(value, `${path}/properties/${key}`, root, visitor);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// Additional properties
|
|
661
|
+
if (isSchemaObject(schema.additionalProperties)) {
|
|
662
|
+
walkSchema(schema.additionalProperties, `${path}/additionalProperties`, root, visitor);
|
|
663
|
+
}
|
|
664
|
+
// Pattern properties
|
|
665
|
+
if (schema.patternProperties) {
|
|
666
|
+
for (const [pattern, value] of Object.entries(schema.patternProperties)) {
|
|
667
|
+
if (isSchemaObject(value)) {
|
|
668
|
+
walkSchema(value, `${path}/patternProperties/${encodeURIComponent(pattern)}`, root, visitor);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Property names
|
|
673
|
+
if (isSchemaObject(schema.propertyNames)) {
|
|
674
|
+
walkSchema(schema.propertyNames, `${path}/propertyNames`, root, visitor);
|
|
675
|
+
}
|
|
676
|
+
// Items (array or single schema)
|
|
677
|
+
if (schema.items) {
|
|
678
|
+
if (Array.isArray(schema.items)) {
|
|
679
|
+
schema.items.forEach((item, i) => {
|
|
680
|
+
if (isSchemaObject(item)) {
|
|
681
|
+
walkSchema(item, `${path}/items/${i}`, root, visitor);
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
else if (isSchemaObject(schema.items)) {
|
|
686
|
+
walkSchema(schema.items, `${path}/items`, root, visitor);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// Prefix items (2020-12)
|
|
690
|
+
if (schema.prefixItems && Array.isArray(schema.prefixItems)) {
|
|
691
|
+
schema.prefixItems.forEach((item, i) => {
|
|
692
|
+
if (isSchemaObject(item)) {
|
|
693
|
+
walkSchema(item, `${path}/prefixItems/${i}`, root, visitor);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
// Contains
|
|
698
|
+
if (isSchemaObject(schema.contains)) {
|
|
699
|
+
walkSchema(schema.contains, `${path}/contains`, root, visitor);
|
|
700
|
+
}
|
|
701
|
+
// Conditional
|
|
702
|
+
if (isSchemaObject(schema.if)) {
|
|
703
|
+
walkSchema(schema.if, `${path}/if`, root, visitor);
|
|
704
|
+
}
|
|
705
|
+
if (isSchemaObject(schema.then)) {
|
|
706
|
+
walkSchema(schema.then, `${path}/then`, root, visitor);
|
|
707
|
+
}
|
|
708
|
+
if (isSchemaObject(schema.else)) {
|
|
709
|
+
walkSchema(schema.else, `${path}/else`, root, visitor);
|
|
710
|
+
}
|
|
711
|
+
// Composition
|
|
712
|
+
for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
|
|
713
|
+
const arr = schema[keyword];
|
|
714
|
+
if (arr && Array.isArray(arr)) {
|
|
715
|
+
arr.forEach((item, i) => {
|
|
716
|
+
if (isSchemaObject(item)) {
|
|
717
|
+
walkSchema(item, `${path}/${keyword}/${i}`, root, visitor);
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// Not
|
|
723
|
+
if (isSchemaObject(schema.not)) {
|
|
724
|
+
walkSchema(schema.not, `${path}/not`, root, visitor);
|
|
725
|
+
}
|
|
726
|
+
// Definitions ($defs)
|
|
727
|
+
if (schema.$defs) {
|
|
728
|
+
for (const [key, value] of Object.entries(schema.$defs)) {
|
|
729
|
+
if (isSchemaObject(value)) {
|
|
730
|
+
walkSchema(value, `${path}/$defs/${key}`, root, visitor);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Get all available rule IDs
|
|
737
|
+
*/
|
|
738
|
+
export function getAvailableRules() {
|
|
739
|
+
return LINT_RULES.map((r) => r.id);
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Get rule description by ID
|
|
743
|
+
*/
|
|
744
|
+
export function getRuleDescription(ruleId) {
|
|
745
|
+
return LINT_RULES.find((r) => r.id === ruleId)?.description;
|
|
746
|
+
}
|
|
747
|
+
//# sourceMappingURL=json-schema-rules.js.map
|