yaml-dsl-spec 0.1.2 → 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.
- package/README.md +1 -17
- package/index.js +1 -0
- package/lib/definition-reader.js +2 -1
- package/lib/dsl-definition.js +2 -0
- package/lib/function-table.js +7 -2
- package/lib/json-path.js +45 -29
- package/lib/problem-wording.js +223 -0
- package/lib/requirement-markers.js +128 -0
- package/lib/schema-validation.js +260 -229
- package/lib/validation-rules.js +123 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,20 +9,4 @@ if (read.problems) for (const problem of read.problems) console.error(String(pro
|
|
|
9
9
|
else for (const { definition } of read.dsls) console.log(definition.id);
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|---|---|
|
|
14
|
-
| `readDslDefinitions(file, text)` | `{ dsls }`, each DSL of a `yaml-dsl.yml`, `{ definition, block }`, validated against its version's schema and the checks the schema cannot state; or `{ problems }`, every problem at the field it is about |
|
|
15
|
-
| `Diagnostic`, `Position` | a problem at its place: `message`, `position` and `notes` (`label`, `rule`, `help`) |
|
|
16
|
-
| `yamlReader.parseYaml(file, text, tagKeys)` | `{ node }`, YAML read with each value's place, or `{ problem }` |
|
|
17
|
-
| `Node`, `numberJson` | a YAML value as written, and a number's text as JSON |
|
|
18
|
-
| `dslDefinition.dslDefinitionFromJson(block)` | a DSL block with its defaults, its fields named as the standard names them |
|
|
19
|
-
| `jsonPath` | `PathPattern` and `PathSet`, the standard's JSONPath subset |
|
|
20
|
-
| `resolved` | `Value`, a resolved document's value with its place |
|
|
21
|
-
| `functionTable` | `FunctionTable`, a schema's `x-yaml-dsl-functions` |
|
|
22
|
-
| `schemaValidation` | `SchemaCache` and `LoadedSchema`, a document validated against its schema, each problem at its place |
|
|
23
|
-
| `parseCallKey`, `closestName` | a call written in a key, and the allowed name closest to a misspelt one |
|
|
24
|
-
| `yamlDslSchema(version)`, `yamlDslSchemas` | the JSON Schema of `yaml-dsl.yml`, at a version, or by version |
|
|
25
|
-
| `functionTableSchema(version)`, `functionTableSchemas` | the JSON Schema of a DSL's function table, `x-yaml-dsl-functions`, at a version, or by version |
|
|
26
|
-
| `YAML_DSL_VERSIONS`, `FUNCTION_TABLE_VERSIONS` | the versions the package reads |
|
|
27
|
-
|
|
28
|
-
A version is a minor, such as `1.0`, or a major, such as `1`, which is its newest minor in the package.
|
|
12
|
+
Its API is the core's, designed in the [package design](https://github.com/litebitus/yaml-dsl-spec/blob/main/docs/package-design.md).
|
package/index.js
CHANGED
package/lib/definition-reader.js
CHANGED
|
@@ -13,6 +13,7 @@ const { compilePattern, groupNamesOf } = require('./pattern-dialect');
|
|
|
13
13
|
const { Position } = require('./source-position');
|
|
14
14
|
const { Value } = require('./resolved-value');
|
|
15
15
|
const { SchemaCache, closestName, nodeJsonText } = require('./schema-validation');
|
|
16
|
+
const { ValidationRules } = require('./validation-rules');
|
|
16
17
|
const { parseYaml } = require('./yaml-reader');
|
|
17
18
|
const { YAML_DSL_VERSIONS, yamlDslSchemaFile } = require('./schema-catalog');
|
|
18
19
|
|
|
@@ -37,7 +38,7 @@ function readDslDefinitions(file, text) {
|
|
|
37
38
|
} catch (error) {
|
|
38
39
|
return { problems: [Diagnostic.unplaced(error.message)] };
|
|
39
40
|
}
|
|
40
|
-
const schemaProblems = schema.validate(Value.fromNode(root),
|
|
41
|
+
const schemaProblems = schema.validate(Value.fromNode(root), new ValidationRules());
|
|
41
42
|
if (schemaProblems.length > 0) return { problems: schemaProblems };
|
|
42
43
|
const blocks = (root.get('dsls') && root.get('dsls').items()) || [];
|
|
43
44
|
const problems = [];
|
package/lib/dsl-definition.js
CHANGED
|
@@ -125,7 +125,9 @@ const DSL_FIELDS = {
|
|
|
125
125
|
})), emptyList),
|
|
126
126
|
tag_keys: withDefault(listOf(struct({ tag: required(text), key: required(text) })), emptyList),
|
|
127
127
|
validator: optional(struct({
|
|
128
|
+
merge_base_keys: withDefault(listOf(text), emptyList),
|
|
128
129
|
child_values_from_parent_key: withDefault(listOf(struct({ pattern: required(text) })), emptyList),
|
|
130
|
+
allow_quoted_values: withDefault(listOf(oneOf(['boolean', 'integer', 'number'])), emptyList),
|
|
129
131
|
})),
|
|
130
132
|
};
|
|
131
133
|
|
package/lib/function-table.js
CHANGED
|
@@ -64,7 +64,7 @@ function compareText(first, second) {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
function functionEntry(name, entry) {
|
|
67
|
-
const listed = isObject(entry) ? entry.arguments : undefined;
|
|
67
|
+
const listed = Array.isArray(entry) ? entry : isObject(entry) ? entry.arguments : undefined;
|
|
68
68
|
if (!Array.isArray(listed)) throw new Error(`function \`${name}\` has no arguments list`);
|
|
69
69
|
const functionArguments = listed.map((argument) => {
|
|
70
70
|
const argumentName = isObject(argument) ? argument.name : undefined;
|
|
@@ -75,7 +75,9 @@ function functionEntry(name, entry) {
|
|
|
75
75
|
default: hasOwn(argument, 'default') ? argument.default : null,
|
|
76
76
|
};
|
|
77
77
|
});
|
|
78
|
-
const macroEntry =
|
|
78
|
+
const macroEntry = isObject(entry) && hasOwn(entry, 'macro')
|
|
79
|
+
? macroEntryOf(name, entry.macro)
|
|
80
|
+
: null;
|
|
79
81
|
return { arguments: functionArguments, macroEntry };
|
|
80
82
|
}
|
|
81
83
|
|
|
@@ -101,6 +103,9 @@ function macroEntryOf(name, value) {
|
|
|
101
103
|
for (const role of ROLE_NAMES) {
|
|
102
104
|
if (typeof value[role] === 'string') roles[role] = value[role];
|
|
103
105
|
}
|
|
106
|
+
if (!hasOwn(roles, 'loop_variables') && typeof value.loop_variable === 'string') {
|
|
107
|
+
roles.loop_variables = value.loop_variable;
|
|
108
|
+
}
|
|
104
109
|
return new MacroEntry(kind, written || 'shallow', nameSuffix, roles);
|
|
105
110
|
}
|
|
106
111
|
|
package/lib/json-path.js
CHANGED
|
@@ -11,6 +11,23 @@ function indexStep(index) {
|
|
|
11
11
|
return { index };
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** A `['name']` step's name and the text after the step, or `null`. */
|
|
15
|
+
function quotedName(text) {
|
|
16
|
+
if (!text.startsWith("['")) return null;
|
|
17
|
+
const close = text.indexOf("'", 2);
|
|
18
|
+
if (close <= 2 || text[close + 1] !== ']') return null;
|
|
19
|
+
return { name: text.slice(2, close), remaining: text.slice(close + 2) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A dotted step's name, which runs to the next `.` or `[`, and the text after it, or `null`. */
|
|
23
|
+
function dottedName(text) {
|
|
24
|
+
if (text.startsWith('*')) return null;
|
|
25
|
+
const stepEnd = text.search(/[.[]/);
|
|
26
|
+
const end = stepEnd === -1 ? text.length : stepEnd;
|
|
27
|
+
if (end === 0) return null;
|
|
28
|
+
return { name: text.slice(0, end), remaining: text.slice(end) };
|
|
29
|
+
}
|
|
30
|
+
|
|
14
31
|
/** A parsed path and the keys its wildcard steps do not select or descend into. */
|
|
15
32
|
class PathPattern {
|
|
16
33
|
constructor(selectors, skipKeys) {
|
|
@@ -23,38 +40,37 @@ class PathPattern {
|
|
|
23
40
|
const invalid = () =>
|
|
24
41
|
Diagnostic.unplaced(`\`${text}\` is not a path of the supported JSONPath subset`);
|
|
25
42
|
if (!text.startsWith('$')) throw invalid();
|
|
26
|
-
|
|
43
|
+
let rest = text.slice(1);
|
|
27
44
|
const selectors = [];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
while (rest.length > 0) {
|
|
46
|
+
let step;
|
|
47
|
+
if (rest.startsWith('..')) {
|
|
48
|
+
const after = rest.slice(2);
|
|
49
|
+
if (after === '*') {
|
|
50
|
+
step = { selector: { kind: 'descendant_all' }, remaining: '' };
|
|
51
|
+
} else {
|
|
52
|
+
const named = quotedName(after) || dottedName(after);
|
|
53
|
+
if (!named) throw invalid();
|
|
54
|
+
step = {
|
|
55
|
+
selector: { kind: 'descendant_name', name: named.name },
|
|
56
|
+
remaining: named.remaining,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
} else if (rest.startsWith('.*')) {
|
|
60
|
+
step = { selector: { kind: 'wildcard' }, remaining: rest.slice(2) };
|
|
61
|
+
} else if (rest.startsWith('.')) {
|
|
62
|
+
const named = dottedName(rest.slice(1));
|
|
63
|
+
if (!named) throw invalid();
|
|
64
|
+
step = { selector: { kind: 'name', name: named.name }, remaining: named.remaining };
|
|
65
|
+
} else if (rest.startsWith('[*]')) {
|
|
66
|
+
step = { selector: { kind: 'wildcard' }, remaining: rest.slice(3) };
|
|
46
67
|
} else {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
name = rest.slice(index, end);
|
|
51
|
-
consumed = end - index;
|
|
68
|
+
const named = quotedName(rest);
|
|
69
|
+
if (!named) throw invalid();
|
|
70
|
+
step = { selector: { kind: 'name', name: named.name }, remaining: named.remaining };
|
|
52
71
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
else if (name !== null) selectors.push({ kind: 'descendant_name', name });
|
|
56
|
-
else if (index === rest.length) selectors.push({ kind: 'descendant_all' });
|
|
57
|
-
else throw invalid();
|
|
72
|
+
selectors.push(step.selector);
|
|
73
|
+
rest = step.remaining;
|
|
58
74
|
}
|
|
59
75
|
return new PathPattern(selectors, [...skipKeys]);
|
|
60
76
|
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/* How a schema problem is worded: the field it names and the sentence about it. */
|
|
4
|
+
|
|
5
|
+
const { descriptionText } = require('./requirement-markers');
|
|
6
|
+
|
|
7
|
+
const DEPENDENCY_KEYWORDS = ['dependencies', 'dependentRequired', 'dependentSchemas'];
|
|
8
|
+
const SCHEMA_MAP_KEYWORDS = [
|
|
9
|
+
'$defs',
|
|
10
|
+
'definitions',
|
|
11
|
+
'dependencies',
|
|
12
|
+
'dependentSchemas',
|
|
13
|
+
'patternProperties',
|
|
14
|
+
'properties',
|
|
15
|
+
];
|
|
16
|
+
const DEFINITION_KEYWORDS = ['$defs', 'definitions'];
|
|
17
|
+
const UNSTATED_CONDITION = 'a condition the schema states';
|
|
18
|
+
|
|
19
|
+
const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field);
|
|
20
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
|
|
22
|
+
function unescapePointer(step) {
|
|
23
|
+
return step.replaceAll('~1', '/').replaceAll('~0', '~');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function escapePointer(key) {
|
|
27
|
+
return key.replaceAll('~', '~0').replaceAll('/', '~1');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function schemaNodeAt(root, pointer) {
|
|
31
|
+
if (pointer === '') return root;
|
|
32
|
+
let node = root;
|
|
33
|
+
for (const step of pointer.slice(1).split('/')) {
|
|
34
|
+
const key = unescapePointer(step);
|
|
35
|
+
if (node === null || typeof node !== 'object' || !hasOwn(node, key)) return undefined;
|
|
36
|
+
node = node[key];
|
|
37
|
+
}
|
|
38
|
+
return node;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The field a problem names: the key of the value at `pointer` in `document`, then
|
|
43
|
+
* `[<index>]` for each list index below it; the root is `the document`.
|
|
44
|
+
*/
|
|
45
|
+
function fieldName(document, pointer) {
|
|
46
|
+
if (pointer === '') return 'the document';
|
|
47
|
+
const steps = [];
|
|
48
|
+
let node = document;
|
|
49
|
+
for (const step of pointer.slice(1).split('/')) {
|
|
50
|
+
const key = unescapePointer(step);
|
|
51
|
+
const inList = Array.isArray(node);
|
|
52
|
+
if (inList) node = node[Number(key)];
|
|
53
|
+
else node = isObject(node) && hasOwn(node, key) ? node[key] : undefined;
|
|
54
|
+
steps.push({ key, inList });
|
|
55
|
+
}
|
|
56
|
+
let keyAt = steps.length - 1;
|
|
57
|
+
while (keyAt >= 0 && steps[keyAt].inList) keyAt -= 1;
|
|
58
|
+
const key = keyAt >= 0 ? steps[keyAt].key : '';
|
|
59
|
+
const indexes = steps.slice(keyAt + 1).map((step) => `[${step.key}]`).join('');
|
|
60
|
+
return `\`${key}${indexes}\``;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A JSON number as a report writes it. */
|
|
64
|
+
function numberText(number) {
|
|
65
|
+
return String(number);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The sentence of an alternative none of whose forms holds: the first sentence of
|
|
70
|
+
* the definition holding the alternatives, where they are a described
|
|
71
|
+
* definition's, `#/$defs/<name>` or `#/definitions/<name>`; else `null`.
|
|
72
|
+
*/
|
|
73
|
+
function describedForm(schema, holderPointer) {
|
|
74
|
+
const steps = holderPointer.split('/').slice(1);
|
|
75
|
+
if (steps.length !== 2 || !DEFINITION_KEYWORDS.includes(steps[0])) return null;
|
|
76
|
+
const description = descriptionText(schemaNodeAt(schema, holderPointer));
|
|
77
|
+
if (!description) return null;
|
|
78
|
+
const sentence = description.split(/(?<=\.)\s/u)[0];
|
|
79
|
+
const firstSentence = sentence.endsWith('.') ? sentence.slice(0, -1) : sentence;
|
|
80
|
+
const leadingWord = /^[A-Za-z0-9]*/u.exec(firstSentence)[0];
|
|
81
|
+
if (leadingWord.length >= 2 && /^[A-Z]+$/u.test(leadingWord)) return firstSentence;
|
|
82
|
+
const [first, ...rest] = Array.from(firstSentence);
|
|
83
|
+
return first === undefined ? null : `${first.toLowerCase()}${rest.join('')}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The dependency keyword's pointer and the key it names, `{ keywordPointer, key }`,
|
|
88
|
+
* where `keywordPointer` lies inside a dependency: the key read from the pointer,
|
|
89
|
+
* or, for a dependency listing fields, from the field `holder` misses; else `null`.
|
|
90
|
+
*/
|
|
91
|
+
function dependencyOf(schema, keywordPointer, holder, missingField) {
|
|
92
|
+
const steps = keywordPointer.split('/').slice(1).map(unescapePointer);
|
|
93
|
+
let at = steps.length - 1;
|
|
94
|
+
while (at >= 0 && !(DEPENDENCY_KEYWORDS.includes(steps[at])
|
|
95
|
+
&& (at === 0 || !SCHEMA_MAP_KEYWORDS.includes(steps[at - 1])))) at -= 1;
|
|
96
|
+
if (at < 0) return null;
|
|
97
|
+
const dependencyPointer = steps.slice(0, at + 1)
|
|
98
|
+
.map((step) => `/${escapePointer(step)}`)
|
|
99
|
+
.join('');
|
|
100
|
+
let key = steps[at + 1];
|
|
101
|
+
if (key === undefined) {
|
|
102
|
+
const listed = schemaNodeAt(schema, dependencyPointer);
|
|
103
|
+
if (typeof missingField !== 'string' || !isObject(listed)) return null;
|
|
104
|
+
key = Object.keys(listed).find((candidate) => isObject(holder) && hasOwn(holder, candidate)
|
|
105
|
+
&& Array.isArray(listed[candidate]) && listed[candidate].includes(missingField));
|
|
106
|
+
if (key === undefined) return null;
|
|
107
|
+
}
|
|
108
|
+
if (!isObject(holder) || !hasOwn(holder, key)) return null;
|
|
109
|
+
return { keywordPointer: dependencyPointer, key };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `` `key` needs <what> ``: what each dependency of the node holding
|
|
114
|
+
* `keywordPointer` states for `key`.
|
|
115
|
+
*/
|
|
116
|
+
function dependencyMessage(schema, keywordPointer, key) {
|
|
117
|
+
const holder = schemaNodeAt(schema, keywordPointer.slice(0, keywordPointer.lastIndexOf('/')));
|
|
118
|
+
const statesKey = (keyword) => isObject(holder)
|
|
119
|
+
&& isObject(holder[keyword])
|
|
120
|
+
&& hasOwn(holder[keyword], key);
|
|
121
|
+
const neededTexts = DEPENDENCY_KEYWORDS
|
|
122
|
+
.filter(statesKey)
|
|
123
|
+
.map((keyword) => neededText(holder[keyword][key]));
|
|
124
|
+
return `\`${key}\` needs ${neededTexts.length > 0 ? neededTexts.join('; ') : UNSTATED_CONDITION}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function neededText(needed) {
|
|
128
|
+
if (Array.isArray(needed)) return keyList(needed);
|
|
129
|
+
return consequenceText(needed) || UNSTATED_CONDITION;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function consequenceText(node) {
|
|
133
|
+
if (!isObject(node)) return null;
|
|
134
|
+
const parts = [];
|
|
135
|
+
if (Array.isArray(node.required) && node.required.length > 0) parts.push(keyList(node.required));
|
|
136
|
+
if (isObject(node.not) && Array.isArray(node.not.required) && node.not.required.length > 0) {
|
|
137
|
+
parts.push(`${keyList(node.not.required)} must not be set`);
|
|
138
|
+
}
|
|
139
|
+
const properties = isObject(node.properties) ? node.properties : {};
|
|
140
|
+
for (const [field, fieldSchema] of Object.entries(properties)) {
|
|
141
|
+
if (isObject(fieldSchema) && typeof fieldSchema.$ref === 'string') {
|
|
142
|
+
parts.push(`\`${field}\` is a \`${referenceName(fieldSchema.$ref)}\``);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const part of Array.isArray(node.allOf) ? node.allOf : []) {
|
|
146
|
+
const partText = consequenceText(part);
|
|
147
|
+
if (partText) parts.push(partText);
|
|
148
|
+
}
|
|
149
|
+
const alternatives = ['anyOf', 'oneOf']
|
|
150
|
+
.flatMap((keyword) => (Array.isArray(node[keyword]) ? node[keyword] : []));
|
|
151
|
+
if (alternatives.length > 0) {
|
|
152
|
+
parts.push(alternatives
|
|
153
|
+
.map((alternative) => consequenceText(alternative) || UNSTATED_CONDITION)
|
|
154
|
+
.join(', or '));
|
|
155
|
+
}
|
|
156
|
+
if (parts.length === 0 && typeof node.$ref === 'string') {
|
|
157
|
+
parts.push(`a \`${referenceName(node.$ref)}\``);
|
|
158
|
+
}
|
|
159
|
+
return parts.length > 0 ? parts.join('; ') : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function referenceName(reference) {
|
|
163
|
+
return reference.slice(reference.lastIndexOf('/') + 1).replace(/^#/u, '');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function keyList(keys) {
|
|
167
|
+
const spans = keys.map((key) => `\`${typeof key === 'string' ? key : JSON.stringify(key)}\``);
|
|
168
|
+
return spans.length > 1 ? `${spans.slice(0, -1).join(', ')} and ${spans.at(-1)}` : spans.join('');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The indexes of the duplicate items a list reports, later first, `[later, earlier]`:
|
|
173
|
+
* from the last item back, the first one equal to an item after it, and the
|
|
174
|
+
* nearest such item; `null` for a list without duplicates.
|
|
175
|
+
*/
|
|
176
|
+
function duplicateItems(items) {
|
|
177
|
+
for (let earlier = items.length - 1; earlier >= 0; earlier -= 1) {
|
|
178
|
+
for (let later = earlier + 1; later < items.length; later += 1) {
|
|
179
|
+
if (jsonEqual(items[earlier], items[later])) return [later, earlier];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function jsonEqual(first, second) {
|
|
186
|
+
if (Array.isArray(first) && Array.isArray(second)) {
|
|
187
|
+
return first.length === second.length
|
|
188
|
+
&& first.every((item, index) => jsonEqual(item, second[index]));
|
|
189
|
+
}
|
|
190
|
+
if (isObject(first) && isObject(second)) {
|
|
191
|
+
const keys = Object.keys(first);
|
|
192
|
+
return keys.length === Object.keys(second).length
|
|
193
|
+
&& keys.every((key) => hasOwn(second, key) && jsonEqual(first[key], second[key]));
|
|
194
|
+
}
|
|
195
|
+
return first === second;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `must contain at least <min> valid item(s)`, with `and no more than <max>`
|
|
200
|
+
* where `holder` bounds it.
|
|
201
|
+
*/
|
|
202
|
+
function containsMessage(holder) {
|
|
203
|
+
const bound = (keyword) => (isObject(holder) && typeof holder[keyword] === 'number'
|
|
204
|
+
? numberText(holder[keyword])
|
|
205
|
+
: null);
|
|
206
|
+
const minimum = bound('minContains') || '1';
|
|
207
|
+
const maximum = bound('maxContains');
|
|
208
|
+
return maximum === null
|
|
209
|
+
? `must contain at least ${minimum} valid item(s)`
|
|
210
|
+
: `must contain at least ${minimum} and no more than ${maximum} valid item(s)`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
fieldName,
|
|
215
|
+
numberText,
|
|
216
|
+
describedForm,
|
|
217
|
+
dependencyOf,
|
|
218
|
+
dependencyMessage,
|
|
219
|
+
duplicateItems,
|
|
220
|
+
containsMessage,
|
|
221
|
+
schemaNodeAt,
|
|
222
|
+
escapePointer,
|
|
223
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Requirement markers: a field's `description` opening with `[required]`,
|
|
5
|
+
* `[~required]` or `[optional]` decides whether the field is required, over its
|
|
6
|
+
* parent's `required` array.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const REQUIRED_MARKER = '[required]';
|
|
10
|
+
const REQUIREMENT_MARKERS = [REQUIRED_MARKER, '[~required]', '[optional]'];
|
|
11
|
+
const CONDITION_KEYWORDS = new Set(['if', 'not']);
|
|
12
|
+
const FORM_SELECTING_KEYWORDS = new Set(['anyOf', 'oneOf']);
|
|
13
|
+
const SAME_INSTANCE_SCHEMA_KEYWORDS = new Set(['else', 'if', 'not', 'then']);
|
|
14
|
+
const SAME_INSTANCE_LIST_KEYWORDS = new Set(['allOf', 'anyOf', 'oneOf']);
|
|
15
|
+
const SCHEMA_MAP_KEYWORDS = new Set([
|
|
16
|
+
'$defs',
|
|
17
|
+
'definitions',
|
|
18
|
+
'dependentSchemas',
|
|
19
|
+
'patternProperties',
|
|
20
|
+
'properties',
|
|
21
|
+
]);
|
|
22
|
+
const DRAFT_KEYWORDS = {
|
|
23
|
+
'draft-07': {
|
|
24
|
+
childSchema: new Set([
|
|
25
|
+
'additionalItems',
|
|
26
|
+
'additionalProperties',
|
|
27
|
+
'contains',
|
|
28
|
+
'items',
|
|
29
|
+
'propertyNames',
|
|
30
|
+
]),
|
|
31
|
+
schemaMap: new Set([
|
|
32
|
+
'$defs',
|
|
33
|
+
'definitions',
|
|
34
|
+
'dependencies',
|
|
35
|
+
'patternProperties',
|
|
36
|
+
'properties',
|
|
37
|
+
]),
|
|
38
|
+
},
|
|
39
|
+
'draft 2019-09': {
|
|
40
|
+
childSchema: new Set([
|
|
41
|
+
'additionalItems',
|
|
42
|
+
'additionalProperties',
|
|
43
|
+
'contains',
|
|
44
|
+
'contentSchema',
|
|
45
|
+
'items',
|
|
46
|
+
'propertyNames',
|
|
47
|
+
'unevaluatedItems',
|
|
48
|
+
'unevaluatedProperties',
|
|
49
|
+
]),
|
|
50
|
+
schemaMap: SCHEMA_MAP_KEYWORDS,
|
|
51
|
+
},
|
|
52
|
+
'draft 2020-12': {
|
|
53
|
+
childSchema: new Set([
|
|
54
|
+
'additionalProperties',
|
|
55
|
+
'contains',
|
|
56
|
+
'contentSchema',
|
|
57
|
+
'items',
|
|
58
|
+
'prefixItems',
|
|
59
|
+
'propertyNames',
|
|
60
|
+
'unevaluatedItems',
|
|
61
|
+
'unevaluatedProperties',
|
|
62
|
+
]),
|
|
63
|
+
schemaMap: SCHEMA_MAP_KEYWORDS,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
68
|
+
|
|
69
|
+
/** The marker a schema node's `description` opens with, or `null`. */
|
|
70
|
+
function requirementMarker(node) {
|
|
71
|
+
if (!isObject(node) || typeof node.description !== 'string') return null;
|
|
72
|
+
const description = node.description.trimStart();
|
|
73
|
+
return REQUIREMENT_MARKERS.find((marker) => description.startsWith(marker)) || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A node's description without the marker it opens with, or `null`. */
|
|
77
|
+
function descriptionText(node) {
|
|
78
|
+
if (!isObject(node) || typeof node.description !== 'string') return null;
|
|
79
|
+
const marker = requirementMarker(node);
|
|
80
|
+
const text = marker ? node.description.trimStart().slice(marker.length) : node.description;
|
|
81
|
+
return text.trim() || null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `schema`, read in the draft named `draftName`, with each `required` array decided by markers. */
|
|
85
|
+
function markedSchema(schema, draftName) {
|
|
86
|
+
return markedNode(schema, null, DRAFT_KEYWORDS[draftName]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function markersOfFields(properties) {
|
|
90
|
+
const markers = new Map();
|
|
91
|
+
for (const [key, field] of Object.entries(properties)) {
|
|
92
|
+
const marker = requirementMarker(field);
|
|
93
|
+
if (marker) markers.set(key, marker);
|
|
94
|
+
}
|
|
95
|
+
return markers;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function markedNode(node, inheritedMarkers, keywords) {
|
|
99
|
+
if (!isObject(node)) return node;
|
|
100
|
+
const ownMarkers = isObject(node.properties) ? markersOfFields(node.properties) : null;
|
|
101
|
+
const markers = ownMarkers || inheritedMarkers;
|
|
102
|
+
const marked = {};
|
|
103
|
+
const markedChild = (child) => markedNode(child, null, keywords);
|
|
104
|
+
for (const [keyword, value] of Object.entries(node)) {
|
|
105
|
+
if (keyword === 'required') continue;
|
|
106
|
+
if (CONDITION_KEYWORDS.has(keyword)) marked[keyword] = value;
|
|
107
|
+
else if (SAME_INSTANCE_SCHEMA_KEYWORDS.has(keyword)) {
|
|
108
|
+
marked[keyword] = markedNode(value, markers, keywords);
|
|
109
|
+
} else if (SAME_INSTANCE_LIST_KEYWORDS.has(keyword) && Array.isArray(value)) {
|
|
110
|
+
const branchMarkers = FORM_SELECTING_KEYWORDS.has(keyword) ? null : markers;
|
|
111
|
+
marked[keyword] = value.map((branch) => markedNode(branch, branchMarkers, keywords));
|
|
112
|
+
} else if (keywords.childSchema.has(keyword)) {
|
|
113
|
+
marked[keyword] = Array.isArray(value) ? value.map(markedChild) : markedChild(value);
|
|
114
|
+
} else if (keywords.schemaMap.has(keyword) && isObject(value)) {
|
|
115
|
+
marked[keyword] = Object.fromEntries(Object.entries(value)
|
|
116
|
+
.map(([name, schema]) => [name, markedChild(schema)]));
|
|
117
|
+
} else {
|
|
118
|
+
marked[keyword] = value;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const listed = Array.isArray(node.required) ? node.required : [];
|
|
122
|
+
const required = listed.filter((key) => !markers || !markers.has(key));
|
|
123
|
+
for (const [key, marker] of ownMarkers || []) if (marker === REQUIRED_MARKER) required.push(key);
|
|
124
|
+
if (required.length > 0) marked.required = required;
|
|
125
|
+
return marked;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { requirementMarker, descriptionText, markedSchema };
|
package/lib/schema-validation.js
CHANGED
|
@@ -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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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'
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
134
|
-
* compiles, such as a call or a reference to a declaration,
|
|
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,
|
|
137
|
-
const instance = new InstanceRecord(
|
|
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.
|
|
161
|
+
const errors = reportedErrors(this.validator.errors || [], this.marked)
|
|
141
162
|
.filter((error) => !instance.readsUnknown(error.instancePath, error.keyword));
|
|
142
|
-
const
|
|
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
|
-
*
|
|
150
|
-
*
|
|
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
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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(
|
|
174
|
-
.withRule(
|
|
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
|
|
189
|
-
return [
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
200
|
-
return [
|
|
201
|
-
|
|
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
|
|
205
|
-
];
|
|
300
|
+
.withHelp(typeof error.data === 'string' ? suggestion(error.data, allowed) : null),
|
|
301
|
+
}];
|
|
206
302
|
}
|
|
207
303
|
if (error.keyword === 'type') {
|
|
208
|
-
const expected =
|
|
209
|
-
return [
|
|
210
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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(
|
|
579
|
-
this.
|
|
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
|
|
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
|
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* How a document is read before it is validated: the DSL definition's
|
|
5
|
+
* `validator` block, ready to apply.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { compilePattern } = require('./pattern-dialect');
|
|
9
|
+
const { numberJson } = require('./yaml-node');
|
|
10
|
+
|
|
11
|
+
const INTEGER_TEXT = /^-?(0|[1-9][0-9]*)$/;
|
|
12
|
+
const NUMBER_TEXT = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/;
|
|
13
|
+
const QUOTED_VALUE_READERS = {
|
|
14
|
+
boolean: (text) => ({ true: true, false: false })[text],
|
|
15
|
+
integer: (text) => (INTEGER_TEXT.test(text) ? numberJson(text) : undefined),
|
|
16
|
+
number: (text) => (NUMBER_TEXT.test(text) ? numberJson(text) : undefined),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field);
|
|
20
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
|
|
22
|
+
/** A DSL's `validator` block with its patterns compiled. */
|
|
23
|
+
class ValidationRules {
|
|
24
|
+
constructor({ mergeBaseKeys = [], childValuePatterns = [], quotedValueTypes = [] } = {}) {
|
|
25
|
+
this.mergeBaseKeys = mergeBaseKeys;
|
|
26
|
+
this.childValuePatterns = childValuePatterns;
|
|
27
|
+
this.quotedValueTypes = quotedValueTypes;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The rules a DSL's `validator` block states, or throws a pattern that does not compile. */
|
|
31
|
+
static fromValidator(validator) {
|
|
32
|
+
if (!validator) return new ValidationRules();
|
|
33
|
+
const childValuePatterns = validator.child_values_from_parent_key.map((rule) => {
|
|
34
|
+
const compiled = compilePattern(rule.pattern);
|
|
35
|
+
if (compiled === null) throw new Error(`\`${rule.pattern}\` is not a pattern`);
|
|
36
|
+
return compiled;
|
|
37
|
+
});
|
|
38
|
+
return new ValidationRules({
|
|
39
|
+
mergeBaseKeys: [...validator.merge_base_keys],
|
|
40
|
+
childValuePatterns,
|
|
41
|
+
quotedValueTypes: [...validator.allow_quoted_values],
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The field a key such as `name mock-orders` sets, `{ field, value }`; else `null`. */
|
|
46
|
+
childValue(key) {
|
|
47
|
+
return childValue(key, this.childValuePatterns);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The value a quoted `text` is validated as where the schema expects one of
|
|
52
|
+
* `expectedTypes`, in their order, `{ value }`; `null` where it reads as none.
|
|
53
|
+
*/
|
|
54
|
+
quotedValue(text, expectedTypes) {
|
|
55
|
+
for (const expectedType of expectedTypes) {
|
|
56
|
+
const allowed = this.quotedValueTypes.includes(expectedType)
|
|
57
|
+
&& hasOwn(QUOTED_VALUE_READERS, expectedType);
|
|
58
|
+
if (!allowed) continue;
|
|
59
|
+
const value = QUOTED_VALUE_READERS[expectedType](text);
|
|
60
|
+
if (value !== undefined) return { value };
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The field a key such as `name mock-orders` sets on the entry it keys, and the
|
|
68
|
+
* value it sets, `{ field, value }`, by the first of `patterns` with `field` and
|
|
69
|
+
* `value` groups that matches; else `null`.
|
|
70
|
+
*/
|
|
71
|
+
function childValue(key, patterns) {
|
|
72
|
+
for (const pattern of patterns) {
|
|
73
|
+
const match = pattern.exec(key);
|
|
74
|
+
const groups = match && match.groups;
|
|
75
|
+
if (groups && groups.field !== undefined && groups.value !== undefined) {
|
|
76
|
+
return { field: groups.field, value: groups.value };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `overlay` merged over `base` as an overlay merges over its common layer: maps
|
|
84
|
+
* merge key by key, an empty map or any other value replaces, and a null deletes
|
|
85
|
+
* the key unless `holdsUnknown` says the null stands for a value not yet known.
|
|
86
|
+
*/
|
|
87
|
+
function mergedOver(base, overlay, overlayPointer, holdsUnknown) {
|
|
88
|
+
const merged = isObject(base) ? structuredClone(base) : {};
|
|
89
|
+
mergeInto(merged, overlay, overlayPointer, holdsUnknown);
|
|
90
|
+
return merged;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function mergeInto(target, overlay, overlayPointer, holdsUnknown) {
|
|
94
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
95
|
+
const childPointer = `${overlayPointer}/${escapePointer(key)}`;
|
|
96
|
+
if (value === null && !holdsUnknown(childPointer)) {
|
|
97
|
+
delete target[key];
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (isObject(target[key]) && isObject(value) && Object.keys(value).length > 0) {
|
|
101
|
+
mergeInto(target[key], value, childPointer, holdsUnknown);
|
|
102
|
+
} else {
|
|
103
|
+
target[key] = structuredClone(value);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The pointers of `merged` that `written` does not hold: the values a merge copied in. */
|
|
109
|
+
function copiedPointers(merged, written, pointer) {
|
|
110
|
+
if (!isObject(merged) || !isObject(written)) return [];
|
|
111
|
+
return Object.entries(merged).flatMap(([key, value]) => {
|
|
112
|
+
const childPointer = `${pointer}/${escapePointer(key)}`;
|
|
113
|
+
return hasOwn(written, key)
|
|
114
|
+
? copiedPointers(value, written[key], childPointer)
|
|
115
|
+
: [childPointer];
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function escapePointer(key) {
|
|
120
|
+
return key.replaceAll('~', '~0').replaceAll('/', '~1');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { ValidationRules, childValue, mergedOver, copiedPointers, escapePointer };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yaml-dsl-spec",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "The YAML DSL standard: the JSON Schemas of yaml-dsl.yml and of a DSL's function table, and a reader returning a yaml-dsl.yml's DSL definitions validated.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|