@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.
Files changed (47) hide show
  1. package/README.md +35 -8
  2. package/dist/chain/slot-type-guard.js +20 -6
  3. package/dist/chain/slot-type-guard.mjs +20 -6
  4. package/dist/compile/resolve-presence.js +36 -5
  5. package/dist/compile/resolve-presence.mjs +36 -5
  6. package/dist/json-schema/assert-supported-dialect.d.ts +37 -0
  7. package/dist/json-schema/assert-supported-dialect.js +113 -0
  8. package/dist/json-schema/assert-supported-dialect.mjs +109 -0
  9. package/dist/json-schema/build-from-schema.d.ts +22 -3
  10. package/dist/json-schema/build-from-schema.js +27 -5
  11. package/dist/json-schema/build-from-schema.mjs +27 -5
  12. package/dist/json-schema/declare-value-keywords.d.ts +0 -2
  13. package/dist/json-schema/declare-value-keywords.js +7 -10
  14. package/dist/json-schema/declare-value-keywords.mjs +7 -9
  15. package/dist/json-schema/extensions/json-schema/index.d.ts +2 -2
  16. package/dist/json-schema/extensions/json-schema/index.js +3 -1
  17. package/dist/json-schema/extensions/json-schema/index.mjs +1 -1
  18. package/dist/json-schema/extensions/json-schema/json-schema.d.ts +2 -2
  19. package/dist/json-schema/extensions/json-schema/json-schema.js +3 -0
  20. package/dist/json-schema/extensions/json-schema/json-schema.mjs +4 -1
  21. package/dist/json-schema/extensions/json-schema-full-feature/bundle-coverage.js +6 -1
  22. package/dist/json-schema/extensions/json-schema-full-feature/bundle-coverage.mjs +6 -1
  23. package/dist/json-schema/extensions/json-schema-full-feature/index.d.ts +3 -1
  24. package/dist/json-schema/extensions/json-schema-full-feature/index.js +18 -1
  25. package/dist/json-schema/extensions/json-schema-full-feature/index.mjs +11 -0
  26. package/dist/json-schema/extensions/json-schema-full-feature/json-schema-full-feature.d.ts +9 -1
  27. package/dist/json-schema/extensions/json-schema-full-feature/json-schema-full-feature.js +9 -2
  28. package/dist/json-schema/extensions/json-schema-full-feature/json-schema-full-feature.mjs +9 -2
  29. package/dist/json-schema/index.d.ts +3 -0
  30. package/dist/json-schema/index.js +7 -1
  31. package/dist/json-schema/index.mjs +2 -0
  32. package/dist/json-schema/keyword-map-core.js +3 -1
  33. package/dist/json-schema/keyword-map-core.mjs +3 -1
  34. package/dist/json-schema/keyword-map.js +3 -0
  35. package/dist/json-schema/keyword-map.mjs +3 -0
  36. package/dist/json-schema/schema-to-declarations.js +1 -4
  37. package/dist/json-schema/schema-to-declarations.mjs +2 -5
  38. package/dist/json-schema/unsupported-dialect-error.d.ts +21 -0
  39. package/dist/json-schema/unsupported-dialect-error.js +58 -0
  40. package/dist/json-schema/unsupported-dialect-error.mjs +54 -0
  41. package/dist/runtime/create-issue.d.ts +9 -0
  42. package/dist/runtime/create-issue.js +9 -2
  43. package/dist/runtime/create-issue.mjs +9 -2
  44. package/dist/runtime/run-field.js +1 -0
  45. package/dist/runtime/run-field.mjs +1 -0
  46. package/dist/types/index.d.ts +12 -0
  47. 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, stable across messages
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 Draft-07 conformance |
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
- 1.x described a `.luq` DSL that would generate validators for other languages,
324
- against dated milestones. Those dates have passed and none of it shipped, so the
325
- plan has been withdrawn rather than moved: no part of it is in this package, and
326
- this release makes no claim about when any of it will exist. What is in the box
327
- is the TypeScript validation library described above.
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: { accepts: (value) => value instanceof Date, noun: "a Date" },
14
- array: { accepts: types_1.isArray, noun: "an array" },
15
- object: { accepts: types_1.isPlainObject, noun: "an object" },
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: `${slot}Type`,
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: { accepts: (value) => value instanceof Date, noun: "a Date" },
11
- array: { accepts: isArray, noun: "an array" },
12
- object: { accepts: isPlainObject, noun: "an object" },
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: `${slot}Type`,
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 the lexicographically smaller code.
73
+ * ties broken by which absence it forbids.
44
74
  *
45
- * Tie-breaking on the CODE rather than on declaration order is what keeps the
46
- * whole policy — not merely its three flags — independent of the order the
47
- * rules arrived in. `.optional().nullable()` and `.nullable().optional()` are
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 || (difference === 0 && rule.code < strictest.code)) {
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 the lexicographically smaller code.
69
+ * ties broken by which absence it forbids.
40
70
  *
41
- * Tie-breaking on the CODE rather than on declaration order is what keeps the
42
- * whole policy — not merely its three flags — independent of the order the
43
- * rules arrived in. `.optional().nullable()` and `.nullable().optional()` are
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 || (difference === 0 && rule.code < strictest.code)) {
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>;