@c9up/rune 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Schema.js CHANGED
@@ -3,13 +3,34 @@
3
3
  *
4
4
  * @implements FR38, FR39, FR40, FR41
5
5
  */
6
- import { RuneError } from "./errors.js";
6
+ var _a;
7
+ import { RuneError, RuneValidationError } from "./errors.js";
7
8
  import { isNativeAvailable, validateNative, warnNativeUnavailableOnce, } from "./native.js";
9
+ export function createRule(validator) {
10
+ return (options) => ({
11
+ __rune: "rule",
12
+ run(value, field) {
13
+ validator(value, options, field);
14
+ },
15
+ });
16
+ }
17
+ /** Default context for internal callers that don't supply one (no root available). */
18
+ const EMPTY_RUN_CONTEXT = { data: {}, parent: {}, meta: {} };
8
19
  /** Type guard: narrows `unknown` to a plain object (non-null, non-array, typeof 'object'). */
9
20
  function isPlainObject(value) {
10
21
  return typeof value === "object" && value !== null && !Array.isArray(value);
11
22
  }
12
- /** Rules the Rust validation engine can handle natively. */
23
+ /**
24
+ * Rules the Rust validation engine (`crates/rune-engine/src/engine.rs`)
25
+ * ACTUALLY implements. A schema built from only these rules can be validated
26
+ * natively; anything else routes to the TS path (`rule.validate`).
27
+ *
28
+ * CRITICAL: a rule name here that the Rust engine does not implement is a
29
+ * SILENT VALIDATION BYPASS — the engine's `_ => {}` arm skips unknown rules, so
30
+ * the constraint never runs. Every entry MUST have a matching arm in engine.rs.
31
+ * The TS chain rules (minLength/uuid/alpha/in/enum/range/…) live only in the TS
32
+ * validator, so they are deliberately absent here.
33
+ */
13
34
  const STANDARD_RULES = new Set([
14
35
  "string",
15
36
  "number",
@@ -18,8 +39,22 @@ const STANDARD_RULES = new Set([
18
39
  "max",
19
40
  "email",
20
41
  "positive",
42
+ "minLength",
43
+ "maxLength",
44
+ "fixedLength",
45
+ "uuid",
46
+ "alpha",
47
+ "alphaNumeric",
48
+ "startsWith",
49
+ "endsWith",
50
+ "in",
51
+ "notIn",
52
+ "enum",
53
+ "negative",
54
+ "nonNegative",
55
+ "range",
21
56
  ]);
22
- /** Default messages for standard rules — used to detect custom-message overrides. */
57
+ /** Default messages for standard rules — used only for translator-key fallback. */
23
58
  const STANDARD_MSGS = {
24
59
  string: "Must be a string",
25
60
  number: "Must be a number",
@@ -28,6 +63,20 @@ const STANDARD_MSGS = {
28
63
  max: "Maximum",
29
64
  email: "Must be a valid email",
30
65
  positive: "Must be positive",
66
+ minLength: "Too short",
67
+ maxLength: "Too long",
68
+ fixedLength: "Wrong length",
69
+ alpha: "Must contain only letters",
70
+ alphaNumeric: "Must contain only letters and numbers",
71
+ startsWith: "Invalid prefix",
72
+ endsWith: "Invalid suffix",
73
+ uuid: "Must be a valid UUID",
74
+ in: "Invalid value",
75
+ notIn: "Invalid value",
76
+ enum: "Invalid value",
77
+ range: "Out of range",
78
+ negative: "Must be negative",
79
+ nonNegative: "Must be positive or zero",
31
80
  };
32
81
  const TYPE_RULE_NAMES = new Set([
33
82
  "string",
@@ -37,15 +86,8 @@ const TYPE_RULE_NAMES = new Set([
37
86
  "array",
38
87
  ]);
39
88
  let validationTranslator;
40
- function hasDefaultMessage(rule) {
41
- if (rule.name === "min" || rule.name === "max") {
42
- if (typeof rule.param !== "number")
43
- return false;
44
- const expected = `${rule.name === "min" ? "Minimum" : "Maximum"} ${rule.param}`;
45
- return rule.message === expected;
46
- }
47
- const defaultMsg = STANDARD_MSGS[rule.name];
48
- return defaultMsg !== undefined && rule.message === defaultMsg;
89
+ function hasCustomMessage(rule) {
90
+ return rule.hasCustomMessage === true;
49
91
  }
50
92
  function resolveValidationMessage(key, fallback, params) {
51
93
  const translated = validationTranslator?.(key, params);
@@ -54,95 +96,133 @@ function resolveValidationMessage(key, fallback, params) {
54
96
  }
55
97
  return fallback;
56
98
  }
57
- function resolveRuleMessage(field, rule) {
58
- const fallback = rule.message;
59
- if (!STANDARD_RULES.has(rule.name)) {
60
- return fallback;
99
+ /** Args carried on a rule, exposed to i18n/providers via message interpolation. */
100
+ function ruleArgs(rule) {
101
+ return rule.args;
102
+ }
103
+ /**
104
+ * Resolve the final message for a failing rule. Precedence:
105
+ * 1. explicit `.message()` override (always wins),
106
+ * 2. a per-call {@link MessagesProviderContract} (VineJS parity),
107
+ * 3. a globally bound translator (rune's rosetta superset),
108
+ * 4. the rule's raw default message.
109
+ */
110
+ function resolveRuleMessage(field, rule, ctx) {
111
+ if (hasCustomMessage(rule)) {
112
+ return rule.message;
113
+ }
114
+ const args = ruleArgs(rule);
115
+ if (ctx.messagesProvider) {
116
+ return ctx.messagesProvider.getMessage(rule.message, rule.name, field, args);
61
117
  }
62
- if (!hasDefaultMessage(rule)) {
63
- return fallback;
118
+ if (!STANDARD_RULES.has(rule.name)) {
119
+ return rule.message;
64
120
  }
65
121
  const params = { field };
66
- if (rule.name === "min" && typeof rule.param === "number") {
67
- params.min = rule.param;
122
+ if (typeof rule.param === "number") {
123
+ if (rule.name === "min" || rule.name === "minLength")
124
+ params.min = rule.param;
125
+ if (rule.name === "max" || rule.name === "maxLength")
126
+ params.max = rule.param;
68
127
  }
69
- if (rule.name === "max" && typeof rule.param === "number") {
70
- params.max = rule.param;
128
+ return resolveValidationMessage(`validation.${rule.name}`, rule.message, params);
129
+ }
130
+ /** Resolve the "required" message through provider → translator → fallback. */
131
+ function resolveRequiredMessage(field, ctx) {
132
+ if (ctx.messagesProvider) {
133
+ return ctx.messagesProvider.getMessage(`${field} is required`, "required", field);
71
134
  }
72
- return resolveValidationMessage(`validation.${rule.name}`, fallback, params);
135
+ return resolveValidationMessage("validation.required", `${field} is required`, { field });
73
136
  }
74
137
  /** Compute once: does any field rule prevent dispatching to Rust? */
75
138
  function detectHasCustomRules(fields) {
76
139
  return Object.values(fields).some((chain) => {
140
+ if (chain.useRules.length > 0)
141
+ return true; // .use() rule — TS-only (Rust can't run JS)
142
+ if (chain.hasConditionalRequired)
143
+ return true; // requiredWhen — TS-only
144
+ if (chain.preTransforms.length > 0)
145
+ return true; // .parse() — TS-only
146
+ if (chain.transforms.length > 0)
147
+ return true; // .transform() — Rust gets only the NAME, can't run a JS fn
148
+ if (chain.isNullable)
149
+ return true; // .nullable() — the flag is not sent to the Rust engine
77
150
  return chain.rules.some((r) => {
78
151
  if (!STANDARD_RULES.has(r.name))
79
152
  return true; // custom rule
80
- if (!hasDefaultMessage(r))
153
+ if (hasCustomMessage(r))
81
154
  return true; // custom message
82
155
  return false;
83
156
  });
84
157
  });
85
158
  }
86
- /**
87
- * Create a validation schema.
88
- *
89
- * Pass `T` explicitly when the caller wants `result.data` typed as a concrete
90
- * shape after `result.valid === true` narrows the union — runtime validation
91
- * is unchanged, the generic only types the success branch.
92
- *
93
- * const RegisterValidator = schema<{ email: string; password: string }>({
94
- * email: rules.string().email(),
95
- * password: rules.string().min(8),
96
- * });
97
- *
98
- * The default `Record<string, unknown>` matches the historical untyped surface
99
- * so existing call sites that read `result.data` field-by-field with their
100
- * own narrowing continue to compile.
101
- */
102
159
  export function schema(fields) {
103
160
  // Computed once at construction time, not per validate() call.
104
161
  const hasCustomRules = detectHasCustomRules(fields);
105
- return {
106
- fields,
107
- validate(data) {
108
- if (!isPlainObject(data)) {
109
- return {
110
- valid: false,
111
- errors: [
112
- {
113
- field: "_root",
114
- rule: "type",
115
- message: "Input must be an object",
116
- },
117
- ],
118
- };
119
- }
120
- if (!hasCustomRules && !validationTranslator) {
121
- if (isNativeAvailable()) {
122
- return validateWithRust(fields, data);
123
- }
124
- // This schema would have used the native engine, but it isn't
125
- // loaded — surface the platform-dependent TS fallback once instead
126
- // of diverging silently. (Schemas with custom rules / a translator
127
- // always run on TS by design and don't warn.)
128
- warnNativeUnavailableOnce();
129
- }
130
- const errors = [];
131
- const validated = {};
132
- for (const [field, chain] of Object.entries(fields)) {
133
- const value = data[field];
134
- const result = chain._validateWithTransform(field, value);
135
- errors.push(...result.errors);
136
- if (result.errors.length === 0 && value !== undefined) {
137
- validated[field] = result.transformed;
138
- }
162
+ function validate(data, options) {
163
+ if (!isPlainObject(data)) {
164
+ return {
165
+ valid: false,
166
+ errors: [
167
+ { field: "_root", rule: "type", message: "Input must be an object" },
168
+ ],
169
+ };
170
+ }
171
+ const provider = options?.messagesProvider;
172
+ if (!hasCustomRules && !validationTranslator && !provider) {
173
+ if (isNativeAvailable()) {
174
+ return validateWithRust(fields, data);
139
175
  }
140
- if (errors.length === 0) {
141
- return { valid: true, errors, data: validated };
176
+ // This schema would have used the native engine, but it isn't loaded —
177
+ // surface the platform-dependent TS fallback once instead of diverging
178
+ // silently.
179
+ warnNativeUnavailableOnce();
180
+ }
181
+ const errors = [];
182
+ const validated = {};
183
+ const rootCtx = {
184
+ data,
185
+ parent: data,
186
+ meta: options?.meta ?? {},
187
+ messagesProvider: provider,
188
+ };
189
+ for (const [field, chain] of Object.entries(fields)) {
190
+ const value = data[field];
191
+ const result = chain._validateWithTransform(field, value, rootCtx);
192
+ errors.push(...result.errors);
193
+ // Gate on the TRANSFORMED result, not the raw input: a pre-transform
194
+ // (`parse(() => 42)`) can produce a value for an absent field, and that
195
+ // value must land in `data` — testing the raw `value` dropped it.
196
+ if (result.errors.length === 0 && result.transformed !== undefined) {
197
+ validated[field] = result.transformed;
142
198
  }
143
- return { valid: false, errors };
144
- },
199
+ }
200
+ if (errors.length === 0) {
201
+ return { valid: true, errors, data: validated };
202
+ }
203
+ return { valid: false, errors };
204
+ }
205
+ function validateOrThrow(data, options) {
206
+ const result = validate(data, options);
207
+ if (result.valid) {
208
+ return result.data;
209
+ }
210
+ throw new RuneValidationError(result.errors.map(toErrorNode));
211
+ }
212
+ return { fields, validate, validateOrThrow };
213
+ }
214
+ /** Map an internal {@link ValidationError} to a {@link RuneErrorNode}. */
215
+ function toErrorNode(error) {
216
+ const node = {
217
+ message: error.message,
218
+ rule: error.rule,
219
+ field: error.field,
145
220
  };
221
+ if (error.index !== undefined)
222
+ node.index = error.index;
223
+ if (error.meta !== undefined)
224
+ node.meta = error.meta;
225
+ return node;
146
226
  }
147
227
  export function setValidationTranslator(translator) {
148
228
  validationTranslator = translator;
@@ -150,13 +230,20 @@ export function setValidationTranslator(translator) {
150
230
  export function bindRosetta(rosetta) {
151
231
  setValidationTranslator((key, params) => rosetta.t(key, params));
152
232
  }
153
- /** Rule chainfluent validation builder. */
233
+ /** UUID (any version/variant) identical to the Rust engine's pattern. */
234
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
235
+ /** Rule chain — fluent, phantom-typed validation builder. */
154
236
  export class RuleChain {
155
237
  #rules = [];
156
238
  #isOptional = false;
239
+ #isNullable = false;
240
+ #bail = false;
157
241
  #transforms = [];
242
+ #preTransforms = [];
158
243
  #nestedSchema = null;
159
244
  #arrayItemChain = null;
245
+ #useRules = [];
246
+ #requiredConditions = [];
160
247
  /** Public read access to rules (for OpenAPI generation, Rust bridge). */
161
248
  get rules() {
162
249
  return this.#rules;
@@ -164,23 +251,75 @@ export class RuleChain {
164
251
  get isOptionalField() {
165
252
  return this.#isOptional;
166
253
  }
254
+ /** Public read access to the `.nullable()` flag (keeps such schemas off the native path). */
255
+ get isNullable() {
256
+ return this.#isNullable;
257
+ }
167
258
  get transforms() {
168
259
  return this.#transforms;
169
260
  }
170
- /** Mark field as optional. */
261
+ /** Public read access to `.use()` rules (used to keep such schemas off the native path). */
262
+ get useRules() {
263
+ return this.#useRules;
264
+ }
265
+ /** Public read access to `.parse()` pre-transforms (kept off the native path). */
266
+ get preTransforms() {
267
+ return this.#preTransforms;
268
+ }
269
+ /** Whether this chain carries a `requiredWhen`-family condition. */
270
+ get hasConditionalRequired() {
271
+ return this.#requiredConditions.length > 0;
272
+ }
273
+ /**
274
+ * Re-type this chain to a new phantom output while carrying its runtime state
275
+ * forward. Cast-free: `new RuleChain<U>()` is genuinely `RuleChain<U>` because
276
+ * the brand is `declare`-only. State arrays are copied so the abandoned source
277
+ * chain can't be mutated through the new one.
278
+ */
279
+ #retype() {
280
+ const next = new _a();
281
+ next.#rules = [...this.#rules];
282
+ next.#isOptional = this.#isOptional;
283
+ next.#isNullable = this.#isNullable;
284
+ next.#bail = this.#bail;
285
+ next.#transforms = [...this.#transforms];
286
+ next.#preTransforms = [...this.#preTransforms];
287
+ next.#nestedSchema = this.#nestedSchema;
288
+ next.#arrayItemChain = this.#arrayItemChain;
289
+ next.#useRules = [...this.#useRules];
290
+ next.#requiredConditions = [...this.#requiredConditions];
291
+ return next;
292
+ }
293
+ /** Mark field as optional (absent / `undefined` allowed). */
171
294
  optional() {
172
295
  this.#isOptional = true;
173
296
  return this;
174
297
  }
298
+ /** Mark field as nullable (`null` allowed, kept in the output). */
299
+ nullable() {
300
+ this.#isNullable = true;
301
+ return this;
302
+ }
303
+ /** Mark field as both optional and nullable. */
304
+ nullish() {
305
+ this.#isOptional = true;
306
+ this.#isNullable = true;
307
+ return this;
308
+ }
309
+ /** Stop at the first failing rule for this field (VineJS bail). */
310
+ bail(enabled = true) {
311
+ this.#bail = enabled;
312
+ return this;
313
+ }
175
314
  /** Must be an object matching a nested schema. */
176
315
  object(shape) {
177
316
  this.#rules.push({
178
317
  name: "object",
179
- validate: (v) => typeof v === "object" && v !== null && !Array.isArray(v),
318
+ validate: (v) => isPlainObject(v),
180
319
  message: "Must be an object",
181
320
  });
182
321
  this.#nestedSchema = shape;
183
- return this;
322
+ return this.#retype();
184
323
  }
185
324
  /** Must be an array. Items validated by the provided chain. */
186
325
  array(itemChain) {
@@ -190,7 +329,7 @@ export class RuleChain {
190
329
  message: "Must be an array",
191
330
  });
192
331
  this.#arrayItemChain = itemChain ?? null;
193
- return this;
332
+ return this.#retype();
194
333
  }
195
334
  /** Must be a string. */
196
335
  string() {
@@ -199,7 +338,7 @@ export class RuleChain {
199
338
  validate: (v) => typeof v === "string",
200
339
  message: "Must be a string",
201
340
  });
202
- return this;
341
+ return this.#retype();
203
342
  }
204
343
  /** Must be a number. */
205
344
  number() {
@@ -208,7 +347,7 @@ export class RuleChain {
208
347
  validate: (v) => typeof v === "number" && !Number.isNaN(v) && Number.isFinite(v),
209
348
  message: "Must be a number",
210
349
  });
211
- return this;
350
+ return this.#retype();
212
351
  }
213
352
  /** Must be a boolean. */
214
353
  boolean() {
@@ -217,13 +356,35 @@ export class RuleChain {
217
356
  validate: (v) => typeof v === "boolean",
218
357
  message: "Must be a boolean",
219
358
  });
220
- return this;
359
+ return this.#retype();
360
+ }
361
+ /** Must equal one of `values` (enum). Narrows the output to the union. */
362
+ enum(values) {
363
+ const allowed = [...values];
364
+ this.#rules.push({
365
+ name: "enum",
366
+ args: { values: allowed },
367
+ validate: (v) => allowed.includes(asPrimitive(v)),
368
+ message: "Invalid value",
369
+ });
370
+ return this.#retype();
371
+ }
372
+ /** Must equal a literal value. */
373
+ literal(value) {
374
+ this.#rules.push({
375
+ name: "literal",
376
+ args: { expectedValue: value },
377
+ validate: (v) => v === value,
378
+ message: `Must be ${String(value)}`,
379
+ });
380
+ return this.#retype();
221
381
  }
222
- /** Minimum length (string) or minimum value (number). */
382
+ /** Minimum length (string) or minimum value (number). Alias of min/minLength. */
223
383
  min(n) {
224
384
  this.#rules.push({
225
385
  name: "min",
226
386
  param: n,
387
+ args: { min: n },
227
388
  validate: (v) => typeof v === "string"
228
389
  ? [...v].length >= n
229
390
  : typeof v === "number"
@@ -233,11 +394,12 @@ export class RuleChain {
233
394
  });
234
395
  return this;
235
396
  }
236
- /** Maximum length (string) or maximum value (number). */
397
+ /** Maximum length (string) or maximum value (number). Alias of max/maxLength. */
237
398
  max(n) {
238
399
  this.#rules.push({
239
400
  name: "max",
240
401
  param: n,
402
+ args: { max: n },
241
403
  validate: (v) => typeof v === "string"
242
404
  ? [...v].length <= n
243
405
  : typeof v === "number"
@@ -247,20 +409,141 @@ export class RuleChain {
247
409
  });
248
410
  return this;
249
411
  }
412
+ /** Minimum length for a string or array (VineJS `minLength`). */
413
+ minLength(n) {
414
+ this.#rules.push({
415
+ name: "minLength",
416
+ param: n,
417
+ args: { min: n },
418
+ validate: (v) => sizedLength(v) >= n,
419
+ message: `Must have at least ${n} characters`,
420
+ });
421
+ return this;
422
+ }
423
+ /** Maximum length for a string or array (VineJS `maxLength`). */
424
+ maxLength(n) {
425
+ this.#rules.push({
426
+ name: "maxLength",
427
+ param: n,
428
+ args: { max: n },
429
+ validate: (v) => {
430
+ const len = sizedLength(v);
431
+ return len >= 0 && len <= n;
432
+ },
433
+ message: `Must not exceed ${n} characters`,
434
+ });
435
+ return this;
436
+ }
437
+ /** Exact length for a string or array (VineJS `fixedLength`). */
438
+ fixedLength(n) {
439
+ this.#rules.push({
440
+ name: "fixedLength",
441
+ param: n,
442
+ args: { size: n },
443
+ validate: (v) => sizedLength(v) === n,
444
+ message: `Must be exactly ${n} characters`,
445
+ });
446
+ return this;
447
+ }
250
448
  /** Must be a valid email. */
