@crewhaus/tool-schema 0.7.0 → 0.8.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 +201 -0
- package/NOTICE +9 -0
- package/README.md +50 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +92 -13
- package/dist/lib/assert.d.ts +36 -1
- package/dist/lib/assert.js +95 -14
- package/dist/lib/compare.js +30 -9
- package/dist/lib/formats.d.ts +13 -0
- package/dist/lib/formats.js +14 -1
- package/dist/lib/jsonschema.d.ts +170 -0
- package/dist/lib/jsonschema.js +530 -68
- package/dist/lib/records.d.ts +16 -0
- package/dist/lib/records.js +17 -3
- package/dist/lib/regex-answers.d.ts +142 -0
- package/dist/lib/regex-answers.js +289 -0
- package/dist/lib/schemadiff.d.ts +19 -0
- package/dist/lib/schemadiff.js +98 -17
- package/dist/lib/shape-index.d.ts +22 -0
- package/dist/lib/shape-index.js +157 -0
- package/dist/lib/summarize.js +28 -9
- package/dist/lib/value.d.ts +16 -2
- package/dist/lib/value.js +143 -13
- package/package.json +11 -6
package/dist/lib/assert.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* only compares.
|
|
9
9
|
*/
|
|
10
10
|
import { checkFormat, isFormatName } from "./formats";
|
|
11
|
+
import { testPatternSync } from "./regex-answers";
|
|
11
12
|
import { deepEqual, getPath, isPlainObject, matchesType, preview, typeOf, } from "./value";
|
|
12
13
|
/** Every comparison {@link runChecks} understands. */
|
|
13
14
|
export const ASSERT_OPS = [
|
|
@@ -77,22 +78,42 @@ function compareLength(actual, expected, ok, describe) {
|
|
|
77
78
|
? { ok: true, reason: "" }
|
|
78
79
|
: { ok: false, reason: describe(length, expected) };
|
|
79
80
|
}
|
|
80
|
-
|
|
81
|
+
/**
|
|
82
|
+
* `matches` / `notMatches`. The pattern is the caller's, so it is never run
|
|
83
|
+
* here: the answer comes from `regex` (resolved in the worker by the tool
|
|
84
|
+
* that called), or, for a caller with none, from the bounded fallback on
|
|
85
|
+
* this thread. A pattern the screen refuses fails the check, as an invalid
|
|
86
|
+
* one always has; one that could not be run to an answer is undetermined,
|
|
87
|
+
* never "does not match" (and so never a pass for `notMatches`).
|
|
88
|
+
*/
|
|
89
|
+
function matchRegex(actual, expected, flags, want, regex) {
|
|
81
90
|
if (typeof actual !== "string") {
|
|
82
91
|
return { ok: false, reason: `matches needs a string, found ${typeOf(actual)}` };
|
|
83
92
|
}
|
|
84
93
|
if (typeof expected !== "string") {
|
|
85
94
|
return { ok: false, reason: "the check's expected value must be a regex source string" };
|
|
86
95
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
const answer = regex === undefined
|
|
97
|
+
? testPatternSync(expected, flags, actual)
|
|
98
|
+
: regex.lookup(expected, flags, actual);
|
|
99
|
+
if (answer === undefined) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
undetermined: true,
|
|
103
|
+
reason: `could not evaluate /${expected}/${flags}: its answer has not been worked out yet`,
|
|
104
|
+
};
|
|
90
105
|
}
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
if (typeof answer !== "boolean") {
|
|
107
|
+
if ("refused" in answer) {
|
|
108
|
+
return { ok: false, reason: `invalid regex /${expected}/${flags}: ${answer.refused}` };
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
undetermined: true,
|
|
113
|
+
reason: `could not evaluate /${expected}/${flags}: ${answer.undetermined}`,
|
|
114
|
+
};
|
|
93
115
|
}
|
|
94
|
-
|
|
95
|
-
if (matched === want)
|
|
116
|
+
if (answer === want)
|
|
96
117
|
return { ok: true, reason: "" };
|
|
97
118
|
return {
|
|
98
119
|
ok: false,
|
|
@@ -101,7 +122,37 @@ function matchRegex(actual, expected, flags, want) {
|
|
|
101
122
|
: `${preview(actual, 60)} matches /${expected}/${flags} but must not`,
|
|
102
123
|
};
|
|
103
124
|
}
|
|
104
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Ask `answers` about every pattern `checks` will test against `value`, so
|
|
127
|
+
* one {@link RegexAnswers.resolve} settles them all before
|
|
128
|
+
* {@link runChecks} reads them. A caller evaluating several check lists
|
|
129
|
+
* against one value (a table's rows, a router's arms) asks for every list
|
|
130
|
+
* first, then resolves once.
|
|
131
|
+
*/
|
|
132
|
+
export function askCheckPatterns(value, checks, answers) {
|
|
133
|
+
for (const check of checks) {
|
|
134
|
+
if (check.op !== "matches" && check.op !== "notMatches")
|
|
135
|
+
continue;
|
|
136
|
+
if (typeof check.expected !== "string")
|
|
137
|
+
continue;
|
|
138
|
+
let actual = value;
|
|
139
|
+
const path = check.path ?? "";
|
|
140
|
+
if (path !== "") {
|
|
141
|
+
try {
|
|
142
|
+
const resolution = getPath(value, path);
|
|
143
|
+
if (!resolution.found)
|
|
144
|
+
continue;
|
|
145
|
+
actual = resolution.value;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (typeof actual === "string")
|
|
152
|
+
answers.lookup(check.expected, check.flags ?? "", actual);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function evaluate(check, actual, found, regex) {
|
|
105
156
|
const expected = check.expected;
|
|
106
157
|
switch (check.op) {
|
|
107
158
|
case "exists":
|
|
@@ -156,9 +207,9 @@ function evaluate(check, actual, found) {
|
|
|
156
207
|
};
|
|
157
208
|
}
|
|
158
209
|
case "matches":
|
|
159
|
-
return matchRegex(actual, expected, check.flags ?? "", true);
|
|
210
|
+
return matchRegex(actual, expected, check.flags ?? "", true, regex);
|
|
160
211
|
case "notMatches":
|
|
161
|
-
return matchRegex(actual, expected, check.flags ?? "", false);
|
|
212
|
+
return matchRegex(actual, expected, check.flags ?? "", false, regex);
|
|
162
213
|
case "startsWith":
|
|
163
214
|
case "endsWith": {
|
|
164
215
|
if (typeof actual !== "string" || typeof expected !== "string") {
|
|
@@ -256,8 +307,14 @@ function evaluate(check, actual, found) {
|
|
|
256
307
|
* A malformed check (a bad regex, a non-numeric bound) fails that check with
|
|
257
308
|
* an explanatory reason rather than throwing, so one typo cannot take down a
|
|
258
309
|
* whole gate.
|
|
310
|
+
*
|
|
311
|
+
* `matches` and `notMatches` read their answers from `options.regex`, which
|
|
312
|
+
* the calling tool resolved in the worker (see {@link askCheckPatterns});
|
|
313
|
+
* without it they fall back to a bounded run on this thread. A check whose
|
|
314
|
+
* pattern could not be run to an answer is not ok and carries
|
|
315
|
+
* `undetermined: true`; its reason says so whatever `message` the check set.
|
|
259
316
|
*/
|
|
260
|
-
export function runChecks(value, checks) {
|
|
317
|
+
export function runChecks(value, checks, options = {}) {
|
|
261
318
|
const results = checks.map((check, index) => {
|
|
262
319
|
const path = check.path ?? "";
|
|
263
320
|
let found = true;
|
|
@@ -291,7 +348,7 @@ export function runChecks(value, checks) {
|
|
|
291
348
|
reason: check.message ?? `the path "${path}" does not resolve`,
|
|
292
349
|
};
|
|
293
350
|
}
|
|
294
|
-
const verdict = evaluate(check, actual, found);
|
|
351
|
+
const verdict = evaluate(check, actual, found, options.regex);
|
|
295
352
|
return {
|
|
296
353
|
index,
|
|
297
354
|
path,
|
|
@@ -299,7 +356,12 @@ export function runChecks(value, checks) {
|
|
|
299
356
|
ok: verdict.ok,
|
|
300
357
|
actual: found ? preview(actual, 80) : null,
|
|
301
358
|
expected: check.expected === undefined ? null : preview(check.expected, 80),
|
|
302
|
-
reason: verdict.ok
|
|
359
|
+
reason: verdict.ok
|
|
360
|
+
? ""
|
|
361
|
+
: verdict.undetermined === true
|
|
362
|
+
? verdict.reason
|
|
363
|
+
: (check.message ?? verdict.reason),
|
|
364
|
+
...(verdict.undetermined === true ? { undetermined: true } : {}),
|
|
303
365
|
};
|
|
304
366
|
});
|
|
305
367
|
const failures = results.filter((r) => !r.ok);
|
|
@@ -307,10 +369,29 @@ export function runChecks(value, checks) {
|
|
|
307
369
|
ok: failures.length === 0,
|
|
308
370
|
passed: results.length - failures.length,
|
|
309
371
|
failed: failures.length,
|
|
372
|
+
undetermined: failures.filter((r) => r.undetermined === true).length,
|
|
310
373
|
results,
|
|
311
374
|
failures,
|
|
312
375
|
};
|
|
313
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* What a check list says as a whole, three ways. Under `all`, a check that
|
|
379
|
+
* definitely failed decides it (`fail`); otherwise an undetermined one leaves
|
|
380
|
+
* it `undetermined`. Under `any`, a check that passed decides it (`pass`);
|
|
381
|
+
* otherwise an undetermined one leaves it `undetermined`. A router or a
|
|
382
|
+
* scorer that reads `report.ok` alone would take an undetermined check for a
|
|
383
|
+
* miss and fall through to the next arm or the default.
|
|
384
|
+
*/
|
|
385
|
+
export function checksVerdict(report, mode = "all") {
|
|
386
|
+
if (mode === "all") {
|
|
387
|
+
if (report.ok)
|
|
388
|
+
return "pass";
|
|
389
|
+
return report.failed > report.undetermined ? "fail" : "undetermined";
|
|
390
|
+
}
|
|
391
|
+
if (report.passed > 0)
|
|
392
|
+
return "pass";
|
|
393
|
+
return report.undetermined > 0 ? "undetermined" : "fail";
|
|
394
|
+
}
|
|
314
395
|
/**
|
|
315
396
|
* Which of `paths` are present and non-empty in `value`, and which are not.
|
|
316
397
|
* "Present" means the path resolves; "non-empty" applies
|
package/dist/lib/compare.js
CHANGED
|
@@ -51,6 +51,27 @@ function matchPattern(segments, pattern) {
|
|
|
51
51
|
}
|
|
52
52
|
return s === segments.length;
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Note a path `ignorePaths` matched, once. A set answers "listed already?":
|
|
56
|
+
* searching the list made a value with many ignored paths quadratic in them.
|
|
57
|
+
*/
|
|
58
|
+
function noteIgnored(ctx, path) {
|
|
59
|
+
if (ctx.ignoredSeen.has(path))
|
|
60
|
+
return;
|
|
61
|
+
ctx.ignoredSeen.add(path);
|
|
62
|
+
ctx.ignored.push(path);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Note an array compared positionally because it was too long, once. As for
|
|
66
|
+
* `noteIgnored`, a set answers "listed already?": searching the list made a
|
|
67
|
+
* value with many long arrays quadratic in them.
|
|
68
|
+
*/
|
|
69
|
+
function noteOrderedFallback(ctx, path) {
|
|
70
|
+
if (ctx.orderedFallbacksSeen.has(path))
|
|
71
|
+
return;
|
|
72
|
+
ctx.orderedFallbacksSeen.add(path);
|
|
73
|
+
ctx.orderedFallbacks.push(path);
|
|
74
|
+
}
|
|
54
75
|
function record(ctx, diff) {
|
|
55
76
|
if (ctx.differences.length >= ctx.opts.maxDifferences) {
|
|
56
77
|
ctx.truncated = true;
|
|
@@ -73,15 +94,16 @@ function equalUnder(actual, expected, ctx, path) {
|
|
|
73
94
|
differences: [],
|
|
74
95
|
truncated: false,
|
|
75
96
|
ignored: [],
|
|
97
|
+
ignoredSeen: new Set(),
|
|
76
98
|
orderedFallbacks: [],
|
|
99
|
+
orderedFallbacksSeen: new Set(),
|
|
77
100
|
};
|
|
78
101
|
walk(actual, expected, path, probe);
|
|
79
102
|
return probe.differences.length === 0;
|
|
80
103
|
}
|
|
81
104
|
function walk(actual, expected, path, ctx) {
|
|
82
105
|
if (matchesAnyPattern(path, ctx.patterns)) {
|
|
83
|
-
|
|
84
|
-
ctx.ignored.push(path);
|
|
106
|
+
noteIgnored(ctx, path);
|
|
85
107
|
return;
|
|
86
108
|
}
|
|
87
109
|
const expectedType = typeOf(expected);
|
|
@@ -132,9 +154,8 @@ function walk(actual, expected, path, ctx) {
|
|
|
132
154
|
}
|
|
133
155
|
function compareArrays(actual, expected, path, ctx) {
|
|
134
156
|
const tooLong = actual.length > UNORDERED_ARRAY_LIMIT || expected.length > UNORDERED_ARRAY_LIMIT;
|
|
135
|
-
if (ctx.opts.ignoreArrayOrder && tooLong
|
|
136
|
-
ctx
|
|
137
|
-
}
|
|
157
|
+
if (ctx.opts.ignoreArrayOrder && tooLong)
|
|
158
|
+
noteOrderedFallback(ctx, path);
|
|
138
159
|
if (ctx.opts.ignoreArrayOrder && !tooLong) {
|
|
139
160
|
compareArraysUnordered(actual, expected, path, ctx);
|
|
140
161
|
return;
|
|
@@ -208,8 +229,7 @@ function compareObjects(actual, expected, path, ctx) {
|
|
|
208
229
|
const childPath = joinPointer(path, key);
|
|
209
230
|
if (!Object.hasOwn(actual, key)) {
|
|
210
231
|
if (matchesAnyPattern(childPath, ctx.patterns)) {
|
|
211
|
-
|
|
212
|
-
ctx.ignored.push(childPath);
|
|
232
|
+
noteIgnored(ctx, childPath);
|
|
213
233
|
continue;
|
|
214
234
|
}
|
|
215
235
|
record(ctx, {
|
|
@@ -230,8 +250,7 @@ function compareObjects(actual, expected, path, ctx) {
|
|
|
230
250
|
continue;
|
|
231
251
|
const childPath = joinPointer(path, key);
|
|
232
252
|
if (matchesAnyPattern(childPath, ctx.patterns)) {
|
|
233
|
-
|
|
234
|
-
ctx.ignored.push(childPath);
|
|
253
|
+
noteIgnored(ctx, childPath);
|
|
235
254
|
continue;
|
|
236
255
|
}
|
|
237
256
|
record(ctx, {
|
|
@@ -260,7 +279,9 @@ export function compareValues(actual, expected, options = {}) {
|
|
|
260
279
|
differences: [],
|
|
261
280
|
truncated: false,
|
|
262
281
|
ignored: [],
|
|
282
|
+
ignoredSeen: new Set(),
|
|
263
283
|
orderedFallbacks: [],
|
|
284
|
+
orderedFallbacksSeen: new Set(),
|
|
264
285
|
};
|
|
265
286
|
walk(actual, expected, "", ctx);
|
|
266
287
|
return {
|
package/dist/lib/formats.d.ts
CHANGED
|
@@ -65,6 +65,19 @@ export declare function isDate(value: string): FormatResult;
|
|
|
65
65
|
* seconds. An offset with minutes over 59 is rejected.
|
|
66
66
|
*/
|
|
67
67
|
export declare function isTime(value: string): FormatResult;
|
|
68
|
+
/**
|
|
69
|
+
* A date-time split at its separator: one character, then everything up to
|
|
70
|
+
* the first `T` or `t` after it, then a non-empty rest. `.` and the class
|
|
71
|
+
* both stop at a line break (`\n`, `\r`, U+2028, U+2029), so a value that
|
|
72
|
+
* holds one never splits.
|
|
73
|
+
*
|
|
74
|
+
* This was `/^(.+?)[Tt](.+)$/`, which splits the same way. Its lazy group
|
|
75
|
+
* tried every `T` of a long first line and ran `(.+)` from each to the line
|
|
76
|
+
* break that `$` cannot pass: quadratic in the line, on ordinary prose. A
|
|
77
|
+
* failed try now gives back only characters that cannot be a `T`, one step
|
|
78
|
+
* each.
|
|
79
|
+
*/
|
|
80
|
+
export declare const DATE_TIME_SPLIT: RegExp;
|
|
68
81
|
/**
|
|
69
82
|
* RFC 3339 `date-time`: a date, a `T` (or lowercase `t`) separator, and a
|
|
70
83
|
* time with an offset. A space separator, as SQL writes it, is rejected —
|
package/dist/lib/formats.js
CHANGED
|
@@ -212,13 +212,26 @@ export function isTime(value) {
|
|
|
212
212
|
}
|
|
213
213
|
return OK;
|
|
214
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* A date-time split at its separator: one character, then everything up to
|
|
217
|
+
* the first `T` or `t` after it, then a non-empty rest. `.` and the class
|
|
218
|
+
* both stop at a line break (`\n`, `\r`, U+2028, U+2029), so a value that
|
|
219
|
+
* holds one never splits.
|
|
220
|
+
*
|
|
221
|
+
* This was `/^(.+?)[Tt](.+)$/`, which splits the same way. Its lazy group
|
|
222
|
+
* tried every `T` of a long first line and ran `(.+)` from each to the line
|
|
223
|
+
* break that `$` cannot pass: quadratic in the line, on ordinary prose. A
|
|
224
|
+
* failed try now gives back only characters that cannot be a `T`, one step
|
|
225
|
+
* each.
|
|
226
|
+
*/
|
|
227
|
+
export const DATE_TIME_SPLIT = /^(.[^Tt\n\r\u2028\u2029]*)[Tt](.+)$/;
|
|
215
228
|
/**
|
|
216
229
|
* RFC 3339 `date-time`: a date, a `T` (or lowercase `t`) separator, and a
|
|
217
230
|
* time with an offset. A space separator, as SQL writes it, is rejected —
|
|
218
231
|
* this is the strict interchange form.
|
|
219
232
|
*/
|
|
220
233
|
export function isDateTime(value) {
|
|
221
|
-
const split = value.match(
|
|
234
|
+
const split = value.match(DATE_TIME_SPLIT);
|
|
222
235
|
if (split === null)
|
|
223
236
|
return bad('expected a "T" between the date and the time');
|
|
224
237
|
const date = isDate(split[1] ?? "");
|
package/dist/lib/jsonschema.d.ts
CHANGED
|
@@ -1,3 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JSON Schema Draft-07 validator, written out in full here so the package
|
|
3
|
+
* has no runtime dependency and so its behaviour is auditable in one file.
|
|
4
|
+
*
|
|
5
|
+
* ## What is enforced
|
|
6
|
+
*
|
|
7
|
+
* | Group | Keywords |
|
|
8
|
+
* |---|---|
|
|
9
|
+
* | any | `type`, `enum`, `const` |
|
|
10
|
+
* | number | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` |
|
|
11
|
+
* | string | `minLength`, `maxLength`, `pattern`, `format` (opt-in) |
|
|
12
|
+
* | array | `items` (schema or tuple), `additionalItems`, `contains`, `minItems`, `maxItems`, `uniqueItems` |
|
|
13
|
+
* | object | `properties`, `patternProperties`, `additionalProperties`, `propertyNames`, `required`, `minProperties`, `maxProperties` |
|
|
14
|
+
* | logic | `allOf`, `anyOf`, `oneOf`, `not`, `if`/`then`/`else` |
|
|
15
|
+
* | reference | `$ref` to a local JSON Pointer, including `#/$defs/...` and `#/definitions/...` |
|
|
16
|
+
*
|
|
17
|
+
* Boolean schemas (`true` accepts everything, `false` rejects everything) are
|
|
18
|
+
* supported wherever a schema is expected.
|
|
19
|
+
*
|
|
20
|
+
* ## What is NOT enforced
|
|
21
|
+
*
|
|
22
|
+
* - **Annotations**, which carry no constraint and are skipped silently:
|
|
23
|
+
* `$id`, `$schema`, `$comment`, `title`, `description`, `default`,
|
|
24
|
+
* `examples`, `readOnly`, `writeOnly`, `deprecated`, `contentMediaType`,
|
|
25
|
+
* `contentEncoding`, `$defs`, `definitions`.
|
|
26
|
+
* - **`format`**, unless `assertFormat` is set. Draft-07 makes `format` an
|
|
27
|
+
* annotation by default and this validator follows that; the opt-in uses
|
|
28
|
+
* the checks in `./formats`, whose subsets are documented there.
|
|
29
|
+
* - **`dependencies` / `dependentRequired` / `dependentSchemas`**,
|
|
30
|
+
* **`unevaluatedProperties` / `unevaluatedItems`**, the draft-04 boolean
|
|
31
|
+
* form of `exclusiveMinimum`/`exclusiveMaximum`, and any other keyword not
|
|
32
|
+
* in the table above. These are never silently dropped: every one
|
|
33
|
+
* encountered is listed in the result's `unsupportedKeywords`, so a caller
|
|
34
|
+
* can tell a clean pass from an unenforced one. Note *encountered*: the
|
|
35
|
+
* list is built while walking, so a keyword sitting in a subschema this
|
|
36
|
+
* value never reached (a property it does not carry, a `$defs` entry no
|
|
37
|
+
* `$ref` took) is not in it. Validate a value that exercises the branch to
|
|
38
|
+
* see its keywords.
|
|
39
|
+
* - **Remote and non-pointer `$ref`** (`http://...`, `other.json#/x`, and
|
|
40
|
+
* `$id`-relative resolution). A `$ref` this validator cannot resolve
|
|
41
|
+
* locally is reported as an error rather than treated as `true`.
|
|
42
|
+
*
|
|
43
|
+
* ## Two deliberate deviations, both in the reporting
|
|
44
|
+
*
|
|
45
|
+
* - When `type` fails, the keywords specific to that type are not also run,
|
|
46
|
+
* so a number arriving where an object belongs produces one error rather
|
|
47
|
+
* than one per property. The verdict is identical; only the error list is
|
|
48
|
+
* shorter. `allOf`/`anyOf`/`oneOf`/`not` still run, since those can fail
|
|
49
|
+
* independently of the type.
|
|
50
|
+
* - Errors stop accumulating at `maxErrors`, and the result says `truncated`.
|
|
51
|
+
*
|
|
52
|
+
* ## A bounded amount of work, and "undetermined" when it runs out
|
|
53
|
+
*
|
|
54
|
+
* `anyOf`/`oneOf`/`allOf` over `$ref`s that share a target multiply: a
|
|
55
|
+
* 1.3 KB schema can ask for 2^30 subschema evaluations, and the walk is
|
|
56
|
+
* synchronous. Every subschema evaluation is counted against a budget. So
|
|
57
|
+
* is every check whose cost follows the value's size rather than the
|
|
58
|
+
* schema's — a string's length or pattern, uniqueItems, an object's keys,
|
|
59
|
+
* each enum candidate and each (key, patternProperties pattern) test, and
|
|
60
|
+
* each error message built — so a small schema cannot spend the value's
|
|
61
|
+
* size once per leaf. When the budget runs out the walk stops and the
|
|
62
|
+
* result is `undetermined`, with `valid: false` and the reason — never a
|
|
63
|
+
* verdict either way. A failing branch is summarised in a bounded message,
|
|
64
|
+
* and a message is built only when it will be kept.
|
|
65
|
+
*
|
|
66
|
+
* The default budget ({@link defaultWorkLimit}) follows what an honest walk
|
|
67
|
+
* can cost. A schema written out as a tree (no `$ref` reached twice at one
|
|
68
|
+
* place) evaluates each of its subschemas at most once per value node, so a
|
|
69
|
+
* value node is allowed as many units as the schema has subschemas (at
|
|
70
|
+
* least {@link WORK_PER_VALUE_NODE}, at most {@link MAX_SCHEMA_FANOUT}); a
|
|
71
|
+
* wide or nested union over thousands of valid rows is answered. Only
|
|
72
|
+
* repeating a `$ref` at one place goes past that, which is the shape that
|
|
73
|
+
* multiplies. The floor is {@link DEFAULT_MAX_WORK} and the ceiling
|
|
74
|
+
* {@link MAX_DEFAULT_WORK}, a few seconds of the worst schemas measured.
|
|
75
|
+
*
|
|
76
|
+
* ## Depth is the value's
|
|
77
|
+
*
|
|
78
|
+
* A recursive schema walks as deep as the value does, so nesting is counted
|
|
79
|
+
* where the value nests — into an item or a property — not per `$ref` or
|
|
80
|
+
* branch. A value deeper than {@link MAX_VALUE_DEPTH}, or a schema and value
|
|
81
|
+
* nesting past {@link MAX_NESTING} walk frames together, is `undetermined`:
|
|
82
|
+
* the part not walked may be valid or not. A `$ref` that reaches itself at
|
|
83
|
+
* one position with nothing consumed between is a cycle, and an error.
|
|
84
|
+
*
|
|
85
|
+
* `pattern` is an unanchored ECMA-262 match, per the spec — `"pattern": "a"`
|
|
86
|
+
* matches `"banana"`.
|
|
87
|
+
*/
|
|
88
|
+
import { type RegexAnswers } from "./regex-answers";
|
|
1
89
|
/** A schema is an object of keywords, or a boolean that accepts/rejects all. */
|
|
2
90
|
export type Schema = boolean | Record<string, unknown>;
|
|
3
91
|
/** One failure, addressed by a JSON Pointer into the value. */
|
|
@@ -16,9 +104,81 @@ export type ValidateOptions = {
|
|
|
16
104
|
assertFormat: boolean;
|
|
17
105
|
/** Stop collecting after this many errors. */
|
|
18
106
|
maxErrors: number;
|
|
107
|
+
/**
|
|
108
|
+
* Most subschema evaluations before the answer is `undetermined`. Default:
|
|
109
|
+
* the larger of DEFAULT_MAX_WORK and WORK_PER_VALUE_NODE per value node.
|
|
110
|
+
*/
|
|
111
|
+
maxWork: number;
|
|
112
|
+
/**
|
|
113
|
+
* A budget shared with other calls (ValidateRecords draws every row from
|
|
114
|
+
* one). When given, `maxWork` is ignored.
|
|
115
|
+
*/
|
|
116
|
+
budget: WorkBudget;
|
|
117
|
+
/**
|
|
118
|
+
* Answers for the schema's `pattern` and `patternProperties` regexes,
|
|
119
|
+
* resolved in the worker by the calling tool (see `withRegexAnswers`). A
|
|
120
|
+
* question not answered yet is recorded there and read as "no match" for
|
|
121
|
+
* this pass, which the caller discards and runs again. Without it, each
|
|
122
|
+
* pattern runs on this thread through the bounded fallback.
|
|
123
|
+
*/
|
|
124
|
+
regex: RegexAnswers;
|
|
125
|
+
};
|
|
126
|
+
/** Subschema evaluations allowed and spent; shared by reference. */
|
|
127
|
+
export type WorkBudget = {
|
|
128
|
+
used: number;
|
|
129
|
+
readonly limit: number;
|
|
19
130
|
};
|
|
131
|
+
/** The budget floor: about a third of a second of the worst schemas measured (Apple silicon). */
|
|
132
|
+
export declare const DEFAULT_MAX_WORK = 500000;
|
|
133
|
+
/**
|
|
134
|
+
* The least work allowed per unit of the value's weight, so a large document
|
|
135
|
+
* is not refused for its size. A schema with more subschemas than this is
|
|
136
|
+
* allowed its subschema count instead.
|
|
137
|
+
*/
|
|
138
|
+
export declare const WORK_PER_VALUE_NODE = 64;
|
|
139
|
+
/** The most work per unit of weight a schema's size earns. */
|
|
140
|
+
export declare const MAX_SCHEMA_FANOUT = 1024;
|
|
141
|
+
/**
|
|
142
|
+
* The most work a default budget allows, however large the value: about
|
|
143
|
+
* seven seconds of the worst schemas measured (Apple silicon, 0.3-0.5 us a
|
|
144
|
+
* unit). The walk is synchronous, so this is how long one call can hold the
|
|
145
|
+
* harness's thread.
|
|
146
|
+
*/
|
|
147
|
+
export declare const MAX_DEFAULT_WORK = 20000000;
|
|
148
|
+
/** Nodes in a JSON value (itself included), counted iteratively, stopping at `cap`. */
|
|
149
|
+
export declare function countValueNodes(value: unknown, cap?: number): number;
|
|
150
|
+
/**
|
|
151
|
+
* A value's weight: one per node, and one more per CHARS_PER_UNIT
|
|
152
|
+
* characters of each string and key — the size the size-bound checks are
|
|
153
|
+
* charged in, so the budget grows with the value in the same units.
|
|
154
|
+
*/
|
|
155
|
+
export declare function valueWeight(value: unknown): number;
|
|
156
|
+
/**
|
|
157
|
+
* Subschemas in `schema` written out as a tree, `$ref`s not followed, up to
|
|
158
|
+
* `cap`: what one value node can honestly be checked against. Counted
|
|
159
|
+
* iteratively, since a schema may nest deeper than the stack.
|
|
160
|
+
*/
|
|
161
|
+
export declare function schemaFanout(schema: unknown, cap?: number): number;
|
|
162
|
+
/**
|
|
163
|
+
* The default budget for validating `value` against `schema`: the value's
|
|
164
|
+
* weight times what the schema allows per unit of it, between
|
|
165
|
+
* DEFAULT_MAX_WORK and MAX_DEFAULT_WORK.
|
|
166
|
+
*/
|
|
167
|
+
export declare function defaultWorkLimit(value: unknown, schema?: unknown): number;
|
|
20
168
|
export type ValidationResult = {
|
|
169
|
+
/** Never true when `undetermined` is set. */
|
|
21
170
|
valid: boolean;
|
|
171
|
+
/**
|
|
172
|
+
* Why no verdict could be reached, or null when `valid` is the verdict.
|
|
173
|
+
* Set when the work budget ran out: the errors listed are the ones found
|
|
174
|
+
* before it did, and the value may be valid or invalid.
|
|
175
|
+
*/
|
|
176
|
+
undetermined: string | null;
|
|
177
|
+
/**
|
|
178
|
+
* True when `undetermined` is the work budget running out: no pattern
|
|
179
|
+
* answer can change that, so a caller resolving patterns in rounds stops.
|
|
180
|
+
*/
|
|
181
|
+
workExhausted: boolean;
|
|
22
182
|
errors: ValidationError[];
|
|
23
183
|
/** True when `maxErrors` cut the list short. */
|
|
24
184
|
truncated: boolean;
|
|
@@ -29,6 +189,16 @@ export type ValidationResult = {
|
|
|
29
189
|
*/
|
|
30
190
|
unsupportedKeywords: string[];
|
|
31
191
|
};
|
|
192
|
+
/** Deepest value the walk follows; past it the answer is undetermined. */
|
|
193
|
+
export declare const MAX_VALUE_DEPTH = 512;
|
|
194
|
+
/**
|
|
195
|
+
* Most walk frames (a subschema evaluation inside another) at once, value
|
|
196
|
+
* and schema nesting together; past it the answer is undetermined. It keeps
|
|
197
|
+
* a deeply nested schema from overflowing the stack.
|
|
198
|
+
*/
|
|
199
|
+
export declare const MAX_NESTING = 2048;
|
|
200
|
+
/** Longest message one error carries; nested branch reasons are cut here. */
|
|
201
|
+
export declare const MAX_MESSAGE_CHARS = 1000;
|
|
32
202
|
/**
|
|
33
203
|
* Validate `value` against `schema`, returning every failure with a JSON
|
|
34
204
|
* Pointer path. See the module comment for the exact supported subset; the
|