@maroonedog/luq 2.5.0 → 2.7.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/README.md +35 -8
- package/dist/chain/slot-type-guard.js +20 -6
- package/dist/chain/slot-type-guard.mjs +20 -6
- package/dist/compile/resolve-presence.js +36 -5
- package/dist/compile/resolve-presence.mjs +36 -5
- package/dist/json-schema/assert-supported-dialect.d.ts +37 -0
- package/dist/json-schema/assert-supported-dialect.js +113 -0
- package/dist/json-schema/assert-supported-dialect.mjs +109 -0
- package/dist/json-schema/build-from-schema.d.ts +22 -3
- package/dist/json-schema/build-from-schema.js +27 -5
- package/dist/json-schema/build-from-schema.mjs +27 -5
- package/dist/json-schema/declare-value-keywords.d.ts +0 -2
- package/dist/json-schema/declare-value-keywords.js +7 -10
- package/dist/json-schema/declare-value-keywords.mjs +7 -9
- package/dist/json-schema/extensions/json-schema/index.d.ts +2 -2
- package/dist/json-schema/extensions/json-schema/index.js +3 -1
- package/dist/json-schema/extensions/json-schema/index.mjs +1 -1
- package/dist/json-schema/extensions/json-schema/json-schema.d.ts +2 -2
- package/dist/json-schema/extensions/json-schema/json-schema.js +3 -0
- package/dist/json-schema/extensions/json-schema/json-schema.mjs +4 -1
- package/dist/json-schema/extensions/json-schema-full-feature/bundle-coverage.js +6 -1
- package/dist/json-schema/extensions/json-schema-full-feature/bundle-coverage.mjs +6 -1
- package/dist/json-schema/extensions/json-schema-full-feature/index.d.ts +3 -1
- package/dist/json-schema/extensions/json-schema-full-feature/index.js +18 -1
- package/dist/json-schema/extensions/json-schema-full-feature/index.mjs +11 -0
- package/dist/json-schema/extensions/json-schema-full-feature/json-schema-full-feature.d.ts +9 -1
- package/dist/json-schema/extensions/json-schema-full-feature/json-schema-full-feature.js +9 -2
- package/dist/json-schema/extensions/json-schema-full-feature/json-schema-full-feature.mjs +9 -2
- package/dist/json-schema/index.d.ts +3 -0
- package/dist/json-schema/index.js +7 -1
- package/dist/json-schema/index.mjs +2 -0
- package/dist/json-schema/keyword-map-core.js +3 -1
- package/dist/json-schema/keyword-map-core.mjs +3 -1
- package/dist/json-schema/keyword-map.js +3 -0
- package/dist/json-schema/keyword-map.mjs +3 -0
- package/dist/json-schema/schema-to-declarations.js +1 -4
- package/dist/json-schema/schema-to-declarations.mjs +2 -5
- package/dist/json-schema/unsupported-dialect-error.d.ts +21 -0
- package/dist/json-schema/unsupported-dialect-error.js +58 -0
- package/dist/json-schema/unsupported-dialect-error.mjs +54 -0
- package/dist/runtime/create-issue.d.ts +9 -0
- package/dist/runtime/create-issue.js +9 -2
- package/dist/runtime/create-issue.mjs +9 -2
- package/dist/runtime/run-field.js +1 -0
- package/dist/runtime/run-field.mjs +1 -0
- package/dist/types/index.d.ts +12 -0
- package/package.json +20 -8
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@ compile error, not a rule that quietly never fires.
|
|
|
42
42
|
| Descending into a built-in (`"when.getTime"` on a `Date`) | compile error |
|
|
43
43
|
| A method that does not exist inside an element sub-chain | compile error |
|
|
44
44
|
| A JSON Schema keyword bound to a chain method that does not exist | compile error |
|
|
45
|
+
| A JSON Schema document declaring 2019-09 or 2020-12 | throws when the validator is built, rather than being read as Draft-07 |
|
|
45
46
|
| A documented example drifting from the API | fails CI |
|
|
46
47
|
|
|
47
48
|
That matters most when the code calling this library is generated rather than
|
|
@@ -136,13 +137,31 @@ const result = orderValidator.validate({ reference: "ab", quantity: 0 });
|
|
|
136
137
|
if (!result.valid) {
|
|
137
138
|
for (const issue of result.issues) {
|
|
138
139
|
// issue.path "reference" — where, with array indices filled in
|
|
139
|
-
// issue.code "stringMin" — which rule
|
|
140
|
+
// issue.code "stringMin" — which rule; the vocabulary is pinned
|
|
140
141
|
// issue.message — the text, overridable per call
|
|
141
142
|
// issue.severity "error" — only "error" makes the value invalid
|
|
143
|
+
// issue.causes — present only on a composite: why it failed
|
|
142
144
|
}
|
|
143
145
|
}
|
|
144
146
|
```
|
|
145
147
|
|
|
148
|
+
`issue.code` is the machine-readable half: a plugin's own name by default,
|
|
149
|
+
overridden per rule with `{ code }`. The set is not a convention. The codes the
|
|
150
|
+
library reports are enumerated in `config/issue-code.lock.json`, derived from
|
|
151
|
+
the source by `npm run generate:issue-codes` and checked by
|
|
152
|
+
`npm run check:issue-code-lock`, so renaming one is a diff in a committed file
|
|
153
|
+
and a deliberate act rather than a silent break. The lock records who reports
|
|
154
|
+
each code, because some codes are shared on purpose — a missing root and a
|
|
155
|
+
missing field both report `required`. Codes a gate carries (`skip`,
|
|
156
|
+
`validateIf`) sit in a separate list: they are accepted from a caller but no
|
|
157
|
+
issue can ever carry one.
|
|
158
|
+
|
|
159
|
+
A composite reports which applicator failed — `allOf`, `anyOf`, `oneOf` — and
|
|
160
|
+
that code cannot say why. `issue.causes` carries the branch failures behind it,
|
|
161
|
+
each a full issue with its own code, so `allOf` can be read down to the
|
|
162
|
+
`stringMin` underneath it. The key is **absent** on an ordinary issue rather
|
|
163
|
+
than present and undefined, so `"causes" in issue` is a question worth asking.
|
|
164
|
+
|
|
146
165
|
`.v(path, chain)` declares rules for one field. A path you do not declare is
|
|
147
166
|
not validated, not required and not read, so covering a type partly is a normal
|
|
148
167
|
state rather than a half-finished one.
|
|
@@ -203,7 +222,11 @@ accounts.pickAll(["email"]); // several fields, same plan
|
|
|
203
222
|
|
|
204
223
|
`validate` and `parse` return the same discriminated union: `{ valid: true,
|
|
205
224
|
data, issues }` or `{ valid: false, issues }`. `validate` hands back the object
|
|
206
|
-
you passed, by identity, when nothing was written.
|
|
225
|
+
you passed, by identity, when nothing was written. Only `parse` runs your
|
|
226
|
+
transform functions, so only `parse` can throw what one of them throws: a throw
|
|
227
|
+
from a transform is a programmer error, not a validation issue, and zod, valibot
|
|
228
|
+
and yup all propagate it the same way. To reject a value instead, use a check
|
|
229
|
+
such as [`custom`](https://luq.dev/docs/api/validator#transform-throws).
|
|
207
230
|
|
|
208
231
|
### Slots
|
|
209
232
|
|
|
@@ -295,7 +318,7 @@ const forStringMin = PLUGIN_MANIFEST.filter((entry) =>
|
|
|
295
318
|
| [Getting started](https://luq.dev/docs/getting-started) | the builder, defaults, `normalize`, reading a result |
|
|
296
319
|
| [Core concepts](https://luq.dev/docs/core-concepts) | field paths, slots, presence, transforms |
|
|
297
320
|
| [Plugins](https://luq.dev/plugins) | every subpath, symbol, chain method and slot |
|
|
298
|
-
| [JSON Schema](https://luq.dev/json-schema) | reading a document in, writing one back out, measured
|
|
321
|
+
| [JSON Schema](https://luq.dev/json-schema) | reading a Draft-07 document in, writing one back out, measured conformance, and why a newer-dialect document is refused |
|
|
299
322
|
| [Standard Schema](https://luq.dev/standard-schema) | tRPC, TanStack Form, Hono, react-hook-form — and what does not cross that boundary |
|
|
300
323
|
| [Benchmarks](https://luq.dev/benchmarks) | bundle size and throughput, with the method |
|
|
301
324
|
| [Luq or zod?](https://luq.dev/luq-or-zod) | when schema-first is the better answer |
|
|
@@ -311,6 +334,8 @@ Breaking changes happen in a major and nowhere else, an API being removed is
|
|
|
311
334
|
deprecated one major ahead, and each major ships with the codemod needed to
|
|
312
335
|
cross it.
|
|
313
336
|
|
|
337
|
+
- **[CHANGELOG.md](https://github.com/maroonedog/luq/blob/master/CHANGELOG.md)** — every released version, what a caller
|
|
338
|
+
sees change in each, and which ones need reading before you take them
|
|
314
339
|
- **[CONTRIBUTING.md](https://github.com/maroonedog/luq/blob/master/CONTRIBUTING.md)** — `npm run verify` is the whole
|
|
315
340
|
contract; the gates and what each one refuses
|
|
316
341
|
- **[SECURITY.md](https://github.com/maroonedog/luq/blob/master/SECURITY.md)** — reporting, zero runtime dependencies, the
|
|
@@ -320,11 +345,13 @@ cross it.
|
|
|
320
345
|
|
|
321
346
|
## About the "universal platform" goal
|
|
322
347
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
348
|
+
The first published line — `0.1.0-alpha` through `0.1.2-alpha`, which the
|
|
349
|
+
migration guide and the docs site both call "1.x" although no `1.x` was ever
|
|
350
|
+
published — described a `.luq` DSL that would generate validators for other
|
|
351
|
+
languages, against dated milestones. Those dates have passed and none of it
|
|
352
|
+
shipped, so the plan has been withdrawn rather than moved: no part of it is in
|
|
353
|
+
this package, and this release makes no claim about when any of it will exist.
|
|
354
|
+
What is in the box is the TypeScript validation library described above.
|
|
328
355
|
|
|
329
356
|
## License
|
|
330
357
|
|
|
@@ -3,16 +3,30 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.slotTypeGuard = slotTypeGuard;
|
|
4
4
|
const types_1 = require("../types");
|
|
5
5
|
const create_rule_1 = require("../plugin-kit/create-rule");
|
|
6
|
+
/**
|
|
7
|
+
* Each code is SPELLED here rather than interpolated from the slot name.
|
|
8
|
+
*
|
|
9
|
+
* `${slot}Type` produced the same six strings, and produced them nowhere a
|
|
10
|
+
* reader could find them: config/issue-code.lock.json enumerates every code
|
|
11
|
+
* the library can report, and a code that only exists once a template has been
|
|
12
|
+
* evaluated cannot be enumerated, so these six were missing from the published
|
|
13
|
+
* vocabulary while being among the most commonly reported codes there are.
|
|
14
|
+
*/
|
|
6
15
|
const SLOT_TYPES = Object.freeze({
|
|
7
|
-
string: { accepts: types_1.isString, noun: "a string" },
|
|
8
|
-
number: { accepts: types_1.isNumber, noun: "a number" },
|
|
16
|
+
string: { accepts: types_1.isString, code: "stringType", noun: "a string" },
|
|
17
|
+
number: { accepts: types_1.isNumber, code: "numberType", noun: "a number" },
|
|
9
18
|
boolean: {
|
|
10
19
|
accepts: (value) => typeof value === "boolean",
|
|
20
|
+
code: "booleanType",
|
|
11
21
|
noun: "a boolean",
|
|
12
22
|
},
|
|
13
|
-
date: {
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
date: {
|
|
24
|
+
accepts: (value) => value instanceof Date,
|
|
25
|
+
code: "dateType",
|
|
26
|
+
noun: "a Date",
|
|
27
|
+
},
|
|
28
|
+
array: { accepts: types_1.isArray, code: "arrayType", noun: "an array" },
|
|
29
|
+
object: { accepts: types_1.isPlainObject, code: "objectType", noun: "an object" },
|
|
16
30
|
});
|
|
17
31
|
/**
|
|
18
32
|
* The rule `b.<slot>` starts its chain with, or null for a slot that claims
|
|
@@ -26,7 +40,7 @@ function slotTypeGuard(slot, severity) {
|
|
|
26
40
|
if (slotType === undefined)
|
|
27
41
|
return null;
|
|
28
42
|
return (0, create_rule_1.check)({
|
|
29
|
-
code:
|
|
43
|
+
code: slotType.code,
|
|
30
44
|
severity,
|
|
31
45
|
run: (value) => value === undefined || value === null || slotType.accepts(value)
|
|
32
46
|
? types_1.PASS
|
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
import { PASS, fail, isArray, isNumber, isPlainObject, isString, } from "../types/index.mjs";
|
|
2
2
|
import { check } from "../plugin-kit/create-rule.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* Each code is SPELLED here rather than interpolated from the slot name.
|
|
5
|
+
*
|
|
6
|
+
* `${slot}Type` produced the same six strings, and produced them nowhere a
|
|
7
|
+
* reader could find them: config/issue-code.lock.json enumerates every code
|
|
8
|
+
* the library can report, and a code that only exists once a template has been
|
|
9
|
+
* evaluated cannot be enumerated, so these six were missing from the published
|
|
10
|
+
* vocabulary while being among the most commonly reported codes there are.
|
|
11
|
+
*/
|
|
3
12
|
const SLOT_TYPES = Object.freeze({
|
|
4
|
-
string: { accepts: isString, noun: "a string" },
|
|
5
|
-
number: { accepts: isNumber, noun: "a number" },
|
|
13
|
+
string: { accepts: isString, code: "stringType", noun: "a string" },
|
|
14
|
+
number: { accepts: isNumber, code: "numberType", noun: "a number" },
|
|
6
15
|
boolean: {
|
|
7
16
|
accepts: (value) => typeof value === "boolean",
|
|
17
|
+
code: "booleanType",
|
|
8
18
|
noun: "a boolean",
|
|
9
19
|
},
|
|
10
|
-
date: {
|
|
11
|
-
|
|
12
|
-
|
|
20
|
+
date: {
|
|
21
|
+
accepts: (value) => value instanceof Date,
|
|
22
|
+
code: "dateType",
|
|
23
|
+
noun: "a Date",
|
|
24
|
+
},
|
|
25
|
+
array: { accepts: isArray, code: "arrayType", noun: "an array" },
|
|
26
|
+
object: { accepts: isPlainObject, code: "objectType", noun: "an object" },
|
|
13
27
|
});
|
|
14
28
|
/**
|
|
15
29
|
* The rule `b.<slot>` starts its chain with, or null for a slot that claims
|
|
@@ -23,7 +37,7 @@ export function slotTypeGuard(slot, severity) {
|
|
|
23
37
|
if (slotType === undefined)
|
|
24
38
|
return null;
|
|
25
39
|
return check({
|
|
26
|
-
code:
|
|
40
|
+
code: slotType.code,
|
|
27
41
|
severity,
|
|
28
42
|
run: (value) => value === undefined || value === null || slotType.accepts(value)
|
|
29
43
|
? PASS
|
|
@@ -38,13 +38,43 @@ function countRestrictions(rule) {
|
|
|
38
38
|
(rule.allowNull ? 0 : 1) +
|
|
39
39
|
(rule.emptyStringIsMissing ? 1 : 0));
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Which of two equally restrictive rules is reported, decided on WHAT each one
|
|
43
|
+
* forbids: undefined first, then null, then the empty string.
|
|
44
|
+
*
|
|
45
|
+
* The order is declared here rather than falling out of something else. It
|
|
46
|
+
* runs from the broadest absence to the narrowest — a field that is not there
|
|
47
|
+
* at all, a field that is there holding null, a field holding "" — so the
|
|
48
|
+
* identity a caller sees names the largest thing that was missing.
|
|
49
|
+
*
|
|
50
|
+
* Nothing in this comparison reads a code. A code is a published name, pinned
|
|
51
|
+
* in config/issue-code.lock.json, and re-spelling one must not move which rule
|
|
52
|
+
* a failure is reported under; comparing the two codes made exactly that
|
|
53
|
+
* happen, silently, from a one-word rename.
|
|
54
|
+
*
|
|
55
|
+
* Two rules forbidding the identical three things are indistinguishable to
|
|
56
|
+
* every reader of the policy, so neither outranks the other and the one
|
|
57
|
+
* declared first stays. That is the only case where the order the methods were
|
|
58
|
+
* typed in is observable at all.
|
|
59
|
+
*/
|
|
60
|
+
function outranksOnRestriction(rule, incumbent) {
|
|
61
|
+
if (rule.allowUndefined !== incumbent.allowUndefined) {
|
|
62
|
+
return !rule.allowUndefined;
|
|
63
|
+
}
|
|
64
|
+
if (rule.allowNull !== incumbent.allowNull)
|
|
65
|
+
return !rule.allowNull;
|
|
66
|
+
if (rule.emptyStringIsMissing !== incumbent.emptyStringIsMissing) {
|
|
67
|
+
return rule.emptyStringIsMissing;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
41
71
|
/**
|
|
42
72
|
* The identity a failure is reported under: the rule that forbids the most,
|
|
43
|
-
* ties broken by
|
|
73
|
+
* ties broken by which absence it forbids.
|
|
44
74
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
75
|
+
* Both halves of the decision read only the three presence flags, so the whole
|
|
76
|
+
* policy — not merely its flags — is independent of the order the rules
|
|
77
|
+
* arrived in. `.optional().nullable()` and `.nullable().optional()` are
|
|
48
78
|
* deep-equal, which is the property the tests assert.
|
|
49
79
|
*/
|
|
50
80
|
function selectStrictestPresence(rules) {
|
|
@@ -55,7 +85,8 @@ function selectStrictestPresence(rules) {
|
|
|
55
85
|
continue;
|
|
56
86
|
}
|
|
57
87
|
const difference = countRestrictions(rule) - countRestrictions(strictest);
|
|
58
|
-
if (difference > 0 ||
|
|
88
|
+
if (difference > 0 ||
|
|
89
|
+
(difference === 0 && outranksOnRestriction(rule, strictest))) {
|
|
59
90
|
strictest = rule;
|
|
60
91
|
}
|
|
61
92
|
}
|
|
@@ -34,13 +34,43 @@ function countRestrictions(rule) {
|
|
|
34
34
|
(rule.allowNull ? 0 : 1) +
|
|
35
35
|
(rule.emptyStringIsMissing ? 1 : 0));
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Which of two equally restrictive rules is reported, decided on WHAT each one
|
|
39
|
+
* forbids: undefined first, then null, then the empty string.
|
|
40
|
+
*
|
|
41
|
+
* The order is declared here rather than falling out of something else. It
|
|
42
|
+
* runs from the broadest absence to the narrowest — a field that is not there
|
|
43
|
+
* at all, a field that is there holding null, a field holding "" — so the
|
|
44
|
+
* identity a caller sees names the largest thing that was missing.
|
|
45
|
+
*
|
|
46
|
+
* Nothing in this comparison reads a code. A code is a published name, pinned
|
|
47
|
+
* in config/issue-code.lock.json, and re-spelling one must not move which rule
|
|
48
|
+
* a failure is reported under; comparing the two codes made exactly that
|
|
49
|
+
* happen, silently, from a one-word rename.
|
|
50
|
+
*
|
|
51
|
+
* Two rules forbidding the identical three things are indistinguishable to
|
|
52
|
+
* every reader of the policy, so neither outranks the other and the one
|
|
53
|
+
* declared first stays. That is the only case where the order the methods were
|
|
54
|
+
* typed in is observable at all.
|
|
55
|
+
*/
|
|
56
|
+
function outranksOnRestriction(rule, incumbent) {
|
|
57
|
+
if (rule.allowUndefined !== incumbent.allowUndefined) {
|
|
58
|
+
return !rule.allowUndefined;
|
|
59
|
+
}
|
|
60
|
+
if (rule.allowNull !== incumbent.allowNull)
|
|
61
|
+
return !rule.allowNull;
|
|
62
|
+
if (rule.emptyStringIsMissing !== incumbent.emptyStringIsMissing) {
|
|
63
|
+
return rule.emptyStringIsMissing;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
37
67
|
/**
|
|
38
68
|
* The identity a failure is reported under: the rule that forbids the most,
|
|
39
|
-
* ties broken by
|
|
69
|
+
* ties broken by which absence it forbids.
|
|
40
70
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
71
|
+
* Both halves of the decision read only the three presence flags, so the whole
|
|
72
|
+
* policy — not merely its flags — is independent of the order the rules
|
|
73
|
+
* arrived in. `.optional().nullable()` and `.nullable().optional()` are
|
|
44
74
|
* deep-equal, which is the property the tests assert.
|
|
45
75
|
*/
|
|
46
76
|
function selectStrictestPresence(rules) {
|
|
@@ -51,7 +81,8 @@ function selectStrictestPresence(rules) {
|
|
|
51
81
|
continue;
|
|
52
82
|
}
|
|
53
83
|
const difference = countRestrictions(rule) - countRestrictions(strictest);
|
|
54
|
-
if (difference > 0 ||
|
|
84
|
+
if (difference > 0 ||
|
|
85
|
+
(difference === 0 && outranksOnRestriction(rule, strictest))) {
|
|
55
86
|
strictest = rule;
|
|
56
87
|
}
|
|
57
88
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the caller may say about the dialect. `JsonSchemaOptions` extends this,
|
|
3
|
+
* so the chain method and the conversion function offer the same word.
|
|
4
|
+
*/
|
|
5
|
+
export interface DialectOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Read the document under Draft-07 rules whatever its `$schema` declares.
|
|
8
|
+
*
|
|
9
|
+
* The reading it buys is Draft-07's, exactly: `$ref` replaces the node it
|
|
10
|
+
* sits in, `format` asserts, and an array under `items` is the tuple form.
|
|
11
|
+
* A 2019-09 or 2020-12 document that relies on any of those meaning what the
|
|
12
|
+
* NEWER draft says will be enforced as less, or as something else, than it
|
|
13
|
+
* states.
|
|
14
|
+
*/
|
|
15
|
+
readonly assumeDraft07?: boolean | undefined;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The released dialects Luq recognises the NAME of and does not implement,
|
|
19
|
+
* each with what it does that Draft-07 does not. They are written out so that
|
|
20
|
+
* "this is a dialect we know and did not build" and "we have never heard of
|
|
21
|
+
* this URI" stay different answers — the same distinction NON_DRAFT07_KEYWORDS
|
|
22
|
+
* draws between an out-of-dialect keyword and an invented one.
|
|
23
|
+
*/
|
|
24
|
+
export declare const NON_DRAFT07_DIALECTS: Readonly<Record<string, string>>;
|
|
25
|
+
/**
|
|
26
|
+
* Reads the root `$schema` and refuses a dialect Luq does not implement.
|
|
27
|
+
*
|
|
28
|
+
* Called at both front doors, immediately after "is this a schema at all" and
|
|
29
|
+
* before any keyword is read, so no validator that enforces less than its
|
|
30
|
+
* document states can be reached by either route.
|
|
31
|
+
*
|
|
32
|
+
* A non-string `$schema` is a malformed VALUE rather than an unimplemented
|
|
33
|
+
* dialect — the meta-schema types it as a string — so it goes to the class that
|
|
34
|
+
* answers that question, and it is refused under the opt-out too: the opt-out
|
|
35
|
+
* says "read this dialect as Draft-07", and a number names no dialect.
|
|
36
|
+
*/
|
|
37
|
+
export declare function assertSupportedDialect(document: unknown, options: DialectOptions): void;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NON_DRAFT07_DIALECTS = void 0;
|
|
4
|
+
exports.assertSupportedDialect = assertSupportedDialect;
|
|
5
|
+
// ===========================================================================
|
|
6
|
+
// L8 src/json-schema/assert-supported-dialect.ts — the DIALECT gate, read
|
|
7
|
+
// once at the document root before any keyword is.
|
|
8
|
+
//
|
|
9
|
+
// WHY A DEFAULT REFUSAL AND NOT A DIVERGENCE DETECTOR. Refusing only documents
|
|
10
|
+
// that actually USE something 2019-09 changed sounds more precise and is the
|
|
11
|
+
// worse trade. The divergences are not all spellable as keyword names: `$ref`
|
|
12
|
+
// gains siblings, `format` and the two `content*` keywords stop asserting by
|
|
13
|
+
// default, and 2020-12 moves the tuple form of `items` to `prefixItems`. A
|
|
14
|
+
// detector would therefore have to carry a second semantic model of two
|
|
15
|
+
// dialects this library does not implement, and every case it fails to think
|
|
16
|
+
// of is a SILENT wrong answer — which is the exact failure this file exists to
|
|
17
|
+
// stop. The `$ref`-sibling case was itself invisible until it was hunted for.
|
|
18
|
+
// `$schema` is the document's own statement of what it means, so that is what
|
|
19
|
+
// is read.
|
|
20
|
+
//
|
|
21
|
+
// WHAT THE DEFAULT COSTS, and why the opt-out exists. A newer `$schema` over a
|
|
22
|
+
// body that happens to be Draft-07-compatible builds correctly today, and
|
|
23
|
+
// people copy the newest `$schema` line into documents that use nothing new.
|
|
24
|
+
// Refusing outright would break those. `assumeDraft07` gives them a one-word
|
|
25
|
+
// fix that is written at the CALL SITE, in the caller's own source, where a
|
|
26
|
+
// reviewer reads it — so the reading is a stated decision rather than a silent
|
|
27
|
+
// default. There is no way to get the wrong answer without having written the
|
|
28
|
+
// word down.
|
|
29
|
+
//
|
|
30
|
+
// A document with NO `$schema` is untouched. That is most real documents and
|
|
31
|
+
// all 929 cases of the conformance corpus, and it is not a guess about the
|
|
32
|
+
// dialect: nothing was declared, so nothing contradicts Draft-07.
|
|
33
|
+
//
|
|
34
|
+
// THE ROOT ONLY. `externalDocuments` is a map the caller loads, and the suite's
|
|
35
|
+
// own map holds 37 documents across four dialects of which a given case follows
|
|
36
|
+
// two or three; refusing at registration would refuse documents nothing reads.
|
|
37
|
+
// A `$schema` deeper in a document is likewise not the document's dialect.
|
|
38
|
+
// ===========================================================================
|
|
39
|
+
const types_1 = require("../types");
|
|
40
|
+
const malformed_schema_error_1 = require("./malformed-schema-error");
|
|
41
|
+
const unsupported_dialect_error_1 = require("./unsupported-dialect-error");
|
|
42
|
+
/**
|
|
43
|
+
* Comparable form of a meta-schema URI: case folded, scheme dropped, and a
|
|
44
|
+
* trailing "#" or "/" removed.
|
|
45
|
+
*
|
|
46
|
+
* The scheme is dropped because the two families disagree about it — Draft-07
|
|
47
|
+
* publishes `http://json-schema.org/draft-07/schema#` and 2020-12 publishes
|
|
48
|
+
* `https://json-schema.org/draft/2020-12/schema` — and a document that writes
|
|
49
|
+
* `https` for the first or `http` for the second is naming the same dialect,
|
|
50
|
+
* not a different one. Matching the published string exactly would make the
|
|
51
|
+
* gate depend on which of two equally common spellings a document picked.
|
|
52
|
+
*/
|
|
53
|
+
function normaliseDialectUri(uri) {
|
|
54
|
+
return uri
|
|
55
|
+
.trim()
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/^https?:\/\//, "")
|
|
58
|
+
.replace(/[#/]+$/, "");
|
|
59
|
+
}
|
|
60
|
+
const DRAFT07_NORMALISED = normaliseDialectUri("http://json-schema.org/draft-07/schema#");
|
|
61
|
+
/**
|
|
62
|
+
* The released dialects Luq recognises the NAME of and does not implement,
|
|
63
|
+
* each with what it does that Draft-07 does not. They are written out so that
|
|
64
|
+
* "this is a dialect we know and did not build" and "we have never heard of
|
|
65
|
+
* this URI" stay different answers — the same distinction NON_DRAFT07_KEYWORDS
|
|
66
|
+
* draws between an out-of-dialect keyword and an invented one.
|
|
67
|
+
*/
|
|
68
|
+
exports.NON_DRAFT07_DIALECTS = Object.freeze({
|
|
69
|
+
"json-schema.org/schema": "the unversioned alias, which names whichever draft the site publishes " +
|
|
70
|
+
"as current rather than a fixed one",
|
|
71
|
+
"json-schema.org/draft-03/schema": "Draft-03, which spells `required` as a boolean on each property",
|
|
72
|
+
"json-schema.org/draft-04/schema": "Draft-04, whose `exclusiveMaximum` is a boolean modifier, not a bound",
|
|
73
|
+
"json-schema.org/draft-06/schema": "Draft-06, which has no `$comment` and reads `$ref` the Draft-07 way",
|
|
74
|
+
"json-schema.org/draft/2019-09/schema": "2019-09, where `$ref` is an ordinary applicator and its siblings are " +
|
|
75
|
+
"applied, and `format` asserts nothing unless the vocabulary asks",
|
|
76
|
+
"json-schema.org/draft/2020-12/schema": "2020-12, where `$ref` siblings are applied, the tuple form of `items` " +
|
|
77
|
+
"is spelled `prefixItems`, and `format` asserts nothing by default",
|
|
78
|
+
});
|
|
79
|
+
const UNRECOGNISED_DIALECT = "a meta-schema Luq has no reading for, so what its keywords mean is unknown";
|
|
80
|
+
const DIALECT_VALUE_REASON = "the value must be a URI string";
|
|
81
|
+
/**
|
|
82
|
+
* Reads the root `$schema` and refuses a dialect Luq does not implement.
|
|
83
|
+
*
|
|
84
|
+
* Called at both front doors, immediately after "is this a schema at all" and
|
|
85
|
+
* before any keyword is read, so no validator that enforces less than its
|
|
86
|
+
* document states can be reached by either route.
|
|
87
|
+
*
|
|
88
|
+
* A non-string `$schema` is a malformed VALUE rather than an unimplemented
|
|
89
|
+
* dialect — the meta-schema types it as a string — so it goes to the class that
|
|
90
|
+
* answers that question, and it is refused under the opt-out too: the opt-out
|
|
91
|
+
* says "read this dialect as Draft-07", and a number names no dialect.
|
|
92
|
+
*/
|
|
93
|
+
function assertSupportedDialect(document, options) {
|
|
94
|
+
// Read off an untyped object rather than through `Draft07SchemaObject`,
|
|
95
|
+
// which types `$schema` as a string: the document is whatever JSON.parse
|
|
96
|
+
// produced, and a `$schema` that is not a string is exactly what this has to
|
|
97
|
+
// be able to see. The §4.4 boolean form and every non-object fall out here,
|
|
98
|
+
// declaring no dialect.
|
|
99
|
+
if (!(0, types_1.isPlainObject)(document))
|
|
100
|
+
return;
|
|
101
|
+
const declared = document["$schema"];
|
|
102
|
+
if (declared === undefined)
|
|
103
|
+
return;
|
|
104
|
+
if (typeof declared !== "string") {
|
|
105
|
+
throw new malformed_schema_error_1.MalformedSchemaError("$schema", DIALECT_VALUE_REASON, declared);
|
|
106
|
+
}
|
|
107
|
+
const normalised = normaliseDialectUri(declared);
|
|
108
|
+
if (normalised === DRAFT07_NORMALISED)
|
|
109
|
+
return;
|
|
110
|
+
if (options.assumeDraft07 === true)
|
|
111
|
+
return;
|
|
112
|
+
throw new unsupported_dialect_error_1.UnsupportedDialectError(declared, exports.NON_DRAFT07_DIALECTS[normalised] ?? UNRECOGNISED_DIALECT);
|
|
113
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// ===========================================================================
|
|
2
|
+
// L8 src/json-schema/assert-supported-dialect.ts — the DIALECT gate, read
|
|
3
|
+
// once at the document root before any keyword is.
|
|
4
|
+
//
|
|
5
|
+
// WHY A DEFAULT REFUSAL AND NOT A DIVERGENCE DETECTOR. Refusing only documents
|
|
6
|
+
// that actually USE something 2019-09 changed sounds more precise and is the
|
|
7
|
+
// worse trade. The divergences are not all spellable as keyword names: `$ref`
|
|
8
|
+
// gains siblings, `format` and the two `content*` keywords stop asserting by
|
|
9
|
+
// default, and 2020-12 moves the tuple form of `items` to `prefixItems`. A
|
|
10
|
+
// detector would therefore have to carry a second semantic model of two
|
|
11
|
+
// dialects this library does not implement, and every case it fails to think
|
|
12
|
+
// of is a SILENT wrong answer — which is the exact failure this file exists to
|
|
13
|
+
// stop. The `$ref`-sibling case was itself invisible until it was hunted for.
|
|
14
|
+
// `$schema` is the document's own statement of what it means, so that is what
|
|
15
|
+
// is read.
|
|
16
|
+
//
|
|
17
|
+
// WHAT THE DEFAULT COSTS, and why the opt-out exists. A newer `$schema` over a
|
|
18
|
+
// body that happens to be Draft-07-compatible builds correctly today, and
|
|
19
|
+
// people copy the newest `$schema` line into documents that use nothing new.
|
|
20
|
+
// Refusing outright would break those. `assumeDraft07` gives them a one-word
|
|
21
|
+
// fix that is written at the CALL SITE, in the caller's own source, where a
|
|
22
|
+
// reviewer reads it — so the reading is a stated decision rather than a silent
|
|
23
|
+
// default. There is no way to get the wrong answer without having written the
|
|
24
|
+
// word down.
|
|
25
|
+
//
|
|
26
|
+
// A document with NO `$schema` is untouched. That is most real documents and
|
|
27
|
+
// all 929 cases of the conformance corpus, and it is not a guess about the
|
|
28
|
+
// dialect: nothing was declared, so nothing contradicts Draft-07.
|
|
29
|
+
//
|
|
30
|
+
// THE ROOT ONLY. `externalDocuments` is a map the caller loads, and the suite's
|
|
31
|
+
// own map holds 37 documents across four dialects of which a given case follows
|
|
32
|
+
// two or three; refusing at registration would refuse documents nothing reads.
|
|
33
|
+
// A `$schema` deeper in a document is likewise not the document's dialect.
|
|
34
|
+
// ===========================================================================
|
|
35
|
+
import { isPlainObject } from "../types/index.mjs";
|
|
36
|
+
import { MalformedSchemaError } from "./malformed-schema-error.mjs";
|
|
37
|
+
import { UnsupportedDialectError } from "./unsupported-dialect-error.mjs";
|
|
38
|
+
/**
|
|
39
|
+
* Comparable form of a meta-schema URI: case folded, scheme dropped, and a
|
|
40
|
+
* trailing "#" or "/" removed.
|
|
41
|
+
*
|
|
42
|
+
* The scheme is dropped because the two families disagree about it — Draft-07
|
|
43
|
+
* publishes `http://json-schema.org/draft-07/schema#` and 2020-12 publishes
|
|
44
|
+
* `https://json-schema.org/draft/2020-12/schema` — and a document that writes
|
|
45
|
+
* `https` for the first or `http` for the second is naming the same dialect,
|
|
46
|
+
* not a different one. Matching the published string exactly would make the
|
|
47
|
+
* gate depend on which of two equally common spellings a document picked.
|
|
48
|
+
*/
|
|
49
|
+
function normaliseDialectUri(uri) {
|
|
50
|
+
return uri
|
|
51
|
+
.trim()
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.replace(/^https?:\/\//, "")
|
|
54
|
+
.replace(/[#/]+$/, "");
|
|
55
|
+
}
|
|
56
|
+
const DRAFT07_NORMALISED = normaliseDialectUri("http://json-schema.org/draft-07/schema#");
|
|
57
|
+
/**
|
|
58
|
+
* The released dialects Luq recognises the NAME of and does not implement,
|
|
59
|
+
* each with what it does that Draft-07 does not. They are written out so that
|
|
60
|
+
* "this is a dialect we know and did not build" and "we have never heard of
|
|
61
|
+
* this URI" stay different answers — the same distinction NON_DRAFT07_KEYWORDS
|
|
62
|
+
* draws between an out-of-dialect keyword and an invented one.
|
|
63
|
+
*/
|
|
64
|
+
export const NON_DRAFT07_DIALECTS = Object.freeze({
|
|
65
|
+
"json-schema.org/schema": "the unversioned alias, which names whichever draft the site publishes " +
|
|
66
|
+
"as current rather than a fixed one",
|
|
67
|
+
"json-schema.org/draft-03/schema": "Draft-03, which spells `required` as a boolean on each property",
|
|
68
|
+
"json-schema.org/draft-04/schema": "Draft-04, whose `exclusiveMaximum` is a boolean modifier, not a bound",
|
|
69
|
+
"json-schema.org/draft-06/schema": "Draft-06, which has no `$comment` and reads `$ref` the Draft-07 way",
|
|
70
|
+
"json-schema.org/draft/2019-09/schema": "2019-09, where `$ref` is an ordinary applicator and its siblings are " +
|
|
71
|
+
"applied, and `format` asserts nothing unless the vocabulary asks",
|
|
72
|
+
"json-schema.org/draft/2020-12/schema": "2020-12, where `$ref` siblings are applied, the tuple form of `items` " +
|
|
73
|
+
"is spelled `prefixItems`, and `format` asserts nothing by default",
|
|
74
|
+
});
|
|
75
|
+
const UNRECOGNISED_DIALECT = "a meta-schema Luq has no reading for, so what its keywords mean is unknown";
|
|
76
|
+
const DIALECT_VALUE_REASON = "the value must be a URI string";
|
|
77
|
+
/**
|
|
78
|
+
* Reads the root `$schema` and refuses a dialect Luq does not implement.
|
|
79
|
+
*
|
|
80
|
+
* Called at both front doors, immediately after "is this a schema at all" and
|
|
81
|
+
* before any keyword is read, so no validator that enforces less than its
|
|
82
|
+
* document states can be reached by either route.
|
|
83
|
+
*
|
|
84
|
+
* A non-string `$schema` is a malformed VALUE rather than an unimplemented
|
|
85
|
+
* dialect — the meta-schema types it as a string — so it goes to the class that
|
|
86
|
+
* answers that question, and it is refused under the opt-out too: the opt-out
|
|
87
|
+
* says "read this dialect as Draft-07", and a number names no dialect.
|
|
88
|
+
*/
|
|
89
|
+
export function assertSupportedDialect(document, options) {
|
|
90
|
+
// Read off an untyped object rather than through `Draft07SchemaObject`,
|
|
91
|
+
// which types `$schema` as a string: the document is whatever JSON.parse
|
|
92
|
+
// produced, and a `$schema` that is not a string is exactly what this has to
|
|
93
|
+
// be able to see. The §4.4 boolean form and every non-object fall out here,
|
|
94
|
+
// declaring no dialect.
|
|
95
|
+
if (!isPlainObject(document))
|
|
96
|
+
return;
|
|
97
|
+
const declared = document["$schema"];
|
|
98
|
+
if (declared === undefined)
|
|
99
|
+
return;
|
|
100
|
+
if (typeof declared !== "string") {
|
|
101
|
+
throw new MalformedSchemaError("$schema", DIALECT_VALUE_REASON, declared);
|
|
102
|
+
}
|
|
103
|
+
const normalised = normaliseDialectUri(declared);
|
|
104
|
+
if (normalised === DRAFT07_NORMALISED)
|
|
105
|
+
return;
|
|
106
|
+
if (options.assumeDraft07 === true)
|
|
107
|
+
return;
|
|
108
|
+
throw new UnsupportedDialectError(declared, NON_DRAFT07_DIALECTS[normalised] ?? UNRECOGNISED_DIALECT);
|
|
109
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { FieldBuilderSurface } from "../builder/builder-surface.types";
|
|
|
2
2
|
import type { FieldEntry } from "../builder/field-entry.types";
|
|
3
3
|
import type { Validator } from "../builder/validator.types";
|
|
4
4
|
import type { GlobalConfig } from "../types/global-config";
|
|
5
|
+
import type { DialectOptions } from "./assert-supported-dialect";
|
|
5
6
|
import type { JsonSchemaBag } from "./json-schema-bag.types";
|
|
6
7
|
/** A value that is not a schema at all. Named, and refused at the boundary. */
|
|
7
8
|
export declare class NotASchemaError extends Error {
|
|
@@ -9,10 +10,28 @@ export declare class NotASchemaError extends Error {
|
|
|
9
10
|
constructor(received: unknown);
|
|
10
11
|
}
|
|
11
12
|
/** One pending `.v()` per declared path, with its callback still unrun. */
|
|
12
|
-
export declare function buildFieldEntries(bag: JsonSchemaBag, schema: unknown): readonly FieldEntry[];
|
|
13
|
+
export declare function buildFieldEntries(bag: JsonSchemaBag, schema: unknown, options?: DialectOptions): readonly FieldEntry[];
|
|
13
14
|
/** The erased front door: the declarations, with nothing compiled yet. */
|
|
14
|
-
export declare function buildFromSchema(bag: JsonSchemaBag, schema: unknown, config?: GlobalConfig): FieldBuilderSurface;
|
|
15
|
+
export declare function buildFromSchema(bag: JsonSchemaBag, schema: unknown, config?: GlobalConfig, options?: DialectOptions): FieldBuilderSurface;
|
|
15
16
|
/**
|
|
17
|
+
* Converts a JSON Schema document into a validator.
|
|
18
|
+
*
|
|
19
|
+
* THE INPUT LIMIT IS DRAFT-07, and it is a limit on what may be handed in, not
|
|
20
|
+
* only a statement of how much of Draft-07 is covered. A document declaring
|
|
21
|
+
* 2019-09 or 2020-12 in its root `$schema` is REFUSED with an
|
|
22
|
+
* `UnsupportedDialectError` rather than read as Draft-07, because the two
|
|
23
|
+
* dialects disagree about what an unchanged keyword means: from 2019-09 on
|
|
24
|
+
* `$ref` is an ordinary applicator whose siblings are applied, while Draft-07
|
|
25
|
+
* §8.3 replaces the node, so `{"$ref": "#/$defs/name", "minLength": 5}` read as
|
|
26
|
+
* Draft-07 loses the `minLength` and accepts a value the document forbids. A
|
|
27
|
+
* document with NO `$schema` is read as Draft-07 and always has been. Pass
|
|
28
|
+
* `{ assumeDraft07: true }` as the fourth argument to read a newer-dialect
|
|
29
|
+
* document under Draft-07 rules deliberately, with that reading's consequences.
|
|
30
|
+
*
|
|
31
|
+
* `T` defaults to `Record<string, unknown>`, never `any`; under the default NO
|
|
32
|
+
* declared path is checked. See the header of this file, which owns that
|
|
33
|
+
* escape hatch.
|
|
34
|
+
*
|
|
16
35
|
* The declared type is put back on by `eraseSchemaValidator`, the fourth
|
|
17
36
|
* function in src/core/type-erasure.ts — the one file the code standard allows
|
|
18
37
|
* to assert. Step 25 expressed this as an OVERLOAD PAIR instead, because
|
|
@@ -20,4 +39,4 @@ export declare function buildFromSchema(bag: JsonSchemaBag, schema: unknown, con
|
|
|
20
39
|
* TypeScript on lenient compatibility rules and so was never audited anywhere.
|
|
21
40
|
* The call below is, in the file where every escape hatch is reviewed together.
|
|
22
41
|
*/
|
|
23
|
-
export declare function fromJsonSchema<T extends object = Record<string, unknown>>(bag: JsonSchemaBag, schema: unknown, config?: GlobalConfig): Validator<T>;
|
|
42
|
+
export declare function fromJsonSchema<T extends object = Record<string, unknown>>(bag: JsonSchemaBag, schema: unknown, config?: GlobalConfig, options?: DialectOptions): Validator<T>;
|