251
449
  email() {
252
450
  this.#rules.push({
253
451
  name: "email",
254
- // Mirror the Rust engine's regex exactly (crates/rune-engine/src/engine.rs)
255
- // so the SAME schema validates identically whether or not the native
256
- // binary loaded: no whitespace anywhere (the old TS rule rejected only
257
- // \r\n, silently accepting interior spaces), a single @, dotted domain.
452
+ // Mirror the Rust engine's regex exactly so the SAME schema validates
453
+ // identically whether or not the native binary loaded.
258
454
  validate: (v) => typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
259
455
  message: "Must be a valid email",
260
456
  });
261
457
  return this;
262
458
  }
263
- /** Must be positive (> 0) and finite. */
459
+ /** Must match a regular expression (TS-only never dispatched to Rust). */
460
+ regex(pattern) {
461
+ this.#rules.push({
462
+ name: "regex",
463
+ validate: (v) => typeof v === "string" && pattern.test(v),
464
+ message: "Invalid format",
465
+ });
466
+ return this;
467
+ }
468
+ /** Must be a valid URL (TS-only — uses the WHATWG URL parser). */
469
+ url() {
470
+ this.#rules.push({
471
+ name: "url",
472
+ validate: (v) => typeof v === "string" && isValidUrl(v),
473
+ message: "Must be a valid URL",
474
+ });
475
+ return this;
476
+ }
477
+ /** Must be a valid UUID. */
478
+ uuid() {
479
+ this.#rules.push({
480
+ name: "uuid",
481
+ validate: (v) => typeof v === "string" && UUID_RE.test(v),
482
+ message: "Must be a valid UUID",
483
+ });
484
+ return this;
485
+ }
486
+ /** Must contain only ASCII letters. */
487
+ alpha() {
488
+ this.#rules.push({
489
+ name: "alpha",
490
+ validate: (v) => typeof v === "string" && v.length > 0 && /^[a-zA-Z]+$/.test(v),
491
+ message: "Must contain only letters",
492
+ });
493
+ return this;
494
+ }
495
+ /** Must contain only ASCII letters and digits. */
496
+ alphaNumeric() {
497
+ this.#rules.push({
498
+ name: "alphaNumeric",
499
+ validate: (v) => typeof v === "string" && v.length > 0 && /^[a-zA-Z0-9]+$/.test(v),
500
+ message: "Must contain only letters and numbers",
501
+ });
502
+ return this;
503
+ }
504
+ /** String must start with `substring`. */
505
+ startsWith(substring) {
506
+ this.#rules.push({
507
+ name: "startsWith",
508
+ args: { substring },
509
+ validate: (v) => typeof v === "string" && v.startsWith(substring),
510
+ message: `Must start with ${substring}`,
511
+ });
512
+ return this;
513
+ }
514
+ /** String must end with `substring`. */
515
+ endsWith(substring) {
516
+ this.#rules.push({
517
+ name: "endsWith",
518
+ args: { substring },
519
+ validate: (v) => typeof v === "string" && v.endsWith(substring),
520
+ message: `Must end with ${substring}`,
521
+ });
522
+ return this;
523
+ }
524
+ /** Value must be one of `values`. */
525
+ in(values) {
526
+ const allowed = [...values];
527
+ this.#rules.push({
528
+ name: "in",
529
+ args: { values: allowed },
530
+ validate: (v) => allowed.includes(asPrimitive(v)),
531
+ message: "Invalid value",
532
+ });
533
+ return this;
534
+ }
535
+ /** Value must NOT be one of `values`. */
536
+ notIn(values) {
537
+ const denied = [...values];
538
+ this.#rules.push({
539
+ name: "notIn",
540
+ args: { values: denied },
541
+ validate: (v) => !denied.includes(asPrimitive(v)),
542
+ message: "Invalid value",
543
+ });
544
+ return this;
545
+ }
546
+ /** Number must be positive (> 0) and finite. */
264
547
  positive() {
265
548
  this.#rules.push({
266
549
  name: "positive",
@@ -269,6 +552,96 @@ export class RuleChain {
269
552
  });
