@maroonedog/luq 2.0.0 → 2.2.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 (165) hide show
  1. package/README.md +364 -57
  2. package/dist/builder/builder-surface.types.d.ts +1 -0
  3. package/dist/builder/create-builder.js +9 -0
  4. package/dist/builder/create-builder.mjs +9 -0
  5. package/dist/builder/field-builder.types.d.ts +13 -0
  6. package/dist/chain/bundle-paths.types.d.ts +26 -0
  7. package/dist/chain/bundle-paths.types.js +2 -0
  8. package/dist/chain/bundle-paths.types.mjs +1 -0
  9. package/dist/chain/chain-method.types.d.ts +8 -3
  10. package/dist/chain/create-chain-node.js +22 -1
  11. package/dist/chain/create-chain-node.mjs +22 -1
  12. package/dist/chain/resolve-args.types.d.ts +4 -2
  13. package/dist/compile/compile-array-node.d.ts +0 -7
  14. package/dist/compile/compile-array-node.js +4 -0
  15. package/dist/compile/compile-array-node.mjs +4 -0
  16. package/dist/compile/compile-field.js +10 -1
  17. package/dist/compile/compile-field.mjs +10 -1
  18. package/dist/compile/resolve-conditional-presence.js +1 -0
  19. package/dist/compile/resolve-conditional-presence.mjs +1 -0
  20. package/dist/compile/resolve-presence.js +4 -0
  21. package/dist/compile/resolve-presence.mjs +4 -0
  22. package/dist/compile/split-rules-by-kind.js +31 -7
  23. package/dist/compile/split-rules-by-kind.mjs +31 -7
  24. package/dist/compile/validation-plan.types.d.ts +33 -0
  25. package/dist/json-schema/build-from-schema.js +2 -3
  26. package/dist/json-schema/build-from-schema.mjs +2 -3
  27. package/dist/json-schema/collect-definitions.d.ts +30 -0
  28. package/dist/json-schema/collect-definitions.js +47 -3
  29. package/dist/json-schema/collect-definitions.mjs +46 -4
  30. package/dist/json-schema/collect-sub-schema-rules.d.ts +12 -1
  31. package/dist/json-schema/collect-sub-schema-rules.js +37 -7
  32. package/dist/json-schema/collect-sub-schema-rules.mjs +38 -8
  33. package/dist/json-schema/create-structural-context.d.ts +13 -3
  34. package/dist/json-schema/create-structural-context.js +72 -6
  35. package/dist/json-schema/create-structural-context.mjs +72 -7
  36. package/dist/json-schema/declare-additional-properties.d.ts +19 -0
  37. package/dist/json-schema/declare-additional-properties.js +42 -0
  38. package/dist/json-schema/declare-additional-properties.mjs +37 -0
  39. package/dist/json-schema/declare-object-keywords.d.ts +0 -2
  40. package/dist/json-schema/declare-object-keywords.js +6 -13
  41. package/dist/json-schema/declare-object-keywords.mjs +7 -13
  42. package/dist/json-schema/declare-presence.d.ts +9 -3
  43. package/dist/json-schema/declare-presence.js +12 -6
  44. package/dist/json-schema/declare-presence.mjs +12 -6
  45. package/dist/json-schema/extensions/json-schema/index.d.ts +1 -0
  46. package/dist/json-schema/extensions/json-schema/json-schema.d.ts +17 -2
  47. package/dist/json-schema/extensions/json-schema/json-schema.js +15 -7
  48. package/dist/json-schema/extensions/json-schema/json-schema.mjs +16 -8
  49. package/dist/json-schema/extensions/json-schema-full-feature/index.d.ts +1 -0
  50. package/dist/json-schema/extensions/json-schema-full-feature/json-schema-full-feature.d.ts +2 -1
  51. package/dist/json-schema/extensions/json-schema-full-feature/json-schema-full-feature.js +3 -1
  52. package/dist/json-schema/extensions/json-schema-full-feature/json-schema-full-feature.mjs +3 -1
  53. package/dist/json-schema/flatten-schema.js +1 -1
  54. package/dist/json-schema/flatten-schema.mjs +1 -1
  55. package/dist/json-schema/follow-json-pointer.d.ts +23 -0
  56. package/dist/json-schema/follow-json-pointer.js +87 -0
  57. package/dist/json-schema/follow-json-pointer.mjs +82 -0
  58. package/dist/json-schema/index.d.ts +7 -2
  59. package/dist/json-schema/index.js +10 -2
  60. package/dist/json-schema/index.mjs +5 -2
  61. package/dist/json-schema/keyword-map-object.d.ts +1 -1
  62. package/dist/json-schema/ref-resolution-error.d.ts +4 -0
  63. package/dist/json-schema/ref-resolution-error.js +20 -0
  64. package/dist/json-schema/ref-resolution-error.mjs +16 -0
  65. package/dist/json-schema/ref-scope.d.ts +18 -0
  66. package/dist/json-schema/ref-scope.js +37 -0
  67. package/dist/json-schema/ref-scope.mjs +33 -0
  68. package/dist/json-schema/resolve-ref.d.ts +14 -9
  69. package/dist/json-schema/resolve-ref.js +89 -59
  70. package/dist/json-schema/resolve-ref.mjs +86 -55
  71. package/dist/json-schema/schema-registry.d.ts +30 -0
  72. package/dist/json-schema/schema-registry.js +142 -0
  73. package/dist/json-schema/schema-registry.mjs +139 -0
  74. package/dist/json-schema/schema-to-declarations.js +2 -1
  75. package/dist/json-schema/schema-to-declarations.mjs +2 -1
  76. package/dist/json-schema/structural-expansion.types.d.ts +8 -1
  77. package/dist/json-schema/uri-reference.d.ts +29 -0
  78. package/dist/json-schema/uri-reference.js +118 -0
  79. package/dist/json-schema/uri-reference.mjs +111 -0
  80. package/dist/path/create-value-writer.js +27 -2
  81. package/dist/path/create-value-writer.mjs +27 -2
  82. package/dist/path/reserved-segment.d.ts +22 -4
  83. package/dist/path/reserved-segment.js +27 -7
  84. package/dist/path/reserved-segment.mjs +27 -7
  85. package/dist/plugin-kit/compiled-rule.d.ts +13 -0
  86. package/dist/plugin-kit/create-rule.d.ts +11 -0
  87. package/dist/plugin-kit/create-rule.js +1 -0
  88. package/dist/plugin-kit/create-rule.mjs +1 -0
  89. package/dist/plugin-kit/marker.types.d.ts +18 -0
  90. package/dist/plugin-kit/plugin-definition.d.ts +15 -0
  91. package/dist/plugin-kit/plugin-definition.js +1 -0
  92. package/dist/plugin-kit/plugin-definition.mjs +1 -0
  93. package/dist/plugins/index.generated.d.ts +1 -0
  94. package/dist/plugins/index.generated.js +4 -2
  95. package/dist/plugins/index.generated.mjs +1 -0
  96. package/dist/plugins/manifest.generated.js +1 -0
  97. package/dist/plugins/manifest.generated.mjs +1 -0
  98. package/dist/plugins/object-additional-properties/object-additional-properties-schema.d.ts +1 -1
  99. package/dist/plugins/object-additional-properties/object-additional-properties-schema.js +4 -2
  100. package/dist/plugins/object-additional-properties/object-additional-properties-schema.mjs +4 -2
  101. package/dist/plugins/object-additional-properties/object-additional-properties.d.ts +1 -1
  102. package/dist/plugins/object-additional-properties/object-additional-properties.js +4 -2
  103. package/dist/plugins/object-additional-properties/object-additional-properties.mjs +4 -2
  104. package/dist/plugins/object-additional-properties/select-additional-keys.d.ts +15 -0
  105. package/dist/plugins/object-additional-properties/select-additional-keys.js +51 -0
  106. package/dist/plugins/object-additional-properties/select-additional-keys.mjs +47 -0
  107. package/dist/plugins/stitch/stitch.d.ts +28 -8
  108. package/dist/plugins/stitch-with/index.d.ts +2 -0
  109. package/dist/plugins/stitch-with/index.js +5 -0
  110. package/dist/plugins/stitch-with/index.mjs +1 -0
  111. package/dist/plugins/stitch-with/stitch-with.d.ts +12 -0
  112. package/dist/plugins/stitch-with/stitch-with.js +94 -0
  113. package/dist/plugins/stitch-with/stitch-with.mjs +91 -0
  114. package/dist/plugins/stitchWith.d.ts +1 -0
  115. package/dist/plugins/stitchWith.js +2 -0
  116. package/dist/plugins/stitchWith.mjs +1 -0
  117. package/dist/plugins/string-exact-length/string-exact-length.js +2 -2
  118. package/dist/plugins/string-exact-length/string-exact-length.mjs +3 -3
  119. package/dist/plugins/string-max/string-max.js +2 -2
  120. package/dist/plugins/string-max/string-max.mjs +3 -3
  121. package/dist/plugins/string-min/string-min.js +21 -3
  122. package/dist/plugins/string-min/string-min.mjs +21 -3
  123. package/dist/presets/index.d.ts +1 -0
  124. package/dist/presets/index.js +9 -0
  125. package/dist/presets/index.mjs +1 -0
  126. package/dist/presets/presets.d.ts +165 -0
  127. package/dist/presets/presets.js +80 -0
  128. package/dist/presets/presets.mjs +77 -0
  129. package/dist/runtime/create-field-validator.js +24 -8
  130. package/dist/runtime/create-field-validator.mjs +24 -8
  131. package/dist/runtime/create-validator.js +38 -8
  132. package/dist/runtime/create-validator.mjs +38 -8
  133. package/dist/runtime/decide-presence.js +2 -1
  134. package/dist/runtime/decide-presence.mjs +2 -1
  135. package/dist/runtime/field-rule-context.d.ts +32 -0
  136. package/dist/runtime/field-rule-context.js +48 -0
  137. package/dist/runtime/field-rule-context.mjs +44 -0
  138. package/dist/runtime/index-stack.d.ts +29 -4
  139. package/dist/runtime/index-stack.js +76 -15
  140. package/dist/runtime/index-stack.mjs +76 -15
  141. package/dist/runtime/run-array-node.js +22 -10
  142. package/dist/runtime/run-array-node.mjs +22 -10
  143. package/dist/runtime/run-field.js +47 -17
  144. package/dist/runtime/run-field.mjs +47 -17
  145. package/dist/runtime/run-plan.js +5 -1
  146. package/dist/runtime/run-plan.mjs +5 -1
  147. package/dist/standard-schema/index.d.ts +6 -0
  148. package/dist/standard-schema/index.js +7 -0
  149. package/dist/standard-schema/index.mjs +2 -0
  150. package/dist/standard-schema/split-issue-path.d.ts +9 -0
  151. package/dist/standard-schema/split-issue-path.js +66 -0
  152. package/dist/standard-schema/split-issue-path.mjs +63 -0
  153. package/dist/standard-schema/standard-schema.types.d.ts +46 -0
  154. package/dist/standard-schema/standard-schema.types.js +13 -0
  155. package/dist/standard-schema/standard-schema.types.mjs +12 -0
  156. package/dist/standard-schema/to-standard-schema.d.ts +41 -0
  157. package/dist/standard-schema/to-standard-schema.js +60 -0
  158. package/dist/standard-schema/to-standard-schema.mjs +57 -0
  159. package/dist/standard-schema.d.ts +1 -0
  160. package/dist/standard-schema.js +2 -0
  161. package/dist/standard-schema.mjs +1 -0
  162. package/dist/types/index.d.ts +24 -0
  163. package/dist/types/index.js +18 -0
  164. package/dist/types/index.mjs +17 -0
  165. package/package.json +42 -19
