@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.
Files changed (109) hide show
  1. package/README.md +100 -10
  2. package/dist/chain/create-chain-node.js +22 -1
  3. package/dist/chain/create-chain-node.mjs +22 -1
  4. package/dist/compile/resolve-conditional-presence.js +1 -0
  5. package/dist/compile/resolve-conditional-presence.mjs +1 -0
  6. package/dist/compile/resolve-presence.js +4 -0
  7. package/dist/compile/resolve-presence.mjs +4 -0
  8. package/dist/compile/validation-plan.types.d.ts +10 -0
  9. package/dist/json-schema/build-from-schema.js +2 -3
  10. package/dist/json-schema/build-from-schema.mjs +2 -3
  11. package/dist/json-schema/collect-definitions.d.ts +30 -0
  12. package/dist/json-schema/collect-definitions.js +47 -3
  13. package/dist/json-schema/collect-definitions.mjs +46 -4
  14. package/dist/json-schema/collect-sub-schema-rules.d.ts +12 -1
  15. package/dist/json-schema/collect-sub-schema-rules.js +37 -7
  16. package/dist/json-schema/collect-sub-schema-rules.mjs +38 -8
  17. package/dist/json-schema/create-structural-context.d.ts +13 -3
  18. package/dist/json-schema/create-structural-context.js +72 -6
  19. package/dist/json-schema/create-structural-context.mjs +72 -7
  20. package/dist/json-schema/declare-additional-properties.d.ts +19 -0
  21. package/dist/json-schema/declare-additional-properties.js +42 -0
  22. package/dist/json-schema/declare-additional-properties.mjs +37 -0
  23. package/dist/json-schema/declare-object-keywords.d.ts +0 -2
  24. package/dist/json-schema/declare-object-keywords.js +6 -13
  25. package/dist/json-schema/declare-object-keywords.mjs +7 -13
  26. package/dist/json-schema/declare-presence.d.ts +9 -3
  27. package/dist/json-schema/declare-presence.js +12 -6
  28. package/dist/json-schema/declare-presence.mjs +12 -6
  29. package/dist/json-schema/extensions/json-schema/index.d.ts +1 -0
  30. package/dist/json-schema/extensions/json-schema/json-schema.d.ts +17 -2
  31. package/dist/json-schema/extensions/json-schema/json-schema.js +15 -7
  32. package/dist/json-schema/extensions/json-schema/json-schema.mjs +16 -8
  33. package/dist/json-schema/extensions/json-schema-full-feature/index.d.ts +1 -0
  34. package/dist/json-schema/extensions/json-schema-full-feature/json-schema-full-feature.d.ts +2 -1
  35. package/dist/json-schema/extensions/json-schema-full-feature/json-schema-full-feature.js +3 -1
  36. package/dist/json-schema/extensions/json-schema-full-feature/json-schema-full-feature.mjs +3 -1
  37. package/dist/json-schema/follow-json-pointer.d.ts +23 -0
  38. package/dist/json-schema/follow-json-pointer.js +87 -0
  39. package/dist/json-schema/follow-json-pointer.mjs +82 -0
  40. package/dist/json-schema/index.d.ts +7 -2
  41. package/dist/json-schema/index.js +10 -2
  42. package/dist/json-schema/index.mjs +5 -2
  43. package/dist/json-schema/keyword-map-object.d.ts +1 -1
  44. package/dist/json-schema/ref-resolution-error.d.ts +4 -0
  45. package/dist/json-schema/ref-resolution-error.js +20 -0
  46. package/dist/json-schema/ref-resolution-error.mjs +16 -0
  47. package/dist/json-schema/ref-scope.d.ts +18 -0
  48. package/dist/json-schema/ref-scope.js +37 -0
  49. package/dist/json-schema/ref-scope.mjs +33 -0
  50. package/dist/json-schema/resolve-ref.d.ts +14 -9
  51. package/dist/json-schema/resolve-ref.js +89 -59
  52. package/dist/json-schema/resolve-ref.mjs +86 -55
  53. package/dist/json-schema/schema-registry.d.ts +30 -0
  54. package/dist/json-schema/schema-registry.js +142 -0
  55. package/dist/json-schema/schema-registry.mjs +139 -0
  56. package/dist/json-schema/schema-to-declarations.js +2 -1
  57. package/dist/json-schema/schema-to-declarations.mjs +2 -1
  58. package/dist/json-schema/structural-expansion.types.d.ts +8 -1
  59. package/dist/json-schema/uri-reference.d.ts +29 -0
  60. package/dist/json-schema/uri-reference.js +118 -0
  61. package/dist/json-schema/uri-reference.mjs +111 -0
  62. package/dist/path/create-value-writer.js +27 -2
  63. package/dist/path/create-value-writer.mjs +27 -2
  64. package/dist/path/reserved-segment.d.ts +22 -4
  65. package/dist/path/reserved-segment.js +27 -7
  66. package/dist/path/reserved-segment.mjs +27 -7
  67. package/dist/plugin-kit/compiled-rule.d.ts +13 -0
  68. package/dist/plugin-kit/create-rule.d.ts +11 -0
  69. package/dist/plugin-kit/create-rule.js +1 -0
  70. package/dist/plugin-kit/create-rule.mjs +1 -0
  71. package/dist/plugin-kit/plugin-definition.d.ts +15 -0
  72. package/dist/plugin-kit/plugin-definition.js +1 -0
  73. package/dist/plugin-kit/plugin-definition.mjs +1 -0
  74. package/dist/plugins/object-additional-properties/object-additional-properties-schema.d.ts +1 -1
  75. package/dist/plugins/object-additional-properties/object-additional-properties-schema.js +4 -2
  76. package/dist/plugins/object-additional-properties/object-additional-properties-schema.mjs +4 -2
  77. package/dist/plugins/object-additional-properties/object-additional-properties.d.ts +1 -1
  78. package/dist/plugins/object-additional-properties/object-additional-properties.js +4 -2
  79. package/dist/plugins/object-additional-properties/object-additional-properties.mjs +4 -2
  80. package/dist/plugins/object-additional-properties/select-additional-keys.d.ts +15 -0
  81. package/dist/plugins/object-additional-properties/select-additional-keys.js +51 -0
  82. package/dist/plugins/object-additional-properties/select-additional-keys.mjs +47 -0
  83. package/dist/plugins/string-exact-length/string-exact-length.js +2 -2
  84. package/dist/plugins/string-exact-length/string-exact-length.mjs +3 -3
  85. package/dist/plugins/string-max/string-max.js +2 -2
  86. package/dist/plugins/string-max/string-max.mjs +3 -3
  87. package/dist/plugins/string-min/string-min.js +2 -2
  88. package/dist/plugins/string-min/string-min.mjs +3 -3
  89. package/dist/runtime/decide-presence.js +2 -1
  90. package/dist/runtime/decide-presence.mjs +2 -1
  91. package/dist/standard-schema/index.d.ts +6 -0
  92. package/dist/standard-schema/index.js +7 -0
  93. package/dist/standard-schema/index.mjs +2 -0
  94. package/dist/standard-schema/split-issue-path.d.ts +9 -0
  95. package/dist/standard-schema/split-issue-path.js +66 -0
  96. package/dist/standard-schema/split-issue-path.mjs +63 -0
  97. package/dist/standard-schema/standard-schema.types.d.ts +46 -0
  98. package/dist/standard-schema/standard-schema.types.js +13 -0
  99. package/dist/standard-schema/standard-schema.types.mjs +12 -0
  100. package/dist/standard-schema/to-standard-schema.d.ts +41 -0
  101. package/dist/standard-schema/to-standard-schema.js +60 -0
  102. package/dist/standard-schema/to-standard-schema.mjs +57 -0
  103. package/dist/standard-schema.d.ts +1 -0
  104. package/dist/standard-schema.js +2 -0
  105. package/dist/standard-schema.mjs +1 -0
  106. package/dist/types/index.d.ts +12 -0
  107. package/dist/types/index.js +18 -0
  108. package/dist/types/index.mjs +17 -0
  109. package/package.json +10 -3