270
553
  return this;
271
554
  }
555
+ /** Number must be negative (< 0) and finite. */
556
+ negative() {
557
+ this.#rules.push({
558
+ name: "negative",
559
+ validate: (v) => typeof v === "number" && Number.isFinite(v) && v < 0,
560
+ message: "Must be negative",
561
+ });
562
+ return this;
563
+ }
564
+ /** Number must be >= 0 and finite. */
565
+ nonNegative() {
566
+ this.#rules.push({
567
+ name: "nonNegative",
568
+ validate: (v) => typeof v === "number" && Number.isFinite(v) && v >= 0,
569
+ message: "Must be positive or zero",
570
+ });
571
+ return this;
572
+ }
573
+ /** Number must fall within `[min, max]` (inclusive). */
574
+ range(min, max) {
575
+ this.#rules.push({
576
+ name: "range",
577
+ args: { min, max },
578
+ validate: (v) => typeof v === "number" && Number.isFinite(v) && v >= min && v <= max,
579
+ message: `Must be between ${min} and ${max}`,
580
+ });
581
+ return this;
582
+ }
583
+ /** Number must have at most `digits` decimal places (TS-only). */
584
+ decimal(digits) {
585
+ this.#rules.push({
586
+ name: "decimal",
587
+ args: { digits },
588
+ validate: (v) => {
589
+ if (typeof v !== "number" || !Number.isFinite(v))
590
+ return false;
591
+ const parts = String(v).split(".");
592
+ return (parts[1]?.length ?? 0) <= digits;
593
+ },
594
+ message: `Must have at most ${digits} decimal places`,
595
+ });
596
+ return this;
597
+ }
598
+ /** Must equal a sibling field (VineJS `sameAs`). Cross-field → TS-only. */
599
+ sameAs(otherField) {
600
+ this.#useRules.push({
601
+ __rune: "rule",
602
+ run: (value, field) => {
603
+ const other = readSibling(field, otherField);
604
+ if (value !== other) {
605
+ field.report(`Must match ${otherField}`, "sameAs");
606
+ }
607
+ },
608
+ });
609
+ return this;
610
+ }
611
+ /** Must equal its `<field>_confirmation` sibling (VineJS `confirmed`). */
612
+ confirmed(options) {
613
+ this.#useRules.push({
614
+ __rune: "rule",
615
+ run: (value, field) => {
616
+ const leaf = field.field.split(".").pop() ?? field.field;
617
+ const other = options?.confirmationField ?? `${leaf}_confirmation`;
618
+ if (value !== readSibling(field, other)) {
619
+ field.report("Confirmation does not match", "confirmed");
620
+ }
621
+ },
622
+ });
623
+ return this;
624
+ }
625
+ /** Required only when `otherField` is present (non-null) — else optional. */
626
+ requiredIfExists(otherField) {
627
+ this.#requiredConditions.push({ kind: "exists", otherField });
628
+ return this;
629
+ }
630
+ /** Required only when `otherField` is absent/null — else optional. */
631
+ requiredIfMissing(otherField) {
632
+ this.#requiredConditions.push({ kind: "missing", otherField });
633
+ return this;
634
+ }
635
+ /** Required only when `otherField <op> value` holds — else optional. */
636
+ requiredWhen(otherField, operator, value) {
637
+ this.#requiredConditions.push({
638
+ kind: "when",
639
+ otherField,
640
+ operator,
641
+ value,
642
+ });
643
+ return this;
644
+ }
272
645
  /** Trim whitespace (transform). */