package/README.md CHANGED
@@ -6,22 +6,52 @@
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 that assumption holds, it is the better arrangement and
20
+ this README will say so again below. When it does not, you end up maintaining a
21
+ second description of a shape you did not choose. You can have the compiler check
22
+ the copy against the original — zod's `satisfies z.ZodType<Order>` does exactly
23
+ that — but you still author it, update it, and remember to write the check.
24
+
25
+ Luq runs the other way. It takes the type you already have and lets you declare
26
+ rules against its field paths. What makes those declarations worth writing is
27
+ that the compiler checks them against the type: a rule that does not apply to
28
+ the field it is written on is a compile error, not a rule that quietly never
29
+ fires.
30
+
31
+ That matters most when the code calling this library is generated rather than
32
+ typed by hand. A generator that picks the wrong rule, misspells a path or drops
33
+ an array wildcard gets a red squiggle, not a validator that passes everything.
34
+
35
+ | Mistake | Result |
36
+ |---|---|
37
+ | A slot unrelated to the field's type (`b.string` on a `number`) | compile error |
38
+ | A missing `[*]` (`"items.name"`) | compile error |
39
+ | Descending into a built-in (`"when.getTime"` on a `Date`) | compile error |
40
+ | A method that does not exist inside an element sub-chain | compile error |
41
+ | A JSON Schema keyword bound to a chain method that does not exist | compile error |
42
+ | A documented example drifting from the API | fails CI |
43
+
44
+ Every rule you can call is a plugin you imported by name, so the bundle contains
45
+ what you used and nothing else.
16
46
 
