@maroonedog/luq 2.0.0 → 2.1.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 +100 -10
- package/dist/chain/create-chain-node.js +22 -1
- package/dist/chain/create-chain-node.mjs +22 -1
- package/dist/compile/resolve-conditional-presence.js +1 -0
- package/dist/compile/resolve-conditional-presence.mjs +1 -0
- package/dist/compile/resolve-presence.js +4 -0
- package/dist/compile/resolve-presence.mjs +4 -0
- package/dist/compile/validation-plan.types.d.ts +10 -0
- package/dist/json-schema/build-from-schema.js +2 -3
- package/dist/json-schema/build-from-schema.mjs +2 -3
- package/dist/json-schema/collect-definitions.d.ts +30 -0
- package/dist/json-schema/collect-definitions.js +47 -3
- package/dist/json-schema/collect-definitions.mjs +46 -4
- package/dist/json-schema/collect-sub-schema-rules.d.ts +12 -1
- package/dist/json-schema/collect-sub-schema-rules.js +37 -7
- package/dist/json-schema/collect-sub-schema-rules.mjs +38 -8
- package/dist/json-schema/create-structural-context.d.ts +13 -3
- package/dist/json-schema/create-structural-context.js +72 -6
- package/dist/json-schema/create-structural-context.mjs +72 -7
- package/dist/json-schema/declare-additional-properties.d.ts +19 -0
- package/dist/json-schema/declare-additional-properties.js +42 -0
- package/dist/json-schema/declare-additional-properties.mjs +37 -0
- package/dist/json-schema/declare-object-keywords.d.ts +0 -2
- package/dist/json-schema/declare-object-keywords.js +6 -13
- package/dist/json-schema/declare-object-keywords.mjs +7 -13
- package/dist/json-schema/declare-presence.d.ts +9 -3
- package/dist/json-schema/declare-presence.js +12 -6
- package/dist/json-schema/declare-presence.mjs +12 -6
- package/dist/json-schema/extensions/json-schema/index.d.ts +1 -0
- package/dist/json-schema/extensions/json-schema/json-schema.d.ts +17 -2
- package/dist/json-schema/extensions/json-schema/json-schema.js +15 -7
- package/dist/json-schema/extensions/json-schema/json-schema.mjs +16 -8
- package/dist/json-schema/extensions/json-schema-full-feature/index.d.ts +1 -0
- package/dist/json-schema/extensions/json-schema-full-feature/json-schema-full-feature.d.ts +2 -1
- package/dist/json-schema/extensions/json-schema-full-feature/json-schema-full-feature.js +3 -1
- package/dist/json-schema/extensions/json-schema-full-feature/json-schema-full-feature.mjs +3 -1
- package/dist/json-schema/follow-json-pointer.d.ts +23 -0
- package/dist/json-schema/follow-json-pointer.js +87 -0
- package/dist/json-schema/follow-json-pointer.mjs +82 -0
- package/dist/json-schema/index.d.ts +7 -2
- package/dist/json-schema/index.js +10 -2
- package/dist/json-schema/index.mjs +5 -2
- package/dist/json-schema/keyword-map-object.d.ts +1 -1
- package/dist/json-schema/ref-resolution-error.d.ts +4 -0
- package/dist/json-schema/ref-resolution-error.js +20 -0
- package/dist/json-schema/ref-resolution-error.mjs +16 -0
- package/dist/json-schema/ref-scope.d.ts +18 -0
- package/dist/json-schema/ref-scope.js +37 -0
- package/dist/json-schema/ref-scope.mjs +33 -0
- package/dist/json-schema/resolve-ref.d.ts +14 -9
- package/dist/json-schema/resolve-ref.js +89 -59
- package/dist/json-schema/resolve-ref.mjs +86 -55
- package/dist/json-schema/schema-registry.d.ts +30 -0
- package/dist/json-schema/schema-registry.js +142 -0
- package/dist/json-schema/schema-registry.mjs +139 -0
- package/dist/json-schema/schema-to-declarations.js +2 -1
- package/dist/json-schema/schema-to-declarations.mjs +2 -1
- package/dist/json-schema/structural-expansion.types.d.ts +8 -1
- package/dist/json-schema/uri-reference.d.ts +29 -0
- package/dist/json-schema/uri-reference.js +118 -0
- package/dist/json-schema/uri-reference.mjs +111 -0
- package/dist/path/create-value-writer.js +27 -2
- package/dist/path/create-value-writer.mjs +27 -2
- package/dist/path/reserved-segment.d.ts +22 -4
- package/dist/path/reserved-segment.js +27 -7
- package/dist/path/reserved-segment.mjs +27 -7
- package/dist/plugin-kit/compiled-rule.d.ts +13 -0
- package/dist/plugin-kit/create-rule.d.ts +11 -0
- package/dist/plugin-kit/create-rule.js +1 -0
- package/dist/plugin-kit/create-rule.mjs +1 -0
- package/dist/plugin-kit/plugin-definition.d.ts +15 -0
- package/dist/plugin-kit/plugin-definition.js +1 -0
- package/dist/plugin-kit/plugin-definition.mjs +1 -0
- package/dist/plugins/object-additional-properties/object-additional-properties-schema.d.ts +1 -1
- package/dist/plugins/object-additional-properties/object-additional-properties-schema.js +4 -2
- package/dist/plugins/object-additional-properties/object-additional-properties-schema.mjs +4 -2
- package/dist/plugins/object-additional-properties/object-additional-properties.d.ts +1 -1
- package/dist/plugins/object-additional-properties/object-additional-properties.js +4 -2
- package/dist/plugins/object-additional-properties/object-additional-properties.mjs +4 -2
- package/dist/plugins/object-additional-properties/select-additional-keys.d.ts +15 -0
- package/dist/plugins/object-additional-properties/select-additional-keys.js +51 -0
- package/dist/plugins/object-additional-properties/select-additional-keys.mjs +47 -0
- package/dist/plugins/string-exact-length/string-exact-length.js +2 -2
- package/dist/plugins/string-exact-length/string-exact-length.mjs +3 -3
- package/dist/plugins/string-max/string-max.js +2 -2
- package/dist/plugins/string-max/string-max.mjs +3 -3
- package/dist/plugins/string-min/string-min.js +2 -2
- package/dist/plugins/string-min/string-min.mjs +3 -3
- package/dist/runtime/decide-presence.js +2 -1
- package/dist/runtime/decide-presence.mjs +2 -1
- package/dist/standard-schema/index.d.ts +6 -0
- package/dist/standard-schema/index.js +7 -0
- package/dist/standard-schema/index.mjs +2 -0
- package/dist/standard-schema/split-issue-path.d.ts +9 -0
- package/dist/standard-schema/split-issue-path.js +66 -0
- package/dist/standard-schema/split-issue-path.mjs +63 -0
- package/dist/standard-schema/standard-schema.types.d.ts +46 -0
- package/dist/standard-schema/standard-schema.types.js +13 -0
- package/dist/standard-schema/standard-schema.types.mjs +12 -0
- package/dist/standard-schema/to-standard-schema.d.ts +41 -0
- package/dist/standard-schema/to-standard-schema.js +60 -0
- package/dist/standard-schema/to-standard-schema.mjs +57 -0
- package/dist/standard-schema.d.ts +1 -0
- package/dist/standard-schema.js +2 -0
- package/dist/standard-schema.mjs +1 -0
- package/dist/types/index.d.ts +12 -0
- package/dist/types/index.js +18 -0
- package/dist/types/index.mjs +17 -0
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -6,13 +6,42 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/@maroonedog/luq)
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
8
|
|
|
9
|
-
**A TypeScript validation library
|
|
9
|
+
**A TypeScript validation library where the wrong rule does not compile.**
|
|
10
10
|
|
|
11
11
|
</div>
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
Your types were probably not written by you. `openapi-typescript` generates
|
|
14
|
+
them from a spec you do not own. Prisma and Drizzle generate them from the
|
|
15
|
+
schema of record. protobuf and GraphQL codegen generate them for services in
|
|
16
|
+
four languages at once. Increasingly, a model generates the code that uses them.
|
|
17
|
+
|
|
18
|
+
A validator whose schema is the source of truth assumes you are the one who
|
|
19
|
+
decides the shape. When you are not, it asks you to write that shape a second
|
|
20
|
+
time and keep the copy in step by hand — and nothing checks that the two still
|
|
21
|
+
agree. They drift, and the first sign is a value that passed the copy and does
|
|
22
|
+
not fit the original.
|
|
23
|
+
|
|
24
|
+
Luq runs the other way. It takes the type you already have and lets you declare
|
|
25
|
+
rules against its field paths. What makes those declarations worth writing is
|
|
26
|
+
that the compiler checks them against the type: a rule that does not apply to
|
|
27
|
+
the field it is written on is a compile error, not a rule that quietly never
|
|
28
|
+
fires.
|
|
29
|
+
|
|
30
|
+
That matters most when the code calling this library is generated rather than
|
|
31
|
+
typed by hand. A generator that picks the wrong rule, misspells a path or drops
|
|
32
|
+
an array wildcard gets a red squiggle, not a validator that passes everything.
|
|
33
|
+
|
|
34
|
+
| Mistake | Result |
|
|
35
|
+
|---|---|
|
|
36
|
+
| A slot unrelated to the field's type (`b.string` on a `number`) | compile error |
|
|
37
|
+
| A missing `[*]` (`"items.name"`) | compile error |
|
|
38
|
+
| Descending into a built-in (`"when.getTime"` on a `Date`) | compile error |
|
|
39
|
+
| A method that does not exist inside an element sub-chain | compile error |
|
|
40
|
+
| A JSON Schema keyword bound to a chain method that does not exist | compile error |
|
|
41
|
+
| A documented example drifting from the API | fails CI |
|
|
42
|
+
|
|
43
|
+
Every rule you can call is a plugin you imported by name, so the bundle contains
|
|
44
|
+
what you used and nothing else.
|
|
16
45
|
|
|
17
46
|
Every number on this page was measured on this repository. Where a measurement
|
|
18
47
|
is worse than the 1.x release, it is written down as worse. The provenance of
|
|
@@ -21,7 +50,7 @@ each figure is named next to it.
|
|
|
21
50
|
## Install
|
|
22
51
|
|
|
23
52
|
```bash
|
|
24
|
-
npm install @maroonedog/luq
|
|
53
|
+
npm install @maroonedog/luq
|
|
25
54
|
```
|
|
26
55
|
|
|
27
56
|
## Quick start
|
|
@@ -179,17 +208,78 @@ Measured Draft-07 conformance against the official
|
|
|
179
208
|
[JSON-Schema-Test-Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite)
|
|
180
209
|
(required tests only, skipped cases counted as **failures**):
|
|
181
210
|
|
|
182
|
-
**
|
|
183
|
-
|
|
184
|
-
|
|
211
|
+
**929 / 929 = 100.00%.** Read that against the floor, not against zero: a
|
|
212
|
+
validator that returned `true` unconditionally scores 551 / 929 = 59.31% on
|
|
213
|
+
this corpus. The skip list is empty, and its `cause` union is `never`, so
|
|
214
|
+
excluding a case again means adding a name to a type. Full breakdown, including
|
|
215
|
+
what closed each cause and what is still bounded:
|
|
185
216
|
[docs/json-schema-conformance.md](docs/json-schema-conformance.md).
|
|
186
217
|
|
|
218
|
+
External `$ref` resolves against a map of documents **you** already have —
|
|
219
|
+
`jsonSchemaFullFeature(document, { externalDocuments })`. Luq never fetches, so
|
|
220
|
+
a URI written in a schema cannot make the process open a socket, conversion
|
|
221
|
+
stays synchronous, and nothing is evaluated.
|
|
222
|
+
|
|
187
223
|
`jsonSchemaFullFeature` bundles 49 plugins so one import covers a whole
|
|
188
224
|
document. `@maroonedog/luq/plugins/jsonSchema` adds a chain method instead, so a
|
|
189
225
|
single declared field can be constrained by a document; it takes the plugin bag
|
|
190
226
|
explicitly. It is billed as the tree-shakeable half, and measured below, it is
|
|
191
227
|
not — use it for the chain method, not to save bytes.
|
|
192
228
|
|
|
229
|
+
## Standard Schema
|
|
230
|
+
|
|
231
|
+
Luq implements [Standard Schema v1](https://standardschema.dev). Anything that
|
|
232
|
+
accepts a Standard Schema — tRPC, TanStack Form, Hono, t3-env — accepts a Luq
|
|
233
|
+
validator wherever it accepts a zod schema.
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
import { Builder } from "@maroonedog/luq";
|
|
237
|
+
import { requiredPlugin } from "@maroonedog/luq/plugins/required";
|
|
238
|
+
import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
|
|
239
|
+
import { toStandardSchema } from "@maroonedog/luq/standard-schema";
|
|
240
|
+
|
|
241
|
+
type Account = { handle: string };
|
|
242
|
+
|
|
243
|
+
const standard = toStandardSchema(
|
|
244
|
+
Builder()
|
|
245
|
+
.use(requiredPlugin)
|
|
246
|
+
.use(stringMinPlugin)
|
|
247
|
+
.for<Account>()
|
|
248
|
+
.v("handle", (b) => b.string.required().min(2))
|
|
249
|
+
.build()
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// `standard` is still the Validator — `validate`, `parse`, `pick` and `pickAll`
|
|
253
|
+
// are all there — and it now also satisfies Standard Schema v1, so it can be
|
|
254
|
+
// handed to tRPC, TanStack Form, Hono or t3-env unchanged.
|
|
255
|
+
const outcome = standard["~standard"].validate({ handle: "j" });
|
|
256
|
+
|
|
257
|
+
if (outcome.issues === undefined) {
|
|
258
|
+
console.error(outcome.value.handle);
|
|
259
|
+
} else {
|
|
260
|
+
for (const issue of outcome.issues) {
|
|
261
|
+
console.error(issue.message, issue.path);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Three decisions the spec leaves open, made explicit here:
|
|
267
|
+
|
|
268
|
+
- `validate` calls Luq's `parse()`, not `validate()`. The spec's success result
|
|
269
|
+
is `{ value: Output }`, and `Output` is the value *after* validation — so a
|
|
270
|
+
`transform` has to be applied, and only `parse()` applies it.
|
|
271
|
+
- It collects every issue rather than stopping at the first. The consumer of
|
|
272
|
+
this seam is a form, and returning one issue at a time produces a UI where
|
|
273
|
+
fixing an error reveals the next one. Callers who want the fast path use the
|
|
274
|
+
`Validator` directly.
|
|
275
|
+
- `InferInput` is the type you wrote in `.for<T>()`, not a type inferred back
|
|
276
|
+
out of a schema value.
|
|
277
|
+
|
|
278
|
+
It is a subpath, not part of `build()`. Measured: the core gzips to 7,420 B and
|
|
279
|
+
carrying `~standard` on every validator adds 312 B — 4.2% charged to everyone,
|
|
280
|
+
including the people who never pass a validator to tRPC. Importing the subpath
|
|
281
|
+
costs those 312 B only when you import it, and nothing when you don't.
|
|
282
|
+
|
|
193
283
|
## Your own rules
|
|
194
284
|
|
|
195
285
|
```ts
|
|
@@ -374,8 +464,8 @@ the 84 keys** against the published declarations under **both** `node16` and
|
|
|
374
464
|
every subpath, method and slot
|
|
375
465
|
- **[Breaking changes from 1.x](docs/migration/breaking-changes.md)** — every
|
|
376
466
|
incompatibility with the fix beside it
|
|
377
|
-
- **[Draft-07 conformance](docs/json-schema-conformance.md)** — the
|
|
378
|
-
|
|
467
|
+
- **[Draft-07 conformance](docs/json-schema-conformance.md)** — the 100% and
|
|
468
|
+
what closed each of the ten causes that used to fail
|
|
379
469
|
|
|
380
470
|
## About the "universal platform" goal
|
|
381
471
|
|
|
@@ -22,6 +22,7 @@ exports.readChainRules = readChainRules;
|
|
|
22
22
|
// needs no assertion and no runtime shape check.
|
|
23
23
|
// ===========================================================================
|
|
24
24
|
const types_1 = require("../types");
|
|
25
|
+
const create_rule_1 = require("../plugin-kit/create-rule");
|
|
25
26
|
const refine_methods_types_1 = require("./refine-methods.types");
|
|
26
27
|
const attach_slot_methods_1 = require("./attach-slot-methods");
|
|
27
28
|
const chainRulesByNode = new WeakMap();
|
|
@@ -52,13 +53,33 @@ function buildRuleContext(wiring, plugin, rawOptions) {
|
|
|
52
53
|
declaredSiblingKeys: wiring.context.declaredSiblingKeys,
|
|
53
54
|
};
|
|
54
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The presence rule a `judgesNull` plugin brings with it. It forbids
|
|
58
|
+
* NOTHING — it only says that null must reach the checks instead of ending
|
|
59
|
+
* the field, which is the one thing a check cannot say for itself.
|
|
60
|
+
*/
|
|
61
|
+
function nullIsAValue(severity) {
|
|
62
|
+
return (0, create_rule_1.presence)({
|
|
63
|
+
code: "type",
|
|
64
|
+
severity,
|
|
65
|
+
allowUndefined: true,
|
|
66
|
+
allowNull: true,
|
|
67
|
+
emptyStringIsMissing: false,
|
|
68
|
+
nullIsValue: true,
|
|
69
|
+
describe: (messageContext) => `${messageContext.path} must not be null`,
|
|
70
|
+
buildMessageContext: () => ({}),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
55
73
|
function createSlotMethod(wiring, slot, rules, plugin) {
|
|
56
74
|
const arity = Math.max(plugin.build.length - 1, 0);
|
|
57
75
|
return (...args) => {
|
|
58
76
|
const rawOptions = args.length > arity ? args[arity] : undefined;
|
|
59
77
|
const resolved = wiring.resolveArguments(plugin, args.slice(0, arity));
|
|
60
78
|
const rule = plugin.build(buildRuleContext(wiring, plugin, rawOptions), ...resolved);
|
|
61
|
-
|
|
79
|
+
const added = plugin.judgesNull === true
|
|
80
|
+
? [nullIsAValue(wiring.context.config.defaultSeverity), rule]
|
|
81
|
+
: [rule];
|
|
82
|
+
return createChainNode(wiring, slot, [...rules, ...added]);
|
|
62
83
|
};
|
|
63
84
|
}
|
|
64
85
|
function attachRefineMethods(target, wiring, rules) {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// needs no assertion and no runtime shape check.
|
|
18
18
|
// ===========================================================================
|
|
19
19
|
import { isPlainObject, isString } from "../types/index.mjs";
|
|
20
|
+
import { presence } from "../plugin-kit/create-rule.mjs";
|
|
20
21
|
import { REFINE_METHOD_SLOTS } from "./refine-methods.types.mjs";
|
|
21
22
|
import { attachSlotMethods } from "./attach-slot-methods.mjs";
|
|
22
23
|
const chainRulesByNode = new WeakMap();
|
|
@@ -47,13 +48,33 @@ function buildRuleContext(wiring, plugin, rawOptions) {
|
|
|
47
48
|
declaredSiblingKeys: wiring.context.declaredSiblingKeys,
|
|
48
49
|
};
|
|
49
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* The presence rule a `judgesNull` plugin brings with it. It forbids
|
|
53
|
+
* NOTHING — it only says that null must reach the checks instead of ending
|
|
54
|
+
* the field, which is the one thing a check cannot say for itself.
|
|
55
|
+
*/
|
|
56
|
+
function nullIsAValue(severity) {
|
|
57
|
+
return presence({
|
|
58
|
+
code: "type",
|
|
59
|
+
severity,
|
|
60
|
+
allowUndefined: true,
|
|
61
|
+
allowNull: true,
|
|
62
|
+
emptyStringIsMissing: false,
|
|
63
|
+
nullIsValue: true,
|
|
64
|
+
describe: (messageContext) => `${messageContext.path} must not be null`,
|
|
65
|
+
buildMessageContext: () => ({}),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
50
68
|
function createSlotMethod(wiring, slot, rules, plugin) {
|
|
51
69
|
const arity = Math.max(plugin.build.length - 1, 0);
|
|
52
70
|
return (...args) => {
|
|
53
71
|
const rawOptions = args.length > arity ? args[arity] : undefined;
|
|
54
72
|
const resolved = wiring.resolveArguments(plugin, args.slice(0, arity));
|
|
55
73
|
const rule = plugin.build(buildRuleContext(wiring, plugin, rawOptions), ...resolved);
|
|
56
|
-
|
|
74
|
+
const added = plugin.judgesNull === true
|
|
75
|
+
? [nullIsAValue(wiring.context.config.defaultSeverity), rule]
|
|
76
|
+
: [rule];
|
|
77
|
+
return createChainNode(wiring, slot, [...rules, ...added]);
|
|
57
78
|
};
|
|
58
79
|
}
|
|
59
80
|
function attachRefineMethods(target, wiring, rules) {
|
|
@@ -33,6 +33,7 @@ function toPolicy(rule, allowance) {
|
|
|
33
33
|
allowUndefined: allowance.allowUndefined,
|
|
34
34
|
allowNull: allowance.allowNull,
|
|
35
35
|
emptyStringIsMissing: allowance.emptyStringIsMissing,
|
|
36
|
+
nullIsValue: allowance.nullIsValue === true,
|
|
36
37
|
// Wrapped rather than aliased, so a `describe` written as an object
|
|
37
38
|
// method keeps its receiver.
|
|
38
39
|
describe: (messageContext) => rule.describe(messageContext),
|
|
@@ -29,6 +29,7 @@ function toPolicy(rule, allowance) {
|
|
|
29
29
|
allowUndefined: allowance.allowUndefined,
|
|
30
30
|
allowNull: allowance.allowNull,
|
|
31
31
|
emptyStringIsMissing: allowance.emptyStringIsMissing,
|
|
32
|
+
nullIsValue: allowance.nullIsValue === true,
|
|
32
33
|
// Wrapped rather than aliased, so a `describe` written as an object
|
|
33
34
|
// method keeps its receiver.
|
|
34
35
|
describe: (messageContext) => rule.describe(messageContext),
|
|
@@ -11,6 +11,7 @@ const OPEN_POLICY = {
|
|
|
11
11
|
allowUndefined: true,
|
|
12
12
|
allowNull: true,
|
|
13
13
|
emptyStringIsMissing: false,
|
|
14
|
+
nullIsValue: false,
|
|
14
15
|
describe: () => "This field declares no presence rule",
|
|
15
16
|
};
|
|
16
17
|
exports.OPEN_PRESENCE = Object.freeze(OPEN_POLICY);
|
|
@@ -24,6 +25,9 @@ function resolvePresence(rules) {
|
|
|
24
25
|
allowUndefined: rules.some((rule) => rule.allowUndefined),
|
|
25
26
|
allowNull: rules.some((rule) => rule.allowNull),
|
|
26
27
|
emptyStringIsMissing: rules.some((rule) => rule.emptyStringIsMissing),
|
|
28
|
+
// One rule saying "null is a value here" is enough: the checks then run
|
|
29
|
+
// and decide, which is strictly more judgement, never less.
|
|
30
|
+
nullIsValue: rules.some((rule) => rule.nullIsValue === true),
|
|
27
31
|
describe: (ctx) => strictest.describe(ctx),
|
|
28
32
|
};
|
|
29
33
|
return Object.freeze(policy);
|
|
@@ -7,6 +7,7 @@ const OPEN_POLICY = {
|
|
|
7
7
|
allowUndefined: true,
|
|
8
8
|
allowNull: true,
|
|
9
9
|
emptyStringIsMissing: false,
|
|
10
|
+
nullIsValue: false,
|
|
10
11
|
describe: () => "This field declares no presence rule",
|
|
11
12
|
};
|
|
12
13
|
export const OPEN_PRESENCE = Object.freeze(OPEN_POLICY);
|
|
@@ -20,6 +21,9 @@ export function resolvePresence(rules) {
|
|
|
20
21
|
allowUndefined: rules.some((rule) => rule.allowUndefined),
|
|
21
22
|
allowNull: rules.some((rule) => rule.allowNull),
|
|
22
23
|
emptyStringIsMissing: rules.some((rule) => rule.emptyStringIsMissing),
|
|
24
|
+
// One rule saying "null is a value here" is enough: the checks then run
|
|
25
|
+
// and decide, which is strictly more judgement, never less.
|
|
26
|
+
nullIsValue: rules.some((rule) => rule.nullIsValue === true),
|
|
23
27
|
describe: (ctx) => strictest.describe(ctx),
|
|
24
28
|
};
|
|
25
29
|
return Object.freeze(policy);
|
|
@@ -23,6 +23,16 @@ export interface PresencePolicy {
|
|
|
23
23
|
readonly allowUndefined: boolean;
|
|
24
24
|
readonly allowNull: boolean;
|
|
25
25
|
readonly emptyStringIsMissing: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* True when `null` is a VALUE this subject has to judge, rather than an
|
|
28
|
+
* absence presence may settle. Draft-07 sub-schemas need it: `false`,
|
|
29
|
+
* `{"not": {}}` and an `enum` without null all forbid null WITHOUT saying
|
|
30
|
+
* anything about `type`, and presence — which runs first — was answering
|
|
31
|
+
* for them. `[null]` passed `{"items":{"not":{}}}` because no check ever
|
|
32
|
+
* ran. Only src/json-schema/ sets it; a field the user declared keeps the
|
|
33
|
+
* builder's meaning, where `.nullable()` ends the field.
|
|
34
|
+
*/
|
|
35
|
+
readonly nullIsValue: boolean;
|
|
26
36
|
describe(ctx: MessageContext): string;
|
|
27
37
|
}
|
|
28
38
|
/**
|
|
@@ -8,7 +8,7 @@ const create_field_builder_1 = require("../builder/create-field-builder");
|
|
|
8
8
|
const type_erasure_1 = require("../core/type-erasure");
|
|
9
9
|
const create_structural_context_1 = require("./create-structural-context");
|
|
10
10
|
const declare_presence_1 = require("./declare-presence");
|
|
11
|
-
const
|
|
11
|
+
const ref_scope_1 = require("./ref-scope");
|
|
12
12
|
const draft07_types_1 = require("./draft07.types");
|
|
13
13
|
const flatten_schema_1 = require("./flatten-schema");
|
|
14
14
|
const schema_to_declarations_1 = require("./schema-to-declarations");
|
|
@@ -25,11 +25,10 @@ class NotASchemaError extends Error {
|
|
|
25
25
|
exports.NotASchemaError = NotASchemaError;
|
|
26
26
|
/** The rules ONE declared path carries: its presence policy and its schema. */
|
|
27
27
|
function collectDeclaredRules(declaration, bag, root, chain) {
|
|
28
|
-
const context = (0, create_structural_context_1.createStructuralContext)({ bag, root, chain }, declaration.schema, Object.freeze([]));
|
|
28
|
+
const context = (0, create_structural_context_1.createStructuralContext)({ bag, scope: (0, ref_scope_1.createLocalScope)(root), chain }, declaration.schema, Object.freeze([]));
|
|
29
29
|
return Object.freeze([
|
|
30
30
|
...(0, declare_presence_1.declarePresenceRules)({
|
|
31
31
|
isRequired: declaration.isRequired,
|
|
32
|
-
allowsNull: (0, declare_value_keywords_1.permitsNull)(declaration.schema),
|
|
33
32
|
severity: chain.config.defaultSeverity,
|
|
34
33
|
}),
|
|
35
34
|
...(0, schema_to_declarations_1.expandSchemaRules)(declaration.schema, context),
|
|
@@ -2,7 +2,7 @@ import { createFieldBuilderSurface } from "../builder/create-field-builder.mjs";
|
|
|
2
2
|
import { eraseSchemaValidator } from "../core/type-erasure.mjs";
|
|
3
3
|
import { createStructuralContext } from "./create-structural-context.mjs";
|
|
4
4
|
import { declarePresenceRules } from "./declare-presence.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { createLocalScope } from "./ref-scope.mjs";
|
|
6
6
|
import { isDraft07Schema } from "./draft07.types.mjs";
|
|
7
7
|
import { flattenSchema } from "./flatten-schema.mjs";
|
|
8
8
|
import { expandSchemaRules, readChildSchemas } from "./schema-to-declarations.mjs";
|
|
@@ -18,11 +18,10 @@ export class NotASchemaError extends Error {
|
|
|
18
18
|
}
|
|
19
19
|
/** The rules ONE declared path carries: its presence policy and its schema. */
|
|
20
20
|
function collectDeclaredRules(declaration, bag, root, chain) {
|
|
21
|
-
const context = createStructuralContext({ bag, root, chain }, declaration.schema, Object.freeze([]));
|
|
21
|
+
const context = createStructuralContext({ bag, scope: createLocalScope(root), chain }, declaration.schema, Object.freeze([]));
|
|
22
22
|
return Object.freeze([
|
|
23
23
|
...declarePresenceRules({
|
|
24
24
|
isRequired: declaration.isRequired,
|
|
25
|
-
allowsNull: permitsNull(declaration.schema),
|
|
26
25
|
severity: chain.config.defaultSeverity,
|
|
27
26
|
}),
|
|
28
27
|
...expandSchemaRules(declaration.schema, context),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Draft07Schema, Draft07SchemaObject } from "./draft07.types";
|
|
2
|
+
import type { RefScope } from "./ref-scope";
|
|
2
3
|
/** The two container spellings, in the order a pointer would try them. */
|
|
3
4
|
export declare const DEFINITION_CONTAINERS: readonly string[];
|
|
4
5
|
export declare function isDefinitionContainer(keyword: string): boolean;
|
|
@@ -10,6 +11,35 @@ export declare function toSchemaObject(schema: Draft07Schema): Draft07SchemaObje
|
|
|
10
11
|
* this draft `$ref` replaces the object it appears in.
|
|
11
12
|
*/
|
|
12
13
|
export declare function resolveSchemaNode(schema: Draft07Schema, root: Draft07Schema): Draft07SchemaObject;
|
|
14
|
+
/** What a node resolved to, and the scope that is in force INSIDE it. */
|
|
15
|
+
export interface ResolvedNode {
|
|
16
|
+
readonly node: Draft07SchemaObject;
|
|
17
|
+
readonly scope: RefScope;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The scope-aware form, and the one the converter uses.
|
|
21
|
+
*
|
|
22
|
+
* The returned scope is not the one passed in: following a `$ref` can cross
|
|
23
|
+
* into another document, and a `$ref` written inside THAT document resolves
|
|
24
|
+
* against ITS base. Returning only the node — which is what the root-taking
|
|
25
|
+
* form above can do — loses that, and is why a two-document schema resolved
|
|
26
|
+
* its second hop against the first document.
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveSchemaNodeInScope(schema: Draft07Schema, scope: RefScope): ResolvedNode;
|
|
29
|
+
/**
|
|
30
|
+
* The ONE place a base URI advances. It was also being done by the caller,
|
|
31
|
+
* and doing it in both meant a relative `$id` was applied twice: a `$ref`
|
|
32
|
+
* of "nested/foo.json" under `$id: "nested/"` went looking for
|
|
33
|
+
* ".../nested/nested/foo.json".
|
|
34
|
+
*
|
|
35
|
+
* A base-setting `$id` starts a new RESOURCE, so the node also becomes the
|
|
36
|
+
* document that `#/definitions/x` written under it resolves against. Moving
|
|
37
|
+
* the base without moving the document is why
|
|
38
|
+
* `{"$id":"a.json","properties":{"foo":{"$id":"b.json","definitions":{...},
|
|
39
|
+
* "allOf":[{"$ref":"#/definitions/inner"}]}}}` looked for `inner` in the
|
|
40
|
+
* OUTER document, where it does not exist.
|
|
41
|
+
*/
|
|
42
|
+
export declare function advanceBase(scope: RefScope, node: Draft07SchemaObject): RefScope;
|
|
13
43
|
/**
|
|
14
44
|
* The pointer a node forwards to, or undefined. Used by the flattener to spot
|
|
15
45
|
* a recursive definition BEFORE it walks into it: `#/definitions/node` inside
|
|
@@ -4,10 +4,14 @@ exports.DEFINITION_CONTAINERS = void 0;
|
|
|
4
4
|
exports.isDefinitionContainer = isDefinitionContainer;
|
|
5
5
|
exports.toSchemaObject = toSchemaObject;
|
|
6
6
|
exports.resolveSchemaNode = resolveSchemaNode;
|
|
7
|
+
exports.resolveSchemaNodeInScope = resolveSchemaNodeInScope;
|
|
8
|
+
exports.advanceBase = advanceBase;
|
|
7
9
|
exports.readRefPointer = readRefPointer;
|
|
8
10
|
exports.collectDefinitionNames = collectDefinitionNames;
|
|
9
11
|
const draft07_types_1 = require("./draft07.types");
|
|
12
|
+
const ref_scope_1 = require("./ref-scope");
|
|
10
13
|
const resolve_ref_1 = require("./resolve-ref");
|
|
14
|
+
const uri_reference_1 = require("./uri-reference");
|
|
11
15
|
/** `true`: matches every instance. */
|
|
12
16
|
const ALWAYS_SCHEMA = Object.freeze({});
|
|
13
17
|
/** `false`: matches nothing. `not` of the empty schema, so no new rule kind. */
|
|
@@ -36,10 +40,50 @@ function toSchemaObject(schema) {
|
|
|
36
40
|
* this draft `$ref` replaces the object it appears in.
|
|
37
41
|
*/
|
|
38
42
|
function resolveSchemaNode(schema, root) {
|
|
43
|
+
return resolveSchemaNodeInScope(schema, (0, ref_scope_1.createLocalScope)(root)).node;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The scope-aware form, and the one the converter uses.
|
|
47
|
+
*
|
|
48
|
+
* The returned scope is not the one passed in: following a `$ref` can cross
|
|
49
|
+
* into another document, and a `$ref` written inside THAT document resolves
|
|
50
|
+
* against ITS base. Returning only the node — which is what the root-taking
|
|
51
|
+
* form above can do — loses that, and is why a two-document schema resolved
|
|
52
|
+
* its second hop against the first document.
|
|
53
|
+
*/
|
|
54
|
+
function resolveSchemaNodeInScope(schema, scope) {
|
|
39
55
|
const node = toSchemaObject(schema);
|
|
40
|
-
if (node.$ref === undefined)
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
if (node.$ref === undefined) {
|
|
57
|
+
// A node's own `$id` moves the base for everything inside it (§8.2).
|
|
58
|
+
return { node, scope: advanceBase(scope, node) };
|
|
59
|
+
}
|
|
60
|
+
// Following a `$ref` lands in whatever base the TARGET lives in, and
|
|
61
|
+
// resolve-ref already knows it — for a document fetched by URI that is the
|
|
62
|
+
// retrieval URI, which the draft says wins over the document's own `$id`.
|
|
63
|
+
const resolved = (0, resolve_ref_1.resolveRefInScope)(node.$ref, scope);
|
|
64
|
+
return { node: toSchemaObject(resolved.schema), scope: resolved.scope };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The ONE place a base URI advances. It was also being done by the caller,
|
|
68
|
+
* and doing it in both meant a relative `$id` was applied twice: a `$ref`
|
|
69
|
+
* of "nested/foo.json" under `$id: "nested/"` went looking for
|
|
70
|
+
* ".../nested/nested/foo.json".
|
|
71
|
+
*
|
|
72
|
+
* A base-setting `$id` starts a new RESOURCE, so the node also becomes the
|
|
73
|
+
* document that `#/definitions/x` written under it resolves against. Moving
|
|
74
|
+
* the base without moving the document is why
|
|
75
|
+
* `{"$id":"a.json","properties":{"foo":{"$id":"b.json","definitions":{...},
|
|
76
|
+
* "allOf":[{"$ref":"#/definitions/inner"}]}}}` looked for `inner` in the
|
|
77
|
+
* OUTER document, where it does not exist.
|
|
78
|
+
*/
|
|
79
|
+
function advanceBase(scope, node) {
|
|
80
|
+
const id = node.$id;
|
|
81
|
+
if (typeof id !== "string" || id === "")
|
|
82
|
+
return scope;
|
|
83
|
+
const baseUri = (0, uri_reference_1.nextBaseUri)(scope.baseUri, id);
|
|
84
|
+
if (baseUri === scope.baseUri)
|
|
85
|
+
return scope;
|
|
86
|
+
return { ...scope, baseUri, document: node };
|
|
43
87
|
}
|
|
44
88
|
/**
|
|
45
89
|
* The pointer a node forwards to, or undefined. Used by the flattener to spot
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { isSchemaObject } from "./draft07.types.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { createLocalScope } from "./ref-scope.mjs";
|
|
3
|
+
import { resolveRefInScope } from "./resolve-ref.mjs";
|
|
4
|
+
import { nextBaseUri } from "./uri-reference.mjs";
|
|
3
5
|
/** `true`: matches every instance. */
|
|
4
6
|
const ALWAYS_SCHEMA = Object.freeze({});
|
|
5
7
|
/** `false`: matches nothing. `not` of the empty schema, so no new rule kind. */
|
|
@@ -28,10 +30,50 @@ export function toSchemaObject(schema) {
|
|
|
28
30
|
* this draft `$ref` replaces the object it appears in.
|
|
29
31
|
*/
|
|
30
32
|
export function resolveSchemaNode(schema, root) {
|
|
33
|
+
return resolveSchemaNodeInScope(schema, createLocalScope(root)).node;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The scope-aware form, and the one the converter uses.
|
|
37
|
+
*
|
|
38
|
+
* The returned scope is not the one passed in: following a `$ref` can cross
|
|
39
|
+
* into another document, and a `$ref` written inside THAT document resolves
|
|
40
|
+
* against ITS base. Returning only the node — which is what the root-taking
|
|
41
|
+
* form above can do — loses that, and is why a two-document schema resolved
|
|
42
|
+
* its second hop against the first document.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveSchemaNodeInScope(schema, scope) {
|
|
31
45
|
const node = toSchemaObject(schema);
|
|
32
|
-
if (node.$ref === undefined)
|
|
33
|
-
|
|
34
|
-
|
|
46
|
+
if (node.$ref === undefined) {
|
|
47
|
+
// A node's own `$id` moves the base for everything inside it (§8.2).
|
|
48
|
+
return { node, scope: advanceBase(scope, node) };
|
|
49
|
+
}
|
|
50
|
+
// Following a `$ref` lands in whatever base the TARGET lives in, and
|
|
51
|
+
// resolve-ref already knows it — for a document fetched by URI that is the
|
|
52
|
+
// retrieval URI, which the draft says wins over the document's own `$id`.
|
|
53
|
+
const resolved = resolveRefInScope(node.$ref, scope);
|
|
54
|
+
return { node: toSchemaObject(resolved.schema), scope: resolved.scope };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The ONE place a base URI advances. It was also being done by the caller,
|
|
58
|
+
* and doing it in both meant a relative `$id` was applied twice: a `$ref`
|
|
59
|
+
* of "nested/foo.json" under `$id: "nested/"` went looking for
|
|
60
|
+
* ".../nested/nested/foo.json".
|
|
61
|
+
*
|
|
62
|
+
* A base-setting `$id` starts a new RESOURCE, so the node also becomes the
|
|
63
|
+
* document that `#/definitions/x` written under it resolves against. Moving
|
|
64
|
+
* the base without moving the document is why
|
|
65
|
+
* `{"$id":"a.json","properties":{"foo":{"$id":"b.json","definitions":{...},
|
|
66
|
+
* "allOf":[{"$ref":"#/definitions/inner"}]}}}` looked for `inner` in the
|
|
67
|
+
* OUTER document, where it does not exist.
|
|
68
|
+
*/
|
|
69
|
+
export function advanceBase(scope, node) {
|
|
70
|
+
const id = node.$id;
|
|
71
|
+
if (typeof id !== "string" || id === "")
|
|
72
|
+
return scope;
|
|
73
|
+
const baseUri = nextBaseUri(scope.baseUri, id);
|
|
74
|
+
if (baseUri === scope.baseUri)
|
|
75
|
+
return scope;
|
|
76
|
+
return { ...scope, baseUri, document: node };
|
|
35
77
|
}
|
|
36
78
|
/**
|
|
37
79
|
* The pointer a node forwards to, or undefined. Used by the flattener to spot
|
|
@@ -2,5 +2,16 @@ import type { CompositeBranch, Rule } from "../plugin-kit/compiled-rule";
|
|
|
2
2
|
import type { Draft07Schema } from "./draft07.types";
|
|
3
3
|
import type { StructuralContext } from "./structural-expansion.types";
|
|
4
4
|
export declare function collectSubSchemaRules(schema: Draft07Schema, context: StructuralContext): readonly Rule[];
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Every branch of every composite is built here, so a branch is one shape.
|
|
7
|
+
*
|
|
8
|
+
* The call is to the function above and NOT to `context.collectSubSchemaRules`,
|
|
9
|
+
* and the difference is load-bearing. `context` is already the child context
|
|
10
|
+
* that createStructuralContext produced by DESCENDING through this schema, so
|
|
11
|
+
* a `$ref` here is already recorded in `visitedRefs`. Going through the
|
|
12
|
+
* context would descend the same `$ref` a second time, the recursion guard
|
|
13
|
+
* would see it as a cycle, and the branch would come back with no rules at all
|
|
14
|
+
* — `{"items":[{"$ref":"#/definitions/x"}]}` constrained nothing while the
|
|
15
|
+
* inline form `{"items":[{"type":"integer"}]}` worked.
|
|
16
|
+
*/
|
|
6
17
|
export declare function toSchemaBranch(label: string, schema: Draft07Schema, context: StructuralContext): CompositeBranch;
|
|
@@ -24,18 +24,15 @@ const create_rule_1 = require("../plugin-kit/create-rule");
|
|
|
24
24
|
const types_1 = require("../types");
|
|
25
25
|
const collect_definitions_1 = require("./collect-definitions");
|
|
26
26
|
const declare_presence_1 = require("./declare-presence");
|
|
27
|
-
const declare_value_keywords_1 = require("./declare-value-keywords");
|
|
28
27
|
const flatten_array_schema_1 = require("./flatten-array-schema");
|
|
29
28
|
const schema_to_declarations_1 = require("./schema-to-declarations");
|
|
30
29
|
const NO_BRANCH_FIELDS = Object.freeze([]);
|
|
31
30
|
function toBranchField(child, context) {
|
|
32
|
-
const node = (0, collect_definitions_1.resolveSchemaNode)(child.schema, context.root);
|
|
33
31
|
return {
|
|
34
32
|
path: child.step,
|
|
35
33
|
rules: Object.freeze([
|
|
36
34
|
...(0, declare_presence_1.declarePresenceRules)({
|
|
37
35
|
isRequired: child.isRequired,
|
|
38
|
-
allowsNull: (0, declare_value_keywords_1.permitsNull)(node),
|
|
39
36
|
severity: context.build.config.defaultSeverity,
|
|
40
37
|
}),
|
|
41
38
|
...context.collectSubSchemaRules(child.schema),
|
|
@@ -56,9 +53,31 @@ function composeProperties(children, context) {
|
|
|
56
53
|
}),
|
|
57
54
|
];
|
|
58
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Every sub-schema subject gets the same one-line statement: null is a VALUE
|
|
58
|
+
* here, so the checks run on it.
|
|
59
|
+
*
|
|
60
|
+
* src/runtime/run-field.ts settles presence BEFORE any check, and a subject
|
|
61
|
+
* with no presence rule carries OPEN_PRESENCE, which ends the field on null.
|
|
62
|
+
* A branch subject and an array element both arrive without one, so every
|
|
63
|
+
* check the sub-schema declared was skipped for null: `[null]` passed
|
|
64
|
+
* `{"items":{"type":"boolean"}}`, and `additionalItems: false` accepted a
|
|
65
|
+
* trailing null. Deciding it from `type` alone is not enough either —
|
|
66
|
+
* `false`, `{"not": {}}` and an `enum` without null forbid null while
|
|
67
|
+
* saying nothing about `type`.
|
|
68
|
+
*/
|
|
69
|
+
function declareOwnNullPolicy(context) {
|
|
70
|
+
return (0, declare_presence_1.declarePresenceRules)({
|
|
71
|
+
isRequired: false,
|
|
72
|
+
severity: context.build.config.defaultSeverity,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
59
75
|
function collectSubSchemaRules(schema, context) {
|
|
60
|
-
const node = (0, collect_definitions_1.
|
|
61
|
-
const rules = [
|
|
76
|
+
const node = (0, collect_definitions_1.resolveSchemaNodeInScope)(schema, context.scope).node;
|
|
77
|
+
const rules = [
|
|
78
|
+
...declareOwnNullPolicy(context),
|
|
79
|
+
...(0, schema_to_declarations_1.expandSchemaRules)(node, context),
|
|
80
|
+
];
|
|
62
81
|
const children = (0, schema_to_declarations_1.readChildSchemas)(node);
|
|
63
82
|
const properties = children.filter((child) => child.step !== flatten_array_schema_1.EACH_STEP);
|
|
64
83
|
if (properties.length > 0) {
|
|
@@ -72,11 +91,22 @@ function collectSubSchemaRules(schema, context) {
|
|
|
72
91
|
}
|
|
73
92
|
return Object.freeze(rules);
|
|
74
93
|
}
|
|
75
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* Every branch of every composite is built here, so a branch is one shape.
|
|
96
|
+
*
|
|
97
|
+
* The call is to the function above and NOT to `context.collectSubSchemaRules`,
|
|
98
|
+
* and the difference is load-bearing. `context` is already the child context
|
|
99
|
+
* that createStructuralContext produced by DESCENDING through this schema, so
|
|
100
|
+
* a `$ref` here is already recorded in `visitedRefs`. Going through the
|
|
101
|
+
* context would descend the same `$ref` a second time, the recursion guard
|
|
102
|
+
* would see it as a cycle, and the branch would come back with no rules at all
|
|
103
|
+
* — `{"items":[{"$ref":"#/definitions/x"}]}` constrained nothing while the
|
|
104
|
+
* inline form `{"items":[{"type":"integer"}]}` worked.
|
|
105
|
+
*/
|
|
76
106
|
function toSchemaBranch(label, schema, context) {
|
|
77
107
|
return {
|
|
78
108
|
label,
|
|
79
|
-
rules:
|
|
109
|
+
rules: collectSubSchemaRules(schema, context),
|
|
80
110
|
fields: NO_BRANCH_FIELDS,
|
|
81
111
|
};
|
|
82
112
|
}
|