273
646
  trim() {
274
647
  this.#transforms.push({
@@ -277,6 +650,21 @@ export class RuleChain {
277
650
  });
278
651
  return this;
279
652
  }
653
+ /**
654
+ * Post-validation transform changing the output type (VineJS `transform`).
655
+ * `value` is `unknown` — narrow it in the callback (the no-cast rule forbids
656
+ * lying about a dynamically-produced value's static type).
657
+ */
658
+ transform(fn) {
659
+ const next = this.#retype();
660
+ next.#transforms.push({ name: "transform", fn: (v, f) => fn(v, f) });
661
+ return next;
662
+ }
663
+ /** Pre-validation transform of the raw input (VineJS `parse`). */
664
+ parse(fn) {
665
+ this.#preTransforms.push(fn);
666
+ return this;
667
+ }
280
668
  /** Custom validation rule. */
281
669
  custom(name, validate, message) {
282
670
  this.#rules.push({
@@ -286,86 +674,153 @@ export class RuleChain {
286
674
  });
287
675
  return this;
288
676
  }
677
+ /**
678
+ * Attach a `.use()` rule (from {@link createRule}). Unlike `custom`, the rule
679
+ * receives a {@link FieldContext} with the root `data` and `parent`, so it can
680
+ * validate across fields. Runs after this field's type/value rules.
681
+ */
682
+ use(rule) {
683
+ if (rule?.__rune !== "rule" || typeof rule.run !== "function") {
684
+ throw new RuneError("INVALID_RULE", "use() expects a compiled rule — call the factory first", { hint: "use(myRule()) or use(myRule(options)), not use(myRule)" });
685
+ }
686
+ this.#useRules.push(rule);
687
+ return this;
688
+ }
289
689
  /** Set custom error message for the last rule. */