17
- Every number on this page was measured on this repository. Where a measurement
18
- is worse than the 1.x release, it is written down as worse. The provenance of
19
- each figure is named next to it.
47
+ Every number on this page was measured on this repository, and the file it came
48
+ from is named next to it. Where a measurement is worse than 1.x and some are —
49
+ it is written down as worse.
20
50
 
21
51
  ## Install
22
52
 
23
53
  ```bash
24
- npm install @maroonedog/luq@alpha
54
+ npm install @maroonedog/luq
25
55
  ```
26
56
 
27
57
  ## Quick start
@@ -80,10 +110,155 @@ carries `data`, both branches carry `issues`, and each issue is
80
110
  `{ path, code, message, severity }`.
81
111
 
82
112
  > Every code block on this page is extracted and typechecked against the built
83
- > package by `npm run check:docs`. The 1.x README's quick start called `build()`'s
84
- > return value as a function, read a `result.issues` member 1.x's `Result` did not
85
- > have, and imported a subpath the exports map did not contain. That is what the
86
- > gate exists to prevent.
113
+ > package by `npm run check:docs`. It exists because documentation drifts from
114
+ > the API it documents unless something compiles it 1.x's quick start had drifted
115
+ > in three places at once (a `build()` result called as a function, a `result.issues`
116
+ > member that did not exist, and a subpath missing from the exports map), and each
117
+ > was the readable kind of mistake that nobody reads.
118
+
119
+ ## It patches onto the types you already have
120
+
121
+ This is the practical consequence of being type-first, and it is the main reason
122
+ to reach for Luq: **your type definitions do not change.** Not re-authored as a
123
+ schema, not replaced by an inferred one, not moved. `.for<Order>()` takes the
124
+ `Order` you already have, exactly as it is, and every rule is declared against
125
+ it.
126
+
127
+ So Luq asks you to describe the **rules**, not the shape — the shape is already
128
+ written down. Adopting a schema-first validator on an existing codebase means
129
+ producing a second description of the same shape for every type you cover, and
130
+ then keeping the two in agreement; that is per-type work whether you author the
131
+ schema alongside the type or switch the type to be inferred from it. Luq skips
132
+ that step because it never needs the second description.
133
+
134
+ Which is what makes adoption a patch rather than a migration:
135
+
136
+ **Declare only the fields you care about.** A path you did not declare is not
137
+ validated, not required, and not read. There is no "unknown key" behaviour to
138
+ opt out of, so a partly-covered type is a normal state and not a half-finished
139
+ one.
140
+
141
+ ```ts
142
+ import { Builder } from "@maroonedog/luq";
143
+ import { requiredPlugin } from "@maroonedog/luq/plugins/required";
144
+ import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
145
+
146
+ type Order = {
147
+ id: string;
148
+ customerNote: string;
149
+ legacyBlob: unknown;
150
+ };
151
+
152
+ // One field of three.
153
+ const orderValidator = Builder()
154
+ .use(requiredPlugin)
155
+ .use(stringMinPlugin)
156
+ .for<Order>()
157
+ .v("id", (b) => b.string.required().min(3))
158
+ .build();
159
+
160
+ // `customerNote` and `legacyBlob` are never read, so anything goes there —
161
+ // including being absent.
162
+ console.error(orderValidator.validate({ id: "abc" } as Order).valid); // true
163
+ console.error(orderValidator.validate({ id: "ab" } as Order).valid); // false
164
+ ```
165
+
166
+ **Validate one field at a time.** `pick(path)` gives back a validator for a
167
+ single declared path, which is what a form needs on blur. It takes the field's
168
+ own value, and optionally its siblings for cross-field rules.
169
+
170
+ ```ts
171
+ import { Builder } from "@maroonedog/luq";
172
+ import { requiredPlugin } from "@maroonedog/luq/plugins/required";
173
+ import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
174
+
175
+ type Order = { id: string; customerNote: string };
176
+
177
+ const id = Builder()
178
+ .use(requiredPlugin)
179
+ .use(stringMinPlugin)
180
+ .for<Order>()
181
+ .v("id", (b) => b.string.required().min(3))
182
+ .build()
183
+ .pick("id");
184
+
185
+ console.error(id.validate("ab").valid); // false
186
+ console.error(id.validate("abc").valid); // true
187
+ ```
188
+
189
+ `pickAll(["a", "b"])` does the same for a named subset and hands back exactly
190
+ those paths, keyed by the strings you asked for.
191
+
192
+ **Keep what you already have.** Luq implements Standard Schema v1, so a Luq
193
+ validator and a zod schema are interchangeable at any boundary that accepts one.
194
+ Adding Luq to one route does not commit the next one, and does not remove zod
195
+ from the routes it is already in.
196
+
197
+ None of this needs a migration step, because there is nothing global to migrate:
198
+ no registry, no plugin installation, no shared configuration object. A validator
199
+ is a value in a module, declared against a type that was there before it.
200
+
201
+ **And when you want the opposite, ask for it: `.strict()`.** Partial coverage is
202
+ the default because that is what makes a patch possible, but a builder that
203
+ declares `.strict()` will not compile until every leaf path of `T` is declared —
204
+ and the error names the ones you missed:
205
+
206
+ ```ts
207
+ import { Builder } from "@maroonedog/luq";
208
+ import { requiredPlugin } from "@maroonedog/luq/plugins/required";
209
+
210
+ type Order = { id: string; customerNote: string; nested: { deep: number } };
211
+
212
+ export const incomplete = Builder()
213
+ .use(requiredPlugin)
214
+ .for<Order>()
215
+ .v("id", (b) => b.string.required())
216
+ .strict()
217
+ // @ts-expect-error strict() returned
218
+ // MissingFieldsError<"customerNote" | "nested.deep">, which has no build().
219
+ .build();
220
+ ```
221
+
222
+ It counts leaves, so an optional property, a `Date`, an array's elements
223
+ (`tags[*]`) and a field inside an array of objects (`items[*].sku`) are each
224
+ required in their own right. It has no run-time effect at all — the obligation
225
+ is discharged by the compiler.
226
+
227
+ So the choice between "cover one field" and "cover everything" is a single call,
228
+ made per builder, and reported at compile time with the missing names rather than
229
+ at run time as a value that quietly passed. What `.strict()` does **not** cover is
230
+ properties that are not in the type; rejecting those is a run-time rule and
231
+ belongs to `additionalProperties(false)`.
232
+
233
+ ## When schema-first is the right answer
234
+
235
+ Worth stating plainly, because it is a real tension and not a debating point:
236
+ **if the schema genuinely is your single source of truth, schema-first is the
237
+ coherent arrangement, and zod, valibot or TypeBox are the right tools.** You
238
+ write one artefact, your types come out of it, and there is nothing to keep in
239
+ step. That is a better position than Luq's, and Luq cannot give it to you.
240
+
241
+ Luq is for the case where that artefact already exists somewhere else and is not
242
+ yours to move — an OpenAPI document you consume, a Prisma schema, a `.proto`
243
+ shared with three other services, a type someone generated last week. There, the
244
+ schema-first arrangement asks you to author a *second* source of truth, and the
245
+ question stops being which library is nicer and becomes which copy is right.
246
+
247
+ Two things follow that are easy to miss:
248
+
249
+ - **Luq contains both directions.** `fromJsonSchema(document)` is schema-first —
250
+ the document decides, and Luq builds the rules from it. That is not a
251
+ contradiction to be argued away; it is the same principle applied to a
252
+ different upstream. What Luq declines to do is make you *hand-write* the second
253
+ copy.
254
+ - **You do not have to pick a side per project, only per boundary.** Standard
255
+ Schema means a zod schema and a Luq validator are interchangeable where they
256
+ meet, so "the schema is the truth here, the type is the truth there" is a
257
+ workable arrangement rather than an unresolved argument.
258
+
259
+ If you are starting from nothing and you will own the shape, use zod. It is
260
+ mature, it is everywhere, and every question you will have is already answered
261
+ somewhere.
87
262
 