package/README.md CHANGED
@@ -6,13 +6,42 @@
6
6
  [![npm version](https://img.shields.io/npm/v/@maroonedog/luq.svg)](https://www.npmjs.com/package/@maroonedog/luq)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
8
8
 
9
- **A TypeScript validation library that validates the types you already have.**
9
+ **A TypeScript validation library where the wrong rule does not compile.**
10
10
 
11
11
  </div>
12
12
 
13
- Luq takes a type you wrote — not a schema you rewrote — and lets you declare
14
- rules against its field paths. Every rule you can call is a plugin you imported
15
- by name, so the bundle contains what you used and nothing else.
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@alpha
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
- **828 / 929 = 89.13%.** A validator that returned `true` unconditionally would
183
- score 551 / 929 = 59.31% on this corpus, which is the number the 89% should be
184
- read against. Full breakdown, including every one of the 101 failures:
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 89.13% and
378
- all 101 failures
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
- return createChainNode(wiring, slot, [...rules, rule]);
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
- return createChainNode(wiring, slot, [...rules, rule]);
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 declare_value_keywords_1 = require("./declare-value-keywords");
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 { permitsNull } from "./declare-value-keywords.mjs";
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
- return node;
42
- return toSchemaObject((0, resolve_ref_1.resolveRef)(node.$ref, root));
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 { resolveRef } from "./resolve-ref.mjs";
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
- return node;
34
- return toSchemaObject(resolveRef(node.$ref, root));
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
- /** Every branch of every composite is built here, so a branch is one shape. */
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.resolveSchemaNode)(schema, context.root);
61
- const rules = [...(0, schema_to_declarations_1.expandSchemaRules)(node, context)];
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
- /** Every branch of every composite is built here, so a branch is one shape. */
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: context.collectSubSchemaRules(schema),
109
+ rules: collectSubSchemaRules(schema, context),
80
110
  fields: NO_BRANCH_FIELDS,
81
111
  };
82
112
  }