yaml-dsl-spec 0.1.1 → 0.1.3

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.
@@ -7,6 +7,24 @@ const AjvDraft202012 = require('ajv/dist/2020').default;
7
7
  const { Diagnostic } = require('./diagnostic-report');
8
8
  const { FunctionTable } = require('./function-table');
9
9
  const { numberJson, integerOfText } = require('./yaml-node');
10
+ const { markedSchema } = require('./requirement-markers');
11
+ const {
12
+ fieldName,
13
+ numberText,
14
+ describedForm,
15
+ dependencyOf,
16
+ dependencyMessage,
17
+ duplicateItems,
18
+ containsMessage,
19
+ schemaNodeAt,
20
+ } = require('./problem-wording');
21
+ const {
22
+ ValidationRules,
23
+ childValue,
24
+ mergedOver,
25
+ copiedPointers,
26
+ escapePointer,
27
+ } = require('./validation-rules');
10
28
 
11
29
  const VALIDATOR_OPTIONS = {
12
30
  allErrors: true,
@@ -49,23 +67,22 @@ const READS_BELOW_KEYWORDS = new Set([
49
67
  'uniqueItems',
50
68
  ]);
51
69
  const REQUIRED_KEYWORDS = new Set(['required', 'dependentRequired', 'dependencies']);
52
- const LIMIT_PLURALS = {
53
- maxLength: ['is longer than', 'character', 'characters'],
54
- minLength: ['is shorter than', 'character', 'characters'],
55
- maxItems: ['has more than', 'item', 'items'],
56
- minItems: ['has less than', 'item', 'items'],
57
- maxProperties: ['has more than', 'property', 'properties'],
58
- minProperties: ['has less than', 'property', 'properties'],
70
+ const BOUND_COMPARISONS = {
71
+ minimum: '>=',
72
+ maximum: '<=',
73
+ exclusiveMinimum: '>',
74
+ exclusiveMaximum: '<',
59
75
  };
60
- const BOUNDS = {
61
- maximum: 'is greater than the maximum of',
62
- minimum: 'is less than the minimum of',
63
- exclusiveMaximum: 'is greater than or equal to the maximum of',
64
- exclusiveMinimum: 'is less than or equal to the minimum of',
76
+ const LENGTH_LIMITS = {
77
+ minLength: ['fewer', 'characters'],
78
+ maxLength: ['more', 'characters'],
79
+ minItems: ['fewer', 'items'],
80
+ maxItems: ['more', 'items'],
81
+ minProperties: ['fewer', 'properties'],
82
+ maxProperties: ['more', 'properties'],
65
83
  };
66
- const KEYWORD_NAMES = { 'false schema': 'falseSchema', items: 'additionalItems' };
84
+ const KEYWORD_NAMES = { 'false schema': 'falseSchema' };
67
85
  const FALSE_SCHEMA_STEP = /\/false schema$/;
68
- const SHOWN_ENUM_OPTIONS = 3;
69
86
 
70
87
  const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field);
71
88
  const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -88,10 +105,11 @@ function draftOfSchema(schema) {
88
105
 
89
106
  /** A document's schema, as its caller hands it over: its function table, and the validation against it. */
90
107
  class LoadedSchema {
91
- constructor(table, validator, schema, name, pointersBySchemaObject) {
108
+ constructor(table, validator, schema, marked, name, pointersBySchemaObject) {
92
109
  this.table = table;
93
110
  this.validator = validator;
94
111
  this.schema = schema;
112
+ this.marked = marked;
95
113
  this.name = name;
96
114
  this.pointersBySchemaObject = pointersBySchemaObject;
97
115
  }
@@ -112,119 +130,234 @@ class LoadedSchema {
112
130
  /** The schema `text` holds, named `name` in reports, or throws why it holds none. */
113
131
  static parse(name, text) {
114
132
  const failed = (error) => new Error(`schema ${name}: ${error.message}`);
115
- let schema;
116
- let table;
117
- let validator;
118
133
  try {
119
- schema = JSON.parse(text);
120
- table = FunctionTable.fromSchema(schema);
134
+ const schema = JSON.parse(text);
135
+ const table = FunctionTable.fromSchema(schema);
121
136
  const draft = draftOfSchema(schema);
122
- const compiled = isObject(schema) && schema.$schema !== undefined && OLDER_DRAFT_URIS.includes(schema.$schema)
137
+ const declaresOlderDraft = isObject(schema)
138
+ && schema.$schema !== undefined
139
+ && OLDER_DRAFT_URIS.includes(schema.$schema);
140
+ const declared = declaresOlderDraft
123
141
  ? { ...schema, $schema: ASSUMED_DRAFT.schemaUris[0] }
124
142
  : schema;
125
- validator = new draft.ValidatorClass(VALIDATOR_OPTIONS).compile(compiled);
126
- return new LoadedSchema(table, validator, schema, name, schemaObjectPointers(compiled));
143
+ const marked = markedSchema(declared, draft.name);
144
+ const validator = new draft.ValidatorClass(VALIDATOR_OPTIONS).compile(marked);
145
+ const pointers = schemaObjectPointers(marked);
146
+ return new LoadedSchema(table, validator, schema, marked, name, pointers);
127
147
  } catch (error) {
128
148
  throw failed(error);
129
149
  }
130
150
  }
131
151
 
132
152
  /**
133
- * Validates `document`, a resolved value. A value not known when the document
134
- * compiles, such as a call or a reference to a declaration, is not checked.
153
+ * Validates `document`, a resolved value, read by `rules`. A value not known
154
+ * when the document compiles, such as a call or a reference to a declaration,
155
+ * is not checked.
135
156
  */
136
- validate(document, childValuePatterns = []) {
137
- const instance = new InstanceRecord(childValuePatterns);
138
- const json = instance.json(document, '', document.position);
157
+ validate(document, rules) {
158
+ const instance = new InstanceRecord(rules);
159
+ const json = this.withQuotedValuesRead(instance.json(document, '', document.position), rules);
139
160
  this.validator(json);
140
- const errors = reportedErrors(this.validator.errors || [], this.schema)
161
+ const errors = reportedErrors(this.validator.errors || [], this.marked)
141
162
  .filter((error) => !instance.readsUnknown(error.instancePath, error.keyword));
142
- const diagnostics = errors.flatMap((error) => this.errorDiagnostics(error, instance));
163
+ const reported = { dependencies: new Set(), itemLists: new Set(), propertyNames: new Set() };
164
+ const diagnostics = errors
165
+ .flatMap((error) => this.errorProblems(error, json, instance, reported))
166
+ .filter((problem) => !instance.isCopied(problem.subject))
167
+ .map((problem) => problem.diagnostic);
143
168
  diagnostics.sort(compareDiagnostics);
144
169
  return diagnostics.filter((diagnostic, index) =>
145
170
  index === 0 || !sameDiagnostic(diagnostic, diagnostics[index - 1]));
146
171
  }
147
172
 
148
173
  /**
149
- * A schema error as the report a reader fixes it from: placed at the value or
150
- * the field that is wrong, labeled with what is wrong there, naming the rule it
151
- * breaks, and the allowed name closest to a misspelt one.
174
+ * `json` with each quoted value that spells a type the schema expects of it, and
175
+ * `rules` allow, read as that type, until no more are read.
152
176
  */
153
- errorDiagnostics(error, instance) {
177
+ withQuotedValuesRead(json, rules) {
178
+ if (rules.quotedValueTypes.length === 0) return json;
179
+ let read = json;
180
+ for (;;) {
181
+ this.validator(read);
182
+ const readings = new Map();
183
+ for (const error of this.validator.errors || []) {
184
+ if (error.keyword !== 'type' || typeof error.data !== 'string') continue;
185
+ const reading = rules.quotedValue(error.data, [].concat(error.schema));
186
+ if (reading !== null && !readings.has(error.instancePath)) {
187
+ readings.set(error.instancePath, reading.value);
188
+ }
189
+ }
190
+ if (readings.size === 0) return read;
191
+ for (const [pointer, value] of readings) read = withValueAt(read, pointer, value);
192
+ }
193
+ }
194
+
195
+ /**
196
+ * A schema error as the problems a reader fixes it from, `{ subject, diagnostic }`:
197
+ * placed at the value or the field that is wrong, worded by the standard,
198
+ * labeled with what is wrong there, naming the rule it breaks, and the allowed
199
+ * name closest to a misspelt one.
200
+ */
201
+ errorProblems(error, json, instance, reported) {
154
202
  const pointer = error.instancePath;
155
203
  const keyword = keywordName(error);
156
204
  const schemaPointer = this.keywordPointer(error);
157
- const rule = `${keyword}, at ${this.name}#${schemaPointer}`;
158
- if (error.propertyName !== undefined) {
159
- return [
160
- Diagnostic.at(instance.valuePosition(pointer), `${displayPointer(pointer)}: ${errorText(error, instance)}`)
161
- .withLabel(`breaks \`${keyword}\``)
162
- .withRule(rule),
163
- ];
164
- }
165
- if (error.keyword === 'items' && error.schema === false) {
166
- const extra = Array.isArray(error.data) ? error.data.slice(error.params.limit) : [];
167
- return extra.map((item, index) => {
168
- const itemPointer = `${pointer}/${error.params.limit + index}`;
169
- return Diagnostic.at(
170
- instance.valuePosition(itemPointer),
171
- `${displayPointer(itemPointer)}: False schema does not allow ${instance.jsonText(item, itemPointer)}`,
205
+ const ruleAt = (name, at) => `${name}, at ${this.name}#${at}`;
206
+ const rule = ruleAt(keyword, schemaPointer);
207
+ const field = fieldName(json, pointer);
208
+ const missingField = REQUIRED_KEYWORDS.has(error.keyword)
209
+ ? error.params.missingProperty
210
+ : null;
211
+ const dependency = error.propertyName === undefined
212
+ ? dependencyOf(this.marked, schemaPointer, valueAt(json, pointer), missingField)
213
+ : null;
214
+ if (dependency !== null) {
215
+ const subject = `${pointer}/${escapePointer(dependency.key)}`;
216
+ if (reported.dependencies.has(subject)) return [];
217
+ reported.dependencies.add(subject);
218
+ const { keywordPointer } = dependency;
219
+ const dependencyKeyword = keywordPointer.slice(keywordPointer.lastIndexOf('/') + 1);
220
+ const at = `${dependency.keywordPointer}/${escapePointer(dependency.key)}`;
221
+ return [{
222
+ subject,
223
+ diagnostic: Diagnostic.at(
224
+ instance.position(subject),
225
+ dependencyMessage(this.marked, dependency.keywordPointer, dependency.key),
172
226
  )
173
- .withLabel('breaks `falseSchema`')
174
- .withRule(`falseSchema, at ${this.name}#${schemaPointer}`);
175
- });
227
+ .withLabel(`breaks \`${dependencyKeyword}\``)
228
+ .withRule(ruleAt(dependencyKeyword, at)),
229
+ }];
176
230
  }
177
- const step = lastStep(pointer);
178
- const holder = step === null ? 'the document' : `\`${step}\``;
179
231
  const suggestion = (name, candidates) => {
180
232
  const closest = closestName(name, candidates);
181
233
  return closest === null ? null : `did you mean \`${closest}\`?`;
182
234
  };
235
+ const atValue = (message) => [{
236
+ subject: pointer,
237
+ diagnostic: Diagnostic.at(instance.valuePosition(pointer), message)
238
+ .withLabel(`breaks \`${keyword}\``)
239
+ .withRule(rule),
240
+ }];
241
+ if (error.propertyName !== undefined) {
242
+ const subject = `${pointer}/${escapePointer(error.propertyName)}`;
243
+ if (reported.propertyNames.has(subject)) return [];
244
+ reported.propertyNames.add(subject);
245
+ return [{
246
+ subject,
247
+ diagnostic: Diagnostic.at(
248
+ instance.position(subject),
249
+ `\`${error.propertyName}\` is not a valid key of ${field}`,
250
+ )
251
+ .withLabel('breaks `propertyNames`')
252
+ .withRule(rule),
253
+ }];
254
+ }
255
+ const itemsBeyondTuple = (error.keyword === 'items' && error.schema === false)
256
+ || error.keyword === 'additionalItems';
257
+ if (itemsBeyondTuple) {
258
+ if (reported.itemLists.has(pointer)) return [];
259
+ reported.itemLists.add(pointer);
260
+ const listKeyword = schemaPointer.slice(schemaPointer.lastIndexOf('/') + 1);
261
+ return [{
262
+ subject: pointer,
263
+ diagnostic: Diagnostic.at(
264
+ instance.valuePosition(pointer),
265
+ `${field} must NOT have more than ${error.params.limit} items`,
266
+ )
267
+ .withLabel(`breaks \`${listKeyword}\``)
268
+ .withRule(ruleAt(listKeyword, schemaPointer)),
269
+ }];
270
+ }
183
271
  if (error.keyword === 'additionalProperties' || error.keyword === 'unevaluatedProperties') {
184
272
  const name = error.keyword === 'additionalProperties'
185
273
  ? error.params.additionalProperty
186
274
  : error.params.unevaluatedProperty;
187
275
  const allowed = allowedFields(this.schema, schemaPointer);
188
- const field = `${pointer}/${escapePointer(name)}`;
189
- return [
190
- Diagnostic.at(instance.position(field), `field \`${name}\` is not allowed`)
191
- .withLabel(`${holder} has no field \`${name}\``)
276
+ const subject = `${pointer}/${escapePointer(name)}`;
277
+ return [{
278
+ subject,
279
+ diagnostic: Diagnostic.at(
280
+ instance.position(subject),
281
+ `\`${name}\` is not a field of ${field}`,
282
+ )
283
+ .withLabel(`${field} has no field \`${name}\``)
192
284
  .withRule(rule)
193
285
  .withHelp(suggestion(name, allowed)),
194
- ];
286
+ }];
195
287
  }
196
288
  if (error.keyword === 'enum') {
197
289
  const options = Array.isArray(error.schema) ? error.schema : [];
198
290
  const allowed = options.filter((option) => typeof option === 'string');
199
- const value = error.data;
200
- return [
201
- Diagnostic.at(instance.valuePosition(pointer), `\`${shownValue(value, pointer, instance)}\` is not an allowed value`)
291
+ const shown = options.map((option) => `\`${jsonText(option)}\``).join(', ');
292
+ return [{
293
+ subject: pointer,
294
+ diagnostic: Diagnostic.at(
295
+ instance.valuePosition(pointer),
296
+ `${field} must be one of ${shown}`,
297
+ )
202
298
  .withLabel(`not one of the ${options.length} values \`enum\` allows`)
203
299
  .withRule(rule)
204
- .withHelp(typeof value === 'string' ? suggestion(value, allowed) : null),
205
- ];
300
+ .withHelp(typeof error.data === 'string' ? suggestion(error.data, allowed) : null),
301
+ }];
206
302
  }
207
303
  if (error.keyword === 'type') {
208
- const expected = typesOf(error.schema);
209
- return [
210
- Diagnostic.at(instance.valuePosition(pointer), `${holder} is not ${withArticle(expected.join(' or '))}`)
304
+ const expected = [].concat(error.schema);
305
+ return [{
306
+ subject: pointer,
307
+ diagnostic: Diagnostic.at(
308
+ instance.valuePosition(pointer),
309
+ `${field} must be ${expected.join(' or ')}`,
310
+ )
211
311
  .withLabel(`is ${withArticle(instance.jsonType(pointer, error.data))}`)
212
312
  .withRule(rule),
213
- ];
313
+ }];
214
314
  }
215
315
  if (REQUIRED_KEYWORDS.has(error.keyword)) {
216
316
  const name = error.params.missingProperty;
217
- return [
218
- Diagnostic.at(instance.position(pointer), `field \`${name}\` is required`)
219
- .withLabel(`${holder} has no field \`${name}\``)
317
+ return [{
318
+ subject: pointer,
319
+ diagnostic: Diagnostic.at(instance.position(pointer), `\`${name}\` is required`)
320
+ .withLabel(`${field} has no field \`${name}\``)
220
321
  .withRule(rule),
221
- ];
322
+ }];
323
+ }
324
+ if (ALTERNATIVE_KEYWORDS.has(error.keyword)) {
325
+ if (Array.isArray(error.params.passingSchemas)) {
326
+ return atValue(`${field} matches more than one of its allowed forms`);
327
+ }
328
+ const holderPointer = schemaPointer.slice(0, schemaPointer.lastIndexOf('/'));
329
+ const form = describedForm(this.schema, holderPointer);
330
+ return atValue(form === null
331
+ ? `${field} matches none of its allowed forms`
332
+ : `${field} must be ${form}`);
333
+ }
334
+ if (hasOwn(BOUND_COMPARISONS, error.keyword)) {
335
+ const comparison = BOUND_COMPARISONS[error.keyword];
336
+ return atValue(`${field} must be ${comparison} ${numberText(error.schema)}`);
337
+ }
338
+ if (hasOwn(LENGTH_LIMITS, error.keyword)) {
339
+ const [comparison, unit] = LENGTH_LIMITS[error.keyword];
340
+ return atValue(`${field} must NOT have ${comparison} than ${error.params.limit} ${unit}`);
341
+ }
342
+ switch (error.keyword) {
343
+ case 'const': return atValue(`${field} must be equal to constant`);
344
+ case 'multipleOf':
345
+ return atValue(`${field} must be multiple of ${numberText(error.schema)}`);
346
+ case 'pattern': return atValue(`${field} must match pattern "${error.params.pattern}"`);
347
+ case 'uniqueItems': {
348
+ const items = Array.isArray(error.data) ? error.data : [];
349
+ const [later, earlier] = duplicateItems(items) || [0, 0];
350
+ return atValue(
351
+ `${field} must NOT have duplicate items (items ## ${later} and ${earlier} are identical)`,
352
+ );
353
+ }
354
+ case 'contains': return atValue(`${field} ${containsMessage(error.parentSchema)}`);
355
+ case 'not': return atValue(`${field} must NOT be valid`);
356
+ case 'false schema': return atValue(`${field} boolean schema is false`);
357
+ case 'unevaluatedItems':
358
+ return atValue(`${field} must NOT have more than ${error.params.len} items`);
359
+ default: return atValue(`${field} breaks \`${keyword}\``);
222
360
  }
223
- return [
224
- Diagnostic.at(instance.valuePosition(pointer), `${displayPointer(pointer)}: ${errorText(error, instance)}`)
225
- .withLabel(`breaks \`${keyword}\``)
226
- .withRule(rule),
227
- ];
228
361
  }
229
362
  }
230
363
 
@@ -322,17 +455,6 @@ function pointerOfSchemaPath(path) {
322
455
  }
323
456
  }
324
457
 
325
- function schemaNodeAt(root, pointer) {
326
- if (pointer === '') return root;
327
- let node = root;
328
- for (const step of pointer.slice(1).split('/')) {
329
- const key = step.replaceAll('~1', '/').replaceAll('~0', '~');
330
- if (node === null || typeof node !== 'object' || !hasOwn(node, key)) return undefined;
331
- node = node[key];
332
- }
333
- return node;
334
- }
335
-
336
458
  /**
337
459
  * The fields a schema node allows, by the `properties` of the node holding the
338
460
  * keyword at `keywordPointer`, of the schemas it references and of those it
@@ -397,61 +519,10 @@ function editDistance(first, second) {
397
519
  return previous[secondCharacters.length];
398
520
  }
399
521
 
400
- /**
401
- * What a JSON pointer names, as a report shows it: its last key, or for a list
402
- * item, the list's key and the item's index, such as `dsls[0]`; `null` for the
403
- * document's root.
404
- */
405
- function lastStep(pointer) {
406
- const slash = pointer.lastIndexOf('/');
407
- if (slash === -1) return null;
408
- const parent = pointer.slice(0, slash);
409
- const step = pointer.slice(slash + 1);
410
- if (/^\d+$/.test(step)) {
411
- const list = lastStep(parent);
412
- if (list !== null) return `${list}[${step}]`;
413
- }
414
- return step.replaceAll('~1', '/').replaceAll('~0', '~');
415
- }
416
-
417
- function escapePointer(key) {
418
- return key.replaceAll('~', '~0').replaceAll('/', '~1');
419
- }
420
-
421
- function displayPointer(pointer) {
422
- if (pointer === '') return 'the document';
423
- const steps = pointer.slice(1).split('/').map((step) => step.replaceAll('~1', '/').replaceAll('~0', '~'));
424
- return `\`${steps.join('.')}\``;
425
- }
426
-
427
522
  function withArticle(noun) {
428
523
  return `${/^[aeiou]/.test(noun) ? 'an' : 'a'} ${noun}`;
429
524
  }
430
525
 
431
- /** A schema's `type` as the crate lists several: in its fixed order of JSON types. */
432
- function typesOf(typeKeyword) {
433
- if (!Array.isArray(typeKeyword)) return [String(typeKeyword)];
434
- const order = ['null', 'boolean', 'integer', 'number', 'string', 'array', 'object'];
435
- return order.filter((type) => typeKeyword.includes(type));
436
- }
437
-
438
- /** A string as Rust's `Debug` writes it. */
439
- function debugText(text) {
440
- let written = '"';
441
- for (const character of text) {
442
- const code = character.codePointAt(0);
443
- if (character === '"') written += '\\"';
444
- else if (character === '\\') written += '\\\\';
445
- else if (character === '\n') written += '\\n';
446
- else if (character === '\r') written += '\\r';
447
- else if (character === '\t') written += '\\t';
448
- else if (character === '\0') written += '\\0';
449
- else if (code < 0x20 || code === 0x7f) written += `\\u{${code.toString(16)}}`;
450
- else written += character;
451
- }
452
- return `${written}"`;
453
- }
454
-
455
526
  /** A floating-point number as `serde_json` writes one. */
456
527
  function floatText(number) {
457
528
  if (Object.is(number, -0)) return '-0.0';
@@ -496,72 +567,6 @@ function nodeJsonText(node) {
496
567
  return jsonText(node.toJson(), (at) => (numberTexts.has(at) ? numberTexts.get(at) : null));
497
568
  }
498
569
 
499
- function shownValue(value, pointer, instance) {
500
- return typeof value === 'string' ? debugText(value) : instance.jsonText(value, pointer);
501
- }
502
-
503
- function plural(count, words) {
504
- return count === 1 ? words[1] : words[2];
505
- }
506
-
507
- /** An error's text as the crate's validator words it. */
508
- function errorText(error, instance) {
509
- const shown = instance.jsonText(error.data, error.instancePath);
510
- const { keyword, params } = error;
511
- if (hasOwn(LIMIT_PLURALS, keyword)) {
512
- const words = LIMIT_PLURALS[keyword];
513
- return `${shown} ${words[0]} ${params.limit} ${plural(params.limit, words)}`;
514
- }
515
- if (hasOwn(BOUNDS, keyword)) return `${shown} ${BOUNDS[keyword]} ${jsonText(params.limit)}`;
516
- switch (keyword) {
517
- case 'enum': return `${shown} is not one of ${enumOptionsText(Array.isArray(error.schema) ? error.schema : [])}`;
518
- case 'type': {
519
- const types = typesOf(error.schema);
520
- return types.length === 1
521
- ? `${shown} is not of type ${JSON.stringify(types[0])}`
522
- : `${shown} is not of types ${types.map((type) => JSON.stringify(type)).join(', ')}`;
523
- }
524
- case 'const': return `${jsonText(error.schema)} was expected`;
525
- case 'pattern': return `${shown} does not match "${params.pattern}"`;
526
- case 'multipleOf': return `${shown} is not a multiple of ${params.multipleOf}`;
527
- case 'uniqueItems': return `${shown} has non-unique elements`;
528
- case 'not': return `${jsonText(error.schema)} is not allowed for ${shown}`;
529
- case 'anyOf': return `${shown} is not valid under any of the schemas listed in the 'anyOf' keyword`;
530
- case 'oneOf':
531
- return params.passingSchemas
532
- ? `${shown} is valid under more than one of the schemas listed in the 'oneOf' keyword`
533
- : `${shown} is not valid under any of the schemas listed in the 'oneOf' keyword`;
534
- case 'contains': return `None of ${shown} are valid under the given schema`;
535
- case 'false schema': return `False schema does not allow ${shown}`;
536
- case 'propertyNames': return `${JSON.stringify(params.propertyName)} is not valid under the property names schema`;
537
- case 'items':
538
- case 'additionalItems': {
539
- const extra = Array.isArray(error.data) ? error.data.slice(params.limit) : [];
540
- const listed = extra.map((item, index) =>
541
- instance.jsonText(item, `${error.instancePath}/${params.limit + index}`));
542
- return `Additional items are not allowed (${listed.join(', ')} ${extra.length === 1 ? 'was' : 'were'} unexpected)`;
543
- }
544
- case 'unevaluatedItems': {
545
- const extra = Array.isArray(error.data) ? error.data.slice(params.len) : [];
546
- const listed = extra.map((item, index) =>
547
- `'${instance.jsonText(item, `${error.instancePath}/${params.len + index}`)}'`);
548
- return `Unevaluated items are not allowed (${listed.join(', ')} ${extra.length === 1 ? 'was' : 'were'} unexpected)`;
549
- }
550
- default: return error.message;
551
- }
552
- }
553
-
554
- /** An enum's options as the crate's validator lists them: a few, then how many more. */
555
- function enumOptionsText(options) {
556
- const written = options.map((option) => jsonText(option));
557
- if (written.length <= SHOWN_ENUM_OPTIONS) {
558
- if (written.length <= 1) return written.join('');
559
- return `${written.slice(0, -1).join(', ')} or ${written.at(-1)}`;
560
- }
561
- const shown = written.slice(0, SHOWN_ENUM_OPTIONS - 1);
562
- return `${shown.join(', ')} or ${written.length - shown.length} other candidates`;
563
- }
564
-
565
570
  function compareDiagnostics(first, second) {
566
571
  const firstPlace = first.position ? [first.position.line, first.position.column] : [0, 0];
567
572
  const secondPlace = second.position ? [second.position.line, second.position.column] : [0, 0];
@@ -573,14 +578,39 @@ function sameDiagnostic(first, second) {
573
578
  return JSON.stringify(first) === JSON.stringify(second);
574
579
  }
575
580
 
581
+ function pointerSteps(pointer) {
582
+ return pointer.slice(1).split('/')
583
+ .map((step) => step.replaceAll('~1', '/').replaceAll('~0', '~'));
584
+ }
585
+
586
+ /** The value at a JSON pointer of `json`, `null` where it holds none. */
587
+ function valueAt(json, pointer) {
588
+ if (pointer === '') return json;
589
+ let node = json;
590
+ for (const step of pointerSteps(pointer)) {
591
+ if (node === null || typeof node !== 'object' || !hasOwn(node, step)) return null;
592
+ node = node[step];
593
+ }
594
+ return node;
595
+ }
596
+
597
+ /** `json` with the value at a JSON pointer it holds replaced by `value`. */
598
+ function withValueAt(json, pointer, value) {
599
+ if (pointer === '') return value;
600
+ const holder = valueAt(json, pointer.slice(0, pointer.lastIndexOf('/')));
601
+ if (holder !== null && typeof holder === 'object') holder[pointerSteps(pointer).at(-1)] = value;
602
+ return json;
603
+ }
604
+
576
605
  /** The document as JSON, and where each of its values was written. */
577
606
  class InstanceRecord {
578
- constructor(childValuePatterns) {
579
- this.childValuePatterns = childValuePatterns;
607
+ constructor(rules) {
608
+ this.rules = rules;
580
609
  this.positions = new Map();
581
610
  this.valuePositions = new Map();
582
611
  this.numberTexts = new Map();
583
612
  this.unknown = [];
613
+ this.copied = [];
584
614
  }
585
615
 
586
616
  json(value, pointer, keyPosition) {
@@ -604,6 +634,7 @@ class InstanceRecord {
604
634
  if (isObject(json)) this.setChildValue(entry.key, json);
605
635
  object[entry.key] = json;
606
636
  }
637
+ this.mergeBaseKeys(object, pointer);
607
638
  return object;
608
639
  }
609
640
  default:
@@ -614,10 +645,30 @@ class InstanceRecord {
614
645
 
615
646
  /** A key such as `name mock-orders` sets the field `name` of the entry it keys, unless it writes the field. */
616
647
  setChildValue(key, fields) {
617
- const found = childValue(key, this.childValuePatterns);
648
+ const found = this.rules.childValue(key);
618
649
  if (found !== null && !hasOwn(fields, found.field)) fields[found.field] = found.value;
619
650
  }
620
651
 
652
+ /** Merges the content of each merge base key `object` holds into each sibling map. */
653
+ mergeBaseKeys(object, pointer) {
654
+ const { mergeBaseKeys } = this.rules;
655
+ for (const baseKey of mergeBaseKeys.filter((key) => hasOwn(object, key))) {
656
+ const base = object[baseKey];
657
+ for (const [key, written] of Object.entries(object)) {
658
+ if (mergeBaseKeys.includes(key) || !isObject(written)) continue;
659
+ const entryPointer = `${pointer}/${escapePointer(key)}`;
660
+ const merged = mergedOver(base, written, entryPointer, (at) => this.isUnknown(at));
661
+ this.copied.push(...copiedPointers(merged, written, entryPointer));
662
+ object[key] = merged;
663
+ }
664
+ }
665
+ }
666
+
667
+ /** Whether `pointer` names a value a merge base key copied in, or one below it. */
668
+ isCopied(pointer) {
669
+ return this.copied.some((copied) => pointer === copied || pointer.startsWith(`${copied}/`));
670
+ }
671
+
621
672
  /**
622
673
  * Whether an error at `pointer` comes from a value not known when the document
623
674
  * compiles: the value itself, or a check that reads a value below it through a
@@ -658,35 +709,15 @@ class InstanceRecord {
658
709
  }
659
710
  return 'object';
660
711
  }
661
-
662
- jsonText(value, pointer) {
663
- return jsonText(value, (at) => (this.numberTexts.has(at) ? this.numberTexts.get(at) : null), pointer);
664
- }
665
- }
666
-
667
- /**
668
- * The field a key such as `name mock-orders` sets on the entry it keys, and the
669
- * value it sets, `{ field, value }`, by the first of `patterns` with `field` and
670
- * `value` groups that matches; else `null`.
671
- */
672
- function childValue(key, patterns) {
673
- for (const pattern of patterns) {
674
- const match = pattern.exec(key);
675
- const groups = match && match.groups;
676
- if (groups && groups.field !== undefined && groups.value !== undefined) {
677
- return { field: groups.field, value: groups.value };
678
- }
679
- }
680
- return null;
681
712
  }
682
713
 
683
714
  module.exports = {
684
715
  LoadedSchema,
685
716
  SchemaCache,
717
+ ValidationRules,
686
718
  childValue,
687
719
  closestName,
688
720
  nodeJsonText,
689
721
  draftOfSchema,
690
722
  compareText,
691
- debugText,
692
723
  };