88
263
  ## Field paths
89
264
 
@@ -122,6 +297,40 @@ A path that does not exist on the type is a compile error, not a silent no-op.
122
297
  So is choosing a slot the field's type cannot be: `b.number` on a `string`
123
298
  field fails to compile.
124
299
 
300
+ **Cross-field rules read those paths back with their types intact.** `stitch`
301
+ takes the paths it needs and hands them over as a bundle keyed by the path
302
+ string — each one typed from your type, nested paths included. There is no
303
+ `unknown` to narrow and no cast to write.
304
+
305
+ ```ts
306
+ import { Builder } from "@maroonedog/luq";
307
+ import { requiredPlugin } from "@maroonedog/luq/plugins/required";
308
+ import { numberMinPlugin } from "@maroonedog/luq/plugins/numberMin";
309
+ import { stitchPlugin } from "@maroonedog/luq/plugins/stitch";
310
+
311
+ type Booking = { seats: number; venue: { capacity: number } };
312
+
313
+ const bookingValidator = Builder()
314
+ .use(requiredPlugin)
315
+ .use(numberMinPlugin)
316
+ .use(stitchPlugin)
317
+ .for<Booking>()
318
+ .v("venue.capacity", (b) => b.number.required().min(1))
319
+ .v("seats", (b) =>
320
+ b.number.required().stitch(["venue.capacity"], (fieldValues, value) => ({
321
+ // fieldValues["venue.capacity"] is number, and value is number.
322
+ valid: value <= fieldValues["venue.capacity"],
323
+ message: "seats must fit the venue",
324
+ }))
325
+ )
326
+ .build();
327
+ ```
328
+
329
+ Asking for a path the type does not have is a compile error, and so is using a
330
+ bundled value at the wrong type. 1.x passed this bundle as
331
+ `Record<string, unknown>`, which meant every cross-field rule opened with a
332
+ cast; the paths were already declared, so the types were always knowable.
333
+
125
334
  ## Plugins are imports