290
690
  message(msg) {
291
691
  if (this.#rules.length === 0) {
292
692
  throw new RuneError("NO_RULE", "message() must be called after a rule");
293
693
  }
294
- this.#rules[this.#rules.length - 1].message = msg;
694
+ const last = this.#rules[this.#rules.length - 1];
695
+ last.message = msg;
696
+ last.hasCustomMessage = true;
295
697
  return this;
296
698
  }
699
+ /** Whether the field is required given the surrounding data (conditionals). */
700
+ #isRequired(ctx) {
701
+ if (this.#requiredConditions.length === 0)
702
+ return true;
703
+ return this.#requiredConditions.some((cond) => evalRequiredCondition(cond, ctx));
704
+ }
297
705
  /** Internal: validate a field value and return errors + transformed value. */
298
- _validateWithTransform(field, value) {
299
- if (value === undefined || value === null) {
300
- if (this.#isOptional)
706
+ _validateWithTransform(field, rawValue, ctx = EMPTY_RUN_CONTEXT) {
707
+ // 0. Pre-validation parse() transforms run on the raw value first.
708
+ let value = rawValue;
709
+ for (const pre of this.#preTransforms) {
710
+ value = pre(value);
711
+ }
712
+ if (value === undefined) {
713
+ if (this.#isOptional || !this.#isRequired(ctx)) {
301
714
  return { errors: [], transformed: value };
302
- return { errors: [this.#requiredError(field)], transformed: value };
715
+ }
716
+ return { errors: [this.#requiredError(field, ctx)], transformed: value };
717
+ }
718
+ if (value === null) {
719
+ // rune treats a `null` value as "absent" for optional/nullable/
720
+ // non-required fields (a deliberate, cross-engine-conformance-tested
721
+ // choice — see the Rust↔TS parity suite). `.nullable()` additionally
722
+ // keeps it in the output. This unifies null/undefined for optional
723
+ // fields, a documented deviation from VineJS's stricter split.
724
+ if (this.#isNullable || this.#isOptional || !this.#isRequired(ctx)) {
725
+ return { errors: [], transformed: value };
726
+ }
727
+ return { errors: [this.#requiredError(field, ctx)], transformed: value };
303
728
  }
304
729
  // 1. Type rules first on the raw value — bail on type mismatch.
305
- const typeError = this.#runTypeRules(field, value);
730
+ const typeError = this.#runTypeRules(field, value, ctx);
306
731
  if (typeError)
307
732
  return { errors: [typeError], transformed: value };
308
733
  // 2. Apply transforms (trim, etc.), then run value rules on the result.
309
- let transformed = this.#applyTransformsTo(value);
310
- const errors = this.#runValueRules(field, transformed);
734
+ let transformed = this.#applyTransformsTo(value, field, ctx);
735
+ const errors = this.#runValueRules(field, transformed, ctx);
736
+ // 3. Vine-style .use() rules — run with a FieldContext exposing the root
737
+ // `data` and `parent`, so a rule can validate across fields.
738
+ if (this.#useRules.length > 0 && !(this.#bail && errors.length > 0)) {
739
+ this.#runUseRules(field, transformed, ctx, errors);
740
+ }
311
741
  // 4. Nested object validation (only if type check passed — not arrays)
312
- if (this.#nestedSchema &&
313
- typeof transformed === "object" &&
314
- transformed !== null &&
315
- !Array.isArray(transformed)) {
316
- transformed = { ...transformed };
742
+ if (this.#nestedSchema && isPlainObject(transformed)) {
743
+ const obj = { ...transformed };
744
+ transformed = obj;
317
745
  for (const [nestedField, chain] of Object.entries(this.#nestedSchema)) {
318
- const nestedValue = transformed[nestedField];
319
- const nestedResult = chain._validateWithTransform(`${field}.${nestedField}`, nestedValue);
746
+ const nestedResult = chain._validateWithTransform(`${field}.${nestedField}`, obj[nestedField], { ...ctx, parent: obj });
320
747
  errors.push(...nestedResult.errors);
321
748
  if (nestedResult.transformed !== undefined) {
322
- transformed[nestedField] =
323
- nestedResult.transformed;
749
+ obj[nestedField] = nestedResult.transformed;
324
750
  }
325
751
  }
326
752
  }
327
753
  // 5. Array item validation
328
754
  if (this.#arrayItemChain && Array.isArray(transformed)) {
329
- transformed = [...transformed];
330
- for (let i = 0; i < transformed.length; i++) {
331
- const itemResult = this.#arrayItemChain._validateWithTransform(`${field}.${i}`, transformed[i]);
755
+ const arr = [...transformed];
756
+ transformed = arr;
757
+ for (let i = 0; i < arr.length; i++) {
758
+ const itemResult = this.#arrayItemChain._validateWithTransform(`${field}.${i}`, arr[i], { ...ctx, parent: arr });
759
+ for (const e of itemResult.errors) {
760
+ if (e.index === undefined)
761
+ e.index = i;
762
+ }
332
763
  errors.push(...itemResult.errors);
333
764
  if (itemResult.transformed !== undefined) {
334
- transformed[i] = itemResult.transformed;
765
+ arr[i] = itemResult.transformed;
335
766
  }
336
767
  }
337
768
  }
338
769
  return { errors, transformed };
339
770
  }
340
- #requiredError(field) {
771
+ /** Run `.use()` rules on the transformed value with a fresh FieldContext. */
772
+ #runUseRules(field, transformed, ctx, errors) {
773
+ const fieldCtx = {
774
+ value: transformed,
775
+ data: ctx.data,
776
+ parent: ctx.parent,
777
+ field,
778
+ meta: ctx.meta,
779
+ isValid: errors.length === 0,
780
+ report(message, rule) {
781
+ errors.push({ field, rule, message });
782
+ },
783
+ };
784
+ for (const rule of this.#useRules) {
785
+ fieldCtx.isValid = errors.length === 0;
786
+ rule.run(transformed, fieldCtx);
787
+ }
788
+ }
789
+ #requiredError(field, ctx) {
341
790
  return {
342
791
  field,
343
792
  rule: "required",
344
- message: resolveValidationMessage("validation.required", `${field} is required`, { field }),
793
+ message: resolveRequiredMessage(field, ctx),
345
794
  };
346
795
  }
347
796
  /** Run the type rules (string/number/…) on the raw value; first failure bails. */
348
- #runTypeRules(field, value) {
797
+ #runTypeRules(field, value, ctx) {
349
798
  for (const rule of this.#rules) {
350
799
  if (TYPE_RULE_NAMES.has(rule.name) && !rule.validate(value)) {
351
800
  return {
352
801
  field,
353
802
  rule: rule.name,
354
- message: resolveRuleMessage(field, rule),
803
+ message: resolveRuleMessage(field, rule, ctx),
804
+ ...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
355
805
  };
356
806
  }
357
807
  }
358
808
  return null;
359
809
  }
360
810
  /** Run the non-type rules (min/max/email/…) on the transformed value. */
361
- #runValueRules(field, transformed) {
811
+ #runValueRules(field, transformed, ctx) {
362
812
  const errors = [];
363
813
  for (const rule of this.#rules) {
364
- if (!TYPE_RULE_NAMES.has(rule.name) && !rule.validate(transformed)) {
814
+ if (TYPE_RULE_NAMES.has(rule.name))
815
+ continue;
816
+ if (this.#bail && errors.length > 0)
817
+ break;
818
+ if (!rule.validate(transformed)) {
365
819
  errors.push({
366
820
  field,
367
821
  rule: rule.name,
368
- message: resolveRuleMessage(field, rule),
822
+ message: resolveRuleMessage(field, rule, ctx),
823
+ ...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
369
824
  });
370
825
  }
371
826
  }
@@ -377,40 +832,139 @@ export class RuleChain {
377
832
  }
378
833
  /** Internal: apply transforms. */
379
834
  _transform(value) {
380
- return this.#applyTransformsTo(value);
835
+ return this.#applyTransformsTo(value, "", EMPTY_RUN_CONTEXT);
381
836
  }
382
- #applyTransformsTo(value) {
837
+ #applyTransformsTo(value, field, ctx) {
383
838
  let result = value;
384
839
  for (const transform of this.#transforms) {
385
- result = transform.fn(result);
840
+ LAST_FIELD.value = result;
841
+ LAST_FIELD.data = ctx.data;
842
+ LAST_FIELD.parent = ctx.parent;
843
+ LAST_FIELD.field = field;
844
+ LAST_FIELD.meta = ctx.meta;
845
+ result = transform.fn(result, LAST_FIELD);
386
846
  }
387
847
  return result;
388
848
  }
389
849
  }
850
+ _a = RuleChain;
851
+ /**
852
+ * Scratch FieldContext reused for `.transform()` callbacks — transforms run
853
+ * inline in {@link RuleChain.#applyTransformsTo}, which repopulates it before
854
+ * each call. A shared object avoids per-transform allocation; it is never
855
+ * retained across calls.
856
+ */
857
+ const LAST_FIELD = {
858
+ value: undefined,
859
+ data: {},
860
+ parent: {},
861
+ field: "",
862
+ meta: {},
863
+ isValid: true,
864
+ report() { },
865
+ };
866
+ /** Coerce a value to a comparable primitive for `in`/`enum` membership. */
867
+ function asPrimitive(value) {
868
+ if (typeof value === "string" ||
869
+ typeof value === "number" ||
870
+ typeof value === "boolean") {
871
+ return value;
872
+ }
873
+ // Non-primitive values can never be a member of a primitive set; return a
874
+ // sentinel that no allowed entry equals.
875
+ return Symbol.iterator.toString();
876
+ }
877
+ /** Code-point length for a string, element count for an array, else -1. */
878
+ function sizedLength(value) {
879
+ if (typeof value === "string")
880
+ return [...value].length;
881
+ if (Array.isArray(value))
882
+ return value.length;
883
+ return -1;
884
+ }
885
+ /** WHATWG URL validity — accepts only http/https to avoid `mailto:` etc. */
886
+ function isValidUrl(value) {
887
+ try {
888
+ const url = new URL(value);
889
+ return url.protocol === "http:" || url.protocol === "https:";
890
+ }
891
+ catch {
892
+ return false;
893
+ }
894
+ }
895
+ /** Read a sibling field's value from the immediate parent (object only). */
896
+ function readSibling(field, name) {
897
+ const parent = field.parent;
898
+ if (Array.isArray(parent))
899
+ return undefined;
900
+ return parent[name];
901
+ }
902
+ /** Evaluate whether a `requiredWhen`-family condition makes the field required. */
903
+ function evalRequiredCondition(cond, ctx) {
904
+ const other = isPlainObject(ctx.parent)
905
+ ? ctx.parent[cond.otherField]
906
+ : ctx.data[cond.otherField];
907
+ const present = other !== undefined && other !== null;
908
+ if (cond.kind === "exists")
909
+ return present;
910
+ if (cond.kind === "missing")
911
+ return !present;
912
+ switch (cond.operator) {
913
+ case "=":
914
+ return other === cond.value;
915
+ case "!=":
916
+ return other !== cond.value;
917
+ case ">":
918
+ return typeof other === "number" && typeof cond.value === "number"
919
+ ? other > cond.value
920
+ : false;
921
+ case "<":
922
+ return typeof other === "number" && typeof cond.value === "number"
923
+ ? other < cond.value
924
+ : false;
925
+ case ">=":
926
+ return typeof other === "number" && typeof cond.value === "number"
927
+ ? other >= cond.value
928
+ : false;
929
+ case "<=":
930
+ return typeof other === "number" && typeof cond.value === "number"
931
+ ? other <= cond.value
932
+ : false;
933
+ case "in":
934
+ return Array.isArray(cond.value) && cond.value.includes(other);
935
+ case "notIn":
936
+ return Array.isArray(cond.value) && !cond.value.includes(other);
937
+ default:
938
+ return false;
939
+ }
940
+ }
941
+ /** No-op alias of {@link schema} — VineJS `vine.compile()` API parity. */
942
+ export function compile(s) {
943
+ return s;
944
+ }
390
945
  /** Entry point for building rules. */
391
946
  export const rules = {
392
947
  string: () => new RuleChain().string(),
393
948
  number: () => new RuleChain().number(),
394
949
  boolean: () => new RuleChain().boolean(),
395
950
  any: () => new RuleChain(),
951
+ object: (shape) => new RuleChain().object(shape),
952
+ array: (item) => new RuleChain().array(item),
953
+ enum: (values) => new RuleChain().enum(values),
954
+ literal: (value) => new RuleChain().literal(value),
396
955
  };
397
956
  /** Serialize schema + data and validate via Rust NAPI. */
398
957
  function validateWithRust(fields, data) {
399
958
  const schemaDesc = {};
400
959
  for (const [field, chain] of Object.entries(fields)) {
401
- const rules = chain.rules.map((r) => ({
960
+ const ruleDescs = chain.rules.map((r) => ({
402
961
  name: r.name,
403
- // Serialize THIS rule's own param. Previously a find-first lookup by
404
- // rule name returned the first matching rule's param, so min(3).min(5)
405
- // sent both Rust entries as min=3, dropping the 5 bound (audit 2026-06-13).
406
- params: r.name === "min"
407
- ? { min: r.param }
408
- : r.name === "max"
409
- ? { max: r.param }
410
- : null,
962
+ // Serialize THIS rule's own args (per-rule, so min(3).min(5) keeps both
963
+ // bounds a find-first lookup previously collapsed them).
964
+ params: r.args ?? null,
411
965
  }));
412
966
  schemaDesc[field] = {
413
- rules,
967
+ rules: ruleDescs,
414
968
  optional: chain.isOptionalField,
415
969
  transforms: chain.transforms.map((t) => t.name),
416
970
  };