126
335
 
127
336
  There is no plugin registry to populate and no barrel you have to pay for.
@@ -145,7 +354,7 @@ const draftValidator = Builder()
145
354
  export const isTitled = draftValidator.validate({ title: "x" }).valid;
146
355
  ```
147
356
 
148
- 77 plugin objects ship across 76 subpaths, plus one deprecated alias kept from
357
+ 78 plugin objects ship across 77 subpaths, plus one deprecated alias kept from
149
358
  1.x. The complete table — subpath, symbol, chain method, slots — is generated
150
359
  from the built package: **[docs/guide/plugin-reference.md](docs/guide/plugin-reference.md)**.
151
360
 
@@ -179,17 +388,78 @@ Measured Draft-07 conformance against the official
179
388
  [JSON-Schema-Test-Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite)
180
389
  (required tests only, skipped cases counted as **failures**):
181
390
 
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:
391
+ **929 / 929 = 100.00%.** Read that against the floor, not against zero: a
392
+ validator that returned `true` unconditionally scores 551 / 929 = 59.31% on
393
+ this corpus. The skip list is empty, and its `cause` union is `never`, so
394
+ excluding a case again means adding a name to a type. Full breakdown, including
395
+ what closed each cause and what is still bounded:
185
396
  [docs/json-schema-conformance.md](docs/json-schema-conformance.md).
186
397
 
398
+ External `$ref` resolves against a map of documents **you** already have —
399
+ `jsonSchemaFullFeature(document, { externalDocuments })`. Luq never fetches, so
400
+ a URI written in a schema cannot make the process open a socket, conversion
401
+ stays synchronous, and nothing is evaluated.
402
+
187
403
  `jsonSchemaFullFeature` bundles 49 plugins so one import covers a whole
188
404
  document. `@maroonedog/luq/plugins/jsonSchema` adds a chain method instead, so a
189
405
  single declared field can be constrained by a document; it takes the plugin bag
190
406
  explicitly. It is billed as the tree-shakeable half, and measured below, it is
191
407
  not — use it for the chain method, not to save bytes.
192
408
 
409
+ ## Standard Schema
410
+
411
+ Luq implements [Standard Schema v1](https://standardschema.dev). Anything that
412
+ accepts a Standard Schema — tRPC, TanStack Form, Hono, t3-env — accepts a Luq
413
+ validator wherever it accepts a zod schema.
414
+
415
+ ```ts
416
+ import { Builder } from "@maroonedog/luq";
417
+ import { requiredPlugin } from "@maroonedog/luq/plugins/required";
418
+ import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
419
+ import { toStandardSchema } from "@maroonedog/luq/standard-schema";
420
+
421
+ type Account = { handle: string };
422
+
423
+ const standard = toStandardSchema(
424
+ Builder()
425
+ .use(requiredPlugin)
426
+ .use(stringMinPlugin)
427
+ .for<Account>()
428
+ .v("handle", (b) => b.string.required().min(2))
429
+ .build()
430
+ );
431
+
432
+ // `standard` is still the Validator — `validate`, `parse`, `pick` and `pickAll`
433
+ // are all there — and it now also satisfies Standard Schema v1, so it can be
434
+ // handed to tRPC, TanStack Form, Hono or t3-env unchanged.
435
+ const outcome = standard["~standard"].validate({ handle: "j" });
436
+
437
+ if (outcome.issues === undefined) {
438
+ console.error(outcome.value.handle);
439
+ } else {
440
+ for (const issue of outcome.issues) {
441
+ console.error(issue.message, issue.path);
442
+ }
443
+ }
444
+ ```
445
+
446
+ Three decisions the spec leaves open, made explicit here:
447
+
448
+ - `validate` calls Luq's `parse()`, not `validate()`. The spec's success result
449
+ is `{ value: Output }`, and `Output` is the value *after* validation — so a
450
+ `transform` has to be applied, and only `parse()` applies it.
451
+ - It collects every issue rather than stopping at the first. The consumer of
452
+ this seam is a form, and returning one issue at a time produces a UI where
453
+ fixing an error reveals the next one. Callers who want the fast path use the
454
+ `Validator` directly.
455
+ - `InferInput` is the type you wrote in `.for<T>()`, not a type inferred back
456
+ out of a schema value.
457
+
458
+ It is a subpath, not part of `build()`. Measured on the 2.0.0 core (7,420 B) and
459
+ carrying `~standard` on every validator adds 312 B — 4.2% charged to everyone,
460
+ including the people who never pass a validator to tRPC. Importing the subpath
461
+ costs those 312 B only when you import it, and nothing when you don't.
462
+
193
463
  ## Your own rules
194
464
 
195
465
  ```ts
@@ -237,21 +507,33 @@ so the two columns are comparable. Recorded in
237
507
 
238
508
  | Entry | gzip | 1.x, same method |
239
509
  |---|---:|---:|
240
- | `Builder` only, zero plugins | **7,420 B** | 17,423 B |
241
- | + 6 plugins (1.x's "simple" set) | **8,373 B** | 19,562 B |
242
- | all 76 plugins | **24,040 B** | |
243
- | core + `jsonSchema`, plugin alone (not usable) | **18,992 B** | — |
244
- | core + `jsonSchema` + a working 49-plugin bag | **21,371 B** | 26.06–29.08 KB |
245
- | core + `jsonSchemaFullFeature` | **21,383 B** | 31.75–32.31 KB |
246
-
247
- The claim 1.x's README made — "tree-shakeable, 19–23KB gzipped" was measuring
248
- a core bundle that cost 17.4 KB **before you used anything**: 89.1% of its
249
- "simple" figure was paid up front. Here the core is 30.9% of the all-plugins
250
- build (7,420 of 24,040 B), and adding a plugin costs 129–224 B of gzip.
510
+ <!-- generated:bundle-size -->
511
+ | `Builder` only, zero plugins | **7,954 B** | 17,423 B |
512
+ | + 6 plugins (1.x's "simple" set) | **8,879 B** | 19,562 B |
513
+ | all 77 plugins | **25,991 B** | — |
514
+ <!-- /generated:bundle-size -->
515
+
516
+ Three more entries were measured the same way on 2026-09-07 but are **not** in
517
+ `config/size-budget.json`, so nothing re-measures them and they can go stale
518
+ without anything noticing. They are kept because the JSON Schema claim needs
519
+ evidence, and marked because a figure nobody checks is worth less than one that
520
+ is checked:
521
+
522
+ | Entry (measured once, not gated) | gzip | 1.x, same method |
523
+ |---|---:|---:|
524
+ | core + `jsonSchema`, plugin alone (not usable) | 18,992 B | — |
525
+ | core + `jsonSchema` + a working 49-plugin bag | 21,371 B | 26.06–29.08 KB |
526
+ | core + `jsonSchemaFullFeature` | 21,383 B | 31.75–32.31 KB |
527
+
528
+ 1.x published "tree-shakeable, 19–23KB gzipped". Measured the same way, its
529
+ core was 17.4 KB **before any plugin was imported** — 89.1% of its "simple"
530
+ figure. Here the core is <!-- generated:bundle-core-share -->30.6% of the all-plugins build (7,954 of 25,991 B)<!-- /generated:bundle-core-share -->,
531
+ and adding a plugin costs 129–224 B of gzip. Both figures are in the table above;
532
+ the difference is where the bytes sit, not which README is right.
251
533
 
252
534
  Two lines that are **not** wins:
253
535
 
254
- - "all 76 plugins at 24,040 B" is larger than the 23,015 B 1.x published for its
536
+ - "all 77 plugins at 25,607 B" is larger than the 23,015 B 1.x published for its
255
537
  `complex` case. The two are not comparable — 1.x's figure was one schema's
256
538
  plugin set, not its whole catalogue — so it is not counted either way here.
257
539
  - The 18,992 B for `jsonSchema` measures the plugin **without a bag**, which is
@@ -271,15 +553,17 @@ skipped. Every subject rotates over a pool of at least four distinct values —
271
553
  one frozen input let V8 delete a subject outright, which is the artefact
272
554
  described below. Each figure is the median of the fastest half of 9 samples; the
273
555
  spread quoted alongside is the full range over that figure, and on these ten it
274
- is 2.98.6%.
556
+ is <!-- generated:perf-spread -->1.2–12.3%<!-- /generated:perf-spread -->.
275
557
 
276
558
  | Shape | `validate` ops/sec | `parse` ops/sec |
277
559
  |---|---:|---:|
278
- | 1 field, 1 check | 2,801,628 | 2,617,522 |
279
- | 3 fields, 6 plugins | 1,073,925 | 1,078,706 |
280
- | nested, depth 2–3 | 707,734 | 700,829 |
281
- | array of 50 elements | 29,963 | 29,774 |
282
- | JSON Schema document | 159,643 | 159,966 |
560
+ <!-- generated:perf-throughput -->
561
+ | 1 field, 1 check | 5,581,199 | 5,593,550 |
562
+ | 3 fields, 6 plugins | 2,444,308 | 1,933,180 |
563
+ | nested, depth 2–3 | 1,722,770 | 1,701,094 |
564
+ | array of 50 elements | 90,590 | 90,013 |
565
+ | JSON Schema document | 312,630 | 309,502 |
566
+ <!-- /generated:perf-throughput -->
283
567
 
284
568
  **This rewrite is slower than 1.x on flat and nested shapes.** Measured side by
285
569
  side, in one process on one machine, 1.x source against this source, sample by
@@ -287,11 +571,13 @@ sample interleaved so a drift in the machine hits both halves of every ratio:
287
571
 
288
572
  | Shape | 1.x | this | ratio |
289
573
  |---|---:|---:|---:|
290
- | 1 field | 25,660,195 | 2,724,339 | **×0.11** |
291
- | 3 fields | 3,059,093 | 1,069,167 | **×0.35** |
292
- | nested | 2,215,768 | 708,461 | **×0.32** |
293
- | array of 50 | 18,941 | 29,629 | ×1.57 |
294
- | JSON Schema | 138,809 | 152,536 | ×1.09 |
574
+ <!-- generated:perf-legacy -->
575
+ | 1 field | 26,568,111 | 5,463,953 | **×0.21** |
576
+ | 3 fields | 3,082,290 | 2,434,509 | **×0.79** |
577
+ | nested | 2,203,633 | 1,842,200 | **×0.83** |
578
+ | array of 50 | 19,610 | 84,707 | ×4.30 |
579
+ | JSON Schema | 146,283 | 306,778 | ×2.10 |
580
+ <!-- /generated:perf-legacy -->
295
581
 
296
582
  1.x carried a directory of specialised fast paths that this implementation has
297
583
  no equivalent of. The comparison was checked for the ways it could be wrong: 1.x
@@ -301,8 +587,9 @@ reject the rejected pool before either is timed.
301
587
 
302
588
  Also worth stating plainly: **neither figure 1.x's README published reproduces
303
589
  here.** It claimed 1.2M ops/sec simple and 43K complex; on this machine 1.x
304
- itself does 3.06M on the shape rebuilt from its own "simple" benchmark source,
305
- and "complex" has no reproducible definition to measure.
590
+ itself does <!-- generated:perf-legacy-simple -->3.08M<!-- /generated:perf-legacy-simple -->
591
+ on the shape rebuilt from its own "simple" benchmark source, and "complex" has
592
+ no reproducible definition to measure.
306
593
 
307
594
  `build()` costs 14–662 µs depending on shape, against sub-microsecond
308
595
  `validate()` calls — so one `build()` pays for itself after 35–100 `validate()`
@@ -338,20 +625,24 @@ figures recorded here span 0.01–0.86.
338
625
 
339
626
  No `eval`, no `new Function`. Checked mechanically over all 762 emitted `.js`
340
627
  and `.mjs` files by `npm run check:no-dynamic-code`, and over `src/` by the
341
- public-API smoke test: **0 occurrences**. 1.x made this claim in its README while
342
- carrying a live `new Function` in `src/types/array-type-analysis.ts`; this is the
343
- first release where it is enforced rather than asserted.
628
+ public-API smoke test: **0 occurrences**.
629
+
630
+ The check is there because the claim is easy to make and easy to stop being true
631
+ — 1.x's README made it while `src/types/array-type-analysis.ts` still carried a
632
+ live `new Function`. This is the first release where a script enforces it on
633
+ every build rather than a sentence asserting it.
344
634
 
345
635
  ### Package
346
636
 
347
- 84 keys in `exports`, every one resolving to files that exist: 7 fixed keys
637
+ 86 keys in `exports`, every one resolving to files that exist: 8 fixed keys
348
638
  (`.`, `./package.json`, `./result`, `./plugin-kit`, `./field-rule`, `./async`,
349
- `./plugins`) and 77 under `./plugins/` — 76 plugins plus one deprecated alias.
350
- `npm pack --dry-run`: 1,149 files, 322,079 B packed, 1,216,371 B unpacked —
351
- `LICENSE`, `README.md`, `package.json` and `dist/` (382 `.d.ts` + 382 `.js` +
352
- 382 `.mjs`), with nothing from `src/`, `test/`, `scripts/`, `bench/` or `docs/`,
639
+ `./plugins`, `./standard-schema`) and 78 under `./plugins/` — 77 plugins plus one
640
+ deprecated alias.
641
+ `npm pack --dry-run`: 1,197 files, 376.9 kB packed, 1.4 MB unpacked
642
+ `LICENSE`, `README.md`, `package.json` and `dist/` (398 `.d.ts` + 398 `.js` +
643
+ 398 `.mjs`), with nothing from `src/`, `test/`, `scripts/`, `bench/` or `docs/`,
353
644
  no raw `.ts` and no source maps. A scratch consumer typechecks **every one of
354
- the 84 keys** against the published declarations under **both** `node16` and
645
+ the 86 keys** against the published declarations under **both** `node16` and
355
646
  `bundler` resolution, and an unpublished subpath is proven to fail.
356
647
 
357
648
  1.x's `createPluginRegistry` / `useField` / `createFieldRule` are published at
@@ -365,7 +656,8 @@ the 84 keys** against the published declarations under **both** `node16` and
365
656
  - **[Field paths](docs/guide/field-paths.md)** — what a path may be, and what
366
657
  changed from 1.x
367
658
  - **[Presence and conditionals](docs/guide/presence-and-conditionals.md)** —
368
- `required` / `optional` / `nullable` / `requiredIf` and the order rules run in
659
+ `required` / `optional` / `nullable` / `requiredIf`, the order rules run
660
+ in, and the cross-field rules (`compareField` / `stitch` / `stitchWith`)
369
661
  - **[JSON Schema](docs/guide/json-schema.md)** — the two front doors, and the
370
662
  keywords that are not supported
371
663
  - **[Writing a plugin](docs/guide/writing-a-plugin.md)** — markers, `out`,
@@ -374,15 +666,30 @@ the 84 keys** against the published declarations under **both** `node16` and
374
666
  every subpath, method and slot
375
667
  - **[Breaking changes from 1.x](docs/migration/breaking-changes.md)** — every
376
668
  incompatibility with the fix beside it
377
- - **[Draft-07 conformance](docs/json-schema-conformance.md)** — the 89.13% and
378
- all 101 failures
669
+ - **[Draft-07 conformance](docs/json-schema-conformance.md)** — the 100% and
670
+ what closed each of the ten causes that used to fail
671
+
672
+ ## Status, and how this gets changed
673
+
674
+ The 2.x API is stable and the surface below is gated, but the production track
675
+ record is still short. Breaking changes happen in a major and nowhere else, an
676
+ API being removed is deprecated one major ahead, and each major ships with the
677
+ codemod needed to cross it.
678
+
679
+ - **[CONTRIBUTING.md](CONTRIBUTING.md)** — `npm run verify` is the whole
680
+ contract; the gates and what each one refuses
681
+ - **[SECURITY.md](SECURITY.md)** — reporting, zero runtime dependencies, the
682
+ prototype-pollution and SSRF positions, and what is *not* protected against
683
+ - **[docs/RELEASING.md](docs/RELEASING.md)** — the release steps, the versioning
684
+ policy, what each CI workflow watches, and what is still decided by hand
379
685
 
380
686
  ## About the "universal platform" goal
381
687
 
382
- 1.x's README advertised a `.luq` DSL that generates validators for other
383
- languages, with dated milestones. No part of it ships in this package and this
384
- release makes no claim about when it will. What is in the box is the TypeScript
385
- validation library described above.
688
+ 1.x described a `.luq` DSL that would generate validators for other languages,
689
+ against dated milestones. Those dates have passed and none of it shipped, so the
690
+ plan has been withdrawn rather than moved: no part of it is in this package, and
691
+ this release makes no claim about when any of it will exist. What is in the box
692
+ is the TypeScript validation library described above.
386
693
 
387
694
  ## License
388
695
 
@@ -34,6 +34,7 @@ export interface FieldBuilderSurface {
34
34
  /** The erased twin of Builder<B>. `use` mutates and returns the receiver. */
35
35
  export interface BuilderSurface {
36
36
  use(plugin: AnyPlugin): BuilderSurface;
37
+ useAll(plugins: PluginBag): BuilderSurface;
37
38
  withConfig(config: GlobalConfig): BuilderSurface;
38
39
  for(): FieldBuilderSurface;
39
40
  }
@@ -50,6 +50,15 @@ function createBuilderSurface() {
50
50
  registerPlugin(registration.plugins, plugin);
51
51
  return surface;
52
52
  },
53
+ useAll(plugins) {
54
+ // 順序は Object.values の列挙順。first-wins なので、同じ名前が二度
55
+ // 来ても最初のものが残る — プリセットが既に登録したものを黙って
56
+ // 置き換えることはない。
57
+ for (const plugin of Object.values(plugins)) {
58
+ registerPlugin(registration.plugins, plugin);
59
+ }
60
+ return surface;
61
+ },
53
62
  withConfig(config) {
54
63
  registration.config = Object.assign({}, registration.config, config);
55
64
  return surface;
@@ -44,6 +44,15 @@ export function createBuilderSurface() {
44
44
  registerPlugin(registration.plugins, plugin);
45
45
  return surface;
46
46
  },
47
+ useAll(plugins) {
48
+ // 順序は Object.values の列挙順。first-wins なので、同じ名前が二度
49
+ // 来ても最初のものが残る — プリセットが既に登録したものを黙って
50
+ // 置き換えることはない。
51
+ for (const plugin of Object.values(plugins)) {
52
+ registerPlugin(registration.plugins, plugin);
53
+ }
54
+ return surface;
55
+ },
47
56
  withConfig(config) {
48
57
  registration.config = Object.assign({}, registration.config, config);
49
58
  return surface;
@@ -26,6 +26,19 @@ export interface FieldBuilder<T extends object, B extends PluginBag, TDeclared e
26
26
  }
27
27
  export interface Builder<B extends PluginBag = Record<never, never>> {
28
28
  use<P extends AnyPlugin>(plugin: P): Builder<B & BagEntry<P>>;
29
+ /**
30
+ * A whole SET of plugins at once — a preset, or any object of them.
31
+ *
32
+ * A bag is a name -> plugin map, so a preset is that value and nothing more;
33
+ * there is no registry and no preset type to learn. `use()` one at a time
34
+ * still works and still costs only what it names, which is the point of the
35
+ * subpaths — this is for the case where writing fifteen `use()` lines is the
36
+ * thing standing between you and the validator.
37
+ *
38
+ * Duplicates follow the same rule as `use()`: FIRST WINS, so a preset cannot
39
+ * quietly replace a plugin you already registered.
40
+ */
41
+ useAll<Bag extends PluginBag>(plugins: Bag): Builder<B & Bag>;
29
42
  /**
30
43
  * The per-builder override of the process-wide GlobalConfig. Merged over
31
44
  * getGlobalConfig() once, at build(), and handed to every plugin as
@@ -0,0 +1,26 @@
1
+ import type { FieldPath } from "../path/field-path.types";
2
+ import type { ValueAtPath } from "../path/value-at-path.types";
3
+ import type { AnyChain } from "./field-chain.types";
4
+ import type { FieldSlots } from "./field-slots.types";
5
+ import type { PluginBag } from "./plugin-bag.types";
6
+ /** 別名 -> ルートのパス。パスはルートに実在するものしか書けない。 */
7
+ export type BundlePaths<TRoot> = Readonly<Record<string, FieldPath<TRoot> & string>>;
8
+ /** 対応表から組み上がる束。別名がキーで、値はそのパスの値。 */
9
+ export type BundleOf<TRoot, M extends BundlePaths<TRoot>> = {
10
+ readonly [A in keyof M & string]: ValueAtPath<TRoot, M[A]>;
11
+ };
12
+ /**
13
+ * 束ひとつに対するサブチェーン。
14
+ *
15
+ * stitch の核は「複数のフィールドを **1つの判定** にまとめる」ことなので、
16
+ * 主体は束そのものであって別名ごとではない。別名ごとにルールを並べる形も
17
+ * 書けるが、それは `total === price * quantity` のような判定が書けず、
18
+ * stitch ではなくなる。
19
+ *
20
+ * 主体が束なので、束の中を `.v()` 的に見るのではなく、`b.object` などの
21
+ * スロットがそのまま開く。型は BundleOf なので、束のメンバーは補完も効くし
22
+ * 取り違えればコンパイルエラーになる — stitch が
23
+ * `Readonly<Record<string, unknown>>` を渡していたのに対する、ここの一点だけ
24
+ * が違いである。
25
+ */
26
+ export type BundleChain<TRoot, M extends BundlePaths<TRoot>, B extends PluginBag> = (b: FieldSlots<BundleOf<TRoot, M>, B, BundleOf<TRoot, M>>) => AnyChain;
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1 @@
1
+ export {};