@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/src/Schema.ts CHANGED
@@ -4,7 +4,9 @@
4
4
  * @implements FR38, FR39, FR40, FR41
5
5
  */
6
6
 
7
- import { RuneError } from "./errors.js";
7
+ import type { RuneErrorNode } from "./errors.js";
8
+ import { RuneError, RuneValidationError } from "./errors.js";
9
+ import type { MessagesProviderContract } from "./MessagesProvider.js";
8
10
  import {
9
11
  isNativeAvailable,
10
12
  validateNative,
@@ -21,6 +23,81 @@ export interface ValidationError {
21
23
  field: string;
22
24
  rule: string;
23
25
  message: string;
26
+ /** Array index when the field is an array item (VineJS parity). */
27
+ index?: number;
28
+ /** Rule metadata carried for reporters/i18n (e.g. `{ min: 3 }`). */
29
+ meta?: Record<string, unknown>;
30
+ }
31
+
32
+ /**
33
+ * Field context handed to `.use()` rules — mirrors VineJS's field context. It
34
+ * exposes the value plus the surrounding data so a rule can validate across
35
+ * fields (e.g. `password === passwordConfirmation`), and a `report()` sink to
36
+ * raise errors (VineJS reports instead of returning a boolean).
37
+ */
38
+ export interface FieldContext {
39
+ /** The current field value (post-transform). */
40
+ value: unknown;
41
+ /** The root object being validated. Shared across fields — do NOT mutate. */
42
+ data: Record<string, unknown>;
43
+ /** The immediate parent container of this field (object or array). */
44
+ parent: Record<string, unknown> | unknown[];
45
+ /** Dotted path to the field, e.g. `address.city` or `tags.0`. */
46
+ field: string;
47
+ /** Runtime metadata passed to `validate(data, { meta })`. */
48
+ meta: Record<string, unknown>;
49
+ /** `true` while no error has been reported for this field yet. */
50
+ isValid: boolean;
51
+ /** Report a validation failure for this field. */
52
+ report(message: string, rule: string): void;
53
+ }
54
+
55
+ /**
56
+ * A `.use()` rule validator — VineJS shape `(value, options, field)`. Report
57
+ * failures via `field.report(...)`; the return value is ignored.
58
+ */
59
+ export type RuleValidator<Options = undefined> = (
60
+ value: unknown,
61
+ options: Options,
62
+ field: FieldContext,
63
+ ) => void;
64
+
65
+ /** A compiled `.use()` rule produced by {@link createRule}. */
66
+ export interface CompiledRule {
67
+ readonly __rune: "rule";
68
+ run(value: unknown, field: FieldContext): void;
69
+ }
70
+
71
+ /**
72
+ * Turn a validator function into a reusable `.use()` rule — VineJS's
73
+ * `createRule`. Returns a factory: call it with the rule's options to get a
74
+ * `CompiledRule`, then attach it with `chain.use(rule(options))`.
75
+ *
76
+ * const sameAs = createRule<string>((value, other, field) => {
77
+ * if (value !== field.data[other]) {
78
+ * field.report(`Must match ${other}`, 'sameAs')
79
+ * }
80
+ * })
81
+ * schema({
82
+ * password: rules.string().min(8),
83
+ * passwordConfirmation: rules.string().use(sameAs('password')),
84
+ * })
85
+ */
86
+ export function createRule(
87
+ validator: RuleValidator<undefined>,
88
+ ): () => CompiledRule;
89
+ export function createRule<Options>(
90
+ validator: RuleValidator<Options>,
91
+ ): (options: Options) => CompiledRule;
92
+ export function createRule<Options>(
93
+ validator: RuleValidator<Options>,
94
+ ): (options: Options) => CompiledRule {
95
+ return (options: Options): CompiledRule => ({
96
+ __rune: "rule",
97
+ run(value: unknown, field: FieldContext): void {
98
+ validator(value, options, field);
99
+ },
100
+ });
24
101
  }
25
102
 
26
103
  /**
@@ -32,17 +109,57 @@ export type ValidationResult<T = Record<string, unknown>> =
32
109
  | { valid: true; errors: ValidationError[]; data: T }
33
110
  | { valid: false; errors: ValidationError[]; data?: undefined };
34
111
 
112
+ /** Options for {@link ValidationSchema.validate}. */
113
+ export interface ValidateOptions {
114
+ /** Runtime metadata exposed to `.use()` rules via `field.meta` (VineJS parity). */
115
+ meta?: Record<string, unknown>;
116
+ /**
117
+ * VineJS-style messages provider. When supplied, default rule messages are
118
+ * resolved through it (custom `.message()` overrides still win, and the
119
+ * provider takes precedence over a globally bound translator).
120
+ */
121
+ messagesProvider?: MessagesProviderContract;
122
+ }
123
+
35
124
  export interface ValidationSchema<T = Record<string, unknown>> {
36
125
  fields: Record<string, RuleChain>;
37
- validate(data: unknown): ValidationResult<T>;
126
+ /** Result-based validation (superset) — never throws. */
127
+ validate(data: unknown, options?: ValidateOptions): ValidationResult<T>;
128
+ /**
129
+ * Throwing validation (VineJS/Adonis parity). Returns the validated data on
130
+ * success; throws {@link RuneValidationError} (`E_VALIDATION_ERROR`, HTTP 422)
131
+ * with a structured `.messages` array on failure.
132
+ */
133
+ validateOrThrow(data: unknown, options?: ValidateOptions): T;
134
+ }
135
+
136
+ /** Context threaded through validation so field rules can reach root/parent/meta. */
137
+ interface RunContext {
138
+ data: Record<string, unknown>;
139
+ parent: Record<string, unknown> | unknown[];
140
+ meta: Record<string, unknown>;
141
+ messagesProvider?: MessagesProviderContract;
38
142
  }
39
143
 
144
+ /** Default context for internal callers that don't supply one (no root available). */
145
+ const EMPTY_RUN_CONTEXT: RunContext = { data: {}, parent: {}, meta: {} };
146
+
40
147
  /** Type guard: narrows `unknown` to a plain object (non-null, non-array, typeof 'object'). */
41
148
  function isPlainObject(value: unknown): value is Record<string, unknown> {
42
149
  return typeof value === "object" && value !== null && !Array.isArray(value);
43
150
  }
44
151
 
45
- /** Rules the Rust validation engine can handle natively. */
152
+ /**
153
+ * Rules the Rust validation engine (`crates/rune-engine/src/engine.rs`)
154
+ * ACTUALLY implements. A schema built from only these rules can be validated
155
+ * natively; anything else routes to the TS path (`rule.validate`).
156
+ *
157
+ * CRITICAL: a rule name here that the Rust engine does not implement is a
158
+ * SILENT VALIDATION BYPASS — the engine's `_ => {}` arm skips unknown rules, so
159
+ * the constraint never runs. Every entry MUST have a matching arm in engine.rs.
160
+ * The TS chain rules (minLength/uuid/alpha/in/enum/range/…) live only in the TS
161
+ * validator, so they are deliberately absent here.
162
+ */
46
163
  const STANDARD_RULES: ReadonlySet<string> = new Set([
47
164
  "string",
48
165
  "number",
@@ -51,9 +168,23 @@ const STANDARD_RULES: ReadonlySet<string> = new Set([
51
168
  "max",
52
169
  "email",
53
170
  "positive",
171
+ "minLength",
172
+ "maxLength",
173
+ "fixedLength",
174
+ "uuid",
175
+ "alpha",
176
+ "alphaNumeric",
177
+ "startsWith",
178
+ "endsWith",
179
+ "in",
180
+ "notIn",
181
+ "enum",
182
+ "negative",
183
+ "nonNegative",
184
+ "range",
54
185
  ]);
55
186
 
56
- /** Default messages for standard rules — used to detect custom-message overrides. */
187
+ /** Default messages for standard rules — used only for translator-key fallback. */
57
188
  const STANDARD_MSGS: Readonly<Record<string, string>> = {
58
189
  string: "Must be a string",
59
190
  number: "Must be a number",
@@ -62,6 +193,20 @@ const STANDARD_MSGS: Readonly<Record<string, string>> = {
62
193
  max: "Maximum",
63
194
  email: "Must be a valid email",
64
195
  positive: "Must be positive",
196
+ minLength: "Too short",
197
+ maxLength: "Too long",
198
+ fixedLength: "Wrong length",
199
+ alpha: "Must contain only letters",
200
+ alphaNumeric: "Must contain only letters and numbers",
201
+ startsWith: "Invalid prefix",
202
+ endsWith: "Invalid suffix",
203
+ uuid: "Must be a valid UUID",
204
+ in: "Invalid value",
205
+ notIn: "Invalid value",
206
+ enum: "Invalid value",
207
+ range: "Out of range",
208
+ negative: "Must be negative",
209
+ nonNegative: "Must be positive or zero",
65
210
  };
66
211
 
67
212
  const TYPE_RULE_NAMES: ReadonlySet<string> = new Set([
@@ -73,15 +218,8 @@ const TYPE_RULE_NAMES: ReadonlySet<string> = new Set([
73
218
  ]);
74
219
  let validationTranslator: ValidationTranslator | undefined;
75
220
 
76
- function hasDefaultMessage(rule: RuleDef): boolean {
77
- if (rule.name === "min" || rule.name === "max") {
78
- if (typeof rule.param !== "number") return false;
79
- const expected = `${rule.name === "min" ? "Minimum" : "Maximum"} ${rule.param}`;
80
- return rule.message === expected;
81
- }
82
-
83
- const defaultMsg = STANDARD_MSGS[rule.name];
84
- return defaultMsg !== undefined && rule.message === defaultMsg;
221
+ function hasCustomMessage(rule: RuleDef): boolean {
222
+ return rule.hasCustomMessage === true;
85
223
  }
86
224
 
87
225
  function resolveValidationMessage(
@@ -96,104 +234,213 @@ function resolveValidationMessage(
96
234
  return fallback;
97
235
  }
98
236
 
99
- function resolveRuleMessage(field: string, rule: RuleDef): string {
100
- const fallback = rule.message;
101
- if (!STANDARD_RULES.has(rule.name)) {
102
- return fallback;
237
+ /** Args carried on a rule, exposed to i18n/providers via message interpolation. */
238
+ function ruleArgs(rule: RuleDef): Record<string, unknown> | undefined {
239
+ return rule.args;
240
+ }
241
+
242
+ /**
243
+ * Resolve the final message for a failing rule. Precedence:
244
+ * 1. explicit `.message()` override (always wins),
245
+ * 2. a per-call {@link MessagesProviderContract} (VineJS parity),
246
+ * 3. a globally bound translator (rune's rosetta superset),
247
+ * 4. the rule's raw default message.
248
+ */
249
+ function resolveRuleMessage(
250
+ field: string,
251
+ rule: RuleDef,
252
+ ctx: RunContext,
253
+ ): string {
254
+ if (hasCustomMessage(rule)) {
255
+ return rule.message;
103
256
  }
104
- if (!hasDefaultMessage(rule)) {
105
- return fallback;
257
+
258
+ const args = ruleArgs(rule);
259
+ if (ctx.messagesProvider) {
260
+ return ctx.messagesProvider.getMessage(
261
+ rule.message,
262
+ rule.name,
263
+ field,
264
+ args,
265
+ );
106
266
  }
107
267
 
108
- const params: ValidationMessageParams = { field };
109
- if (rule.name === "min" && typeof rule.param === "number") {
110
- params.min = rule.param;
268
+ if (!STANDARD_RULES.has(rule.name)) {
269
+ return rule.message;
111
270
  }
112
- if (rule.name === "max" && typeof rule.param === "number") {
113
- params.max = rule.param;
271
+
272
+ const params: ValidationMessageParams = { field };
273
+ if (typeof rule.param === "number") {
274
+ if (rule.name === "min" || rule.name === "minLength")
275
+ params.min = rule.param;
276
+ if (rule.name === "max" || rule.name === "maxLength")
277
+ params.max = rule.param;
114
278
  }
279
+ return resolveValidationMessage(
280
+ `validation.${rule.name}`,
281
+ rule.message,
282
+ params,
283
+ );
284
+ }
115
285
 
116
- return resolveValidationMessage(`validation.${rule.name}`, fallback, params);
286
+ /** Resolve the "required" message through provider → translator → fallback. */
287
+ function resolveRequiredMessage(field: string, ctx: RunContext): string {
288
+ if (ctx.messagesProvider) {
289
+ return ctx.messagesProvider.getMessage(
290
+ `${field} is required`,
291
+ "required",
292
+ field,
293
+ );
294
+ }
295
+ return resolveValidationMessage(
296
+ "validation.required",
297
+ `${field} is required`,
298
+ { field },
299
+ );
117
300
  }
118
301
 
119
302
  /** Compute once: does any field rule prevent dispatching to Rust? */
120
303
  function detectHasCustomRules(fields: Record<string, RuleChain>): boolean {
121
304
  return Object.values(fields).some((chain) => {
305
+ if (chain.useRules.length > 0) return true; // .use() rule — TS-only (Rust can't run JS)
306
+ if (chain.hasConditionalRequired) return true; // requiredWhen — TS-only
307
+ if (chain.preTransforms.length > 0) return true; // .parse() — TS-only
308
+ if (chain.transforms.length > 0) return true; // .transform() — Rust gets only the NAME, can't run a JS fn
309
+ if (chain.isNullable) return true; // .nullable() — the flag is not sent to the Rust engine
122
310
  return chain.rules.some((r) => {
123
311
  if (!STANDARD_RULES.has(r.name)) return true; // custom rule
124
- if (!hasDefaultMessage(r)) return true; // custom message
312
+ if (hasCustomMessage(r)) return true; // custom message
125
313
  return false;
126
314
  });
127
315
  });
128
316
  }
129
317
 
318
+ /** Extract the phantom output type of a chain. */
319
+ type OutputOf<C> = C extends RuleChain<infer O> ? O : never;
320
+ /** Keys whose output includes `undefined` become optional in the inferred shape. */
321
+ type OptionalKeys<S> = {
322
+ [K in keyof S]: undefined extends OutputOf<S[K]> ? K : never;
323
+ }[keyof S];
324
+ /** Flatten an intersection into a single readable object type. */
325
+ type Prettify<T> = { [K in keyof T]: T[K] } & unknown;
326
+
327
+ /**
328
+ * Infer the validated data shape from a schema's field map — the type
329
+ * `result.data` carries once `result.valid === true`. `rules.string()` →
330
+ * `string`, `.optional()` → an optional key, `.nullable()` → `T | null`,
331
+ * `.object(shape)`/`.array(item)` recurse.
332
+ */
333
+ export type Infer<S> = Prettify<
334
+ {
335
+ [K in Exclude<keyof S, OptionalKeys<S>>]: OutputOf<S[K]>;
336
+ } & {
337
+ [K in OptionalKeys<S>]?: Exclude<OutputOf<S[K]>, undefined>;
338
+ }
339
+ >;
340
+
130
341
  /**
131
342
  * Create a validation schema.
132
343
  *
133
- * Pass `T` explicitly when the caller wants `result.data` typed as a concrete
134
- * shape after `result.valid === true` narrows the union — runtime validation
135
- * is unchanged, the generic only types the success branch.
344
+ * The field map's rule chains are phantom-typed, so `result.data` is inferred
345
+ * automatically `schema({ email: rules.string(), age: rules.number() })`
346
+ * types `data` as `{ email: string; age: number }` with no manual generic.
136
347
  *
137
- * const RegisterValidator = schema<{ email: string; password: string }>({
348
+ * const RegisterValidator = schema({
138
349
  * email: rules.string().email(),
139
- * password: rules.string().min(8),
350
+ * age: rules.number().optional(),
140
351
  * });
352
+ * // Infer<typeof RegisterValidator> not needed — result.data is typed.
141
353
  *
142
- * The default `Record<string, unknown>` matches the historical untyped surface
143
- * so existing call sites that read `result.data` field-by-field with their
144
- * own narrowing continue to compile.
354
+ * An explicit generic is still accepted for back-compat
355
+ * (`schema<MyType>({ ... })`), overriding inference.
145
356
  */
357
+ export function schema<S extends Record<string, RuleChain>>(
358
+ fields: S,
359
+ ): ValidationSchema<Infer<S>>;
146
360
  export function schema<T = Record<string, unknown>>(
147
361
  fields: Record<string, RuleChain>,
148
- ): ValidationSchema<T> {
362
+ ): ValidationSchema<T>;
363
+ export function schema(
364
+ fields: Record<string, RuleChain>,
365
+ ): ValidationSchema<Record<string, unknown>> {
149
366
  // Computed once at construction time, not per validate() call.
150
367
  const hasCustomRules = detectHasCustomRules(fields);
151
368
 
152
- return {
153
- fields,
154
- validate(data: unknown): ValidationResult<T> {
155
- if (!isPlainObject(data)) {
156
- return {
157
- valid: false,
158
- errors: [
159
- {
160
- field: "_root",
161
- rule: "type",
162
- message: "Input must be an object",
163
- },
164
- ],
165
- };
166
- }
369
+ function validate(
370
+ data: unknown,
371
+ options?: ValidateOptions,
372
+ ): ValidationResult<Record<string, unknown>> {
373
+ if (!isPlainObject(data)) {
374
+ return {
375
+ valid: false,
376
+ errors: [
377
+ { field: "_root", rule: "type", message: "Input must be an object" },
378
+ ],
379
+ };
380
+ }
167
381
 
168
- if (!hasCustomRules && !validationTranslator) {
169
- if (isNativeAvailable()) {
170
- return validateWithRust<T>(fields, data);
171
- }
172
- // This schema would have used the native engine, but it isn't
173
- // loaded — surface the platform-dependent TS fallback once instead
174
- // of diverging silently. (Schemas with custom rules / a translator
175
- // always run on TS by design and don't warn.)
176
- warnNativeUnavailableOnce();
382
+ const provider = options?.messagesProvider;
383
+ if (!hasCustomRules && !validationTranslator && !provider) {
384
+ if (isNativeAvailable()) {
385
+ return validateWithRust(fields, data);
177
386
  }
387
+ // This schema would have used the native engine, but it isn't loaded —
388
+ // surface the platform-dependent TS fallback once instead of diverging
389
+ // silently.
390
+ warnNativeUnavailableOnce();
391
+ }
178
392
 
179
- const errors: ValidationError[] = [];
180
- const validated: Record<string, unknown> = {};
393
+ const errors: ValidationError[] = [];
394
+ const validated: Record<string, unknown> = {};
395
+ const rootCtx: RunContext = {
396
+ data,
397
+ parent: data,
398
+ meta: options?.meta ?? {},
399
+ messagesProvider: provider,
400
+ };
181
401
 
182
- for (const [field, chain] of Object.entries(fields)) {
183
- const value = data[field];
184
- const result = chain._validateWithTransform(field, value);
185
- errors.push(...result.errors);
186
- if (result.errors.length === 0 && value !== undefined) {
187
- validated[field] = result.transformed;
188
- }
402
+ for (const [field, chain] of Object.entries(fields)) {
403
+ const value = data[field];
404
+ const result = chain._validateWithTransform(field, value, rootCtx);
405
+ errors.push(...result.errors);
406
+ // Gate on the TRANSFORMED result, not the raw input: a pre-transform
407
+ // (`parse(() => 42)`) can produce a value for an absent field, and that
408
+ // value must land in `data` — testing the raw `value` dropped it.
409
+ if (result.errors.length === 0 && result.transformed !== undefined) {
410
+ validated[field] = result.transformed;
189
411
  }
412
+ }
190
413
 
191
- if (errors.length === 0) {
192
- return { valid: true, errors, data: validated as T };
193
- }
194
- return { valid: false, errors };
195
- },
414
+ if (errors.length === 0) {
415
+ return { valid: true, errors, data: validated };
416
+ }
417
+ return { valid: false, errors };
418
+ }
419
+
420
+ function validateOrThrow(
421
+ data: unknown,
422
+ options?: ValidateOptions,
423
+ ): Record<string, unknown> {
424
+ const result = validate(data, options);
425
+ if (result.valid) {
426
+ return result.data;
427
+ }
428
+ throw new RuneValidationError(result.errors.map(toErrorNode));
429
+ }
430
+
431
+ return { fields, validate, validateOrThrow };
432
+ }
433
+
434
+ /** Map an internal {@link ValidationError} to a {@link RuneErrorNode}. */
435
+ function toErrorNode(error: ValidationError): RuneErrorNode {
436
+ const node: RuneErrorNode = {
437
+ message: error.message,
438
+ rule: error.rule,
439
+ field: error.field,
196
440
  };
441
+ if (error.index !== undefined) node.index = error.index;
442
+ if (error.meta !== undefined) node.meta = error.meta;
443
+ return node;
197
444
  }
198
445
 
199
446
  export function setValidationTranslator(
@@ -212,17 +459,47 @@ export function bindRosetta(rosetta: {
212
459
  export interface RuleDef {
213
460
  name: string;
214
461
  param?: number;
462
+ /** Interpolation args exposed to i18n/messages providers (e.g. `{ min: 3 }`). */
463
+ args?: Record<string, unknown>;
215
464
  validate: (value: unknown) => boolean;
216
465
  message: string;
466
+ /** Set when `.message()` overrode this rule's default text. */
467
+ hasCustomMessage?: boolean;
217
468
  }
218
469
 
219
- /** Rule chain fluent validation builder. */
220
- export class RuleChain {
470
+ /** A conditional-required condition (VineJS `requiredWhen` family). */
471
+ interface RequiredCondition {
472
+ kind: "exists" | "missing" | "when";
473
+ otherField: string;
474
+ operator?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn";
475
+ value?: unknown;
476
+ }
477
+
478
+ /** Phantom brand carrying the inferred output type (never assigned at runtime). */
479
+ declare const OUTPUT: unique symbol;
480
+
481
+ /** UUID (any version/variant) — identical to the Rust engine's pattern. */
482
+ const UUID_RE =
483
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
484
+
485
+ /** Rule chain — fluent, phantom-typed validation builder. */
486
+ export class RuleChain<Output = unknown> {
487
+ /** Phantom output type — drives {@link Infer}; never read at runtime. */
488
+ declare readonly [OUTPUT]: Output;
489
+
221
490
  #rules: RuleDef[] = [];
222
491
  #isOptional = false;
223
- #transforms: Array<{ name: string; fn: (value: unknown) => unknown }> = [];
492
+ #isNullable = false;
493
+ #bail = false;
494
+ #transforms: Array<{
495
+ name: string;
496
+ fn: (value: unknown, field: FieldContext) => unknown;
497
+ }> = [];
498
+ #preTransforms: Array<(value: unknown) => unknown> = [];
224
499
  #nestedSchema: Record<string, RuleChain> | null = null;
225
500
  #arrayItemChain: RuleChain | null = null;
501
+ #useRules: CompiledRule[] = [];
502
+ #requiredConditions: RequiredCondition[] = [];
226
503
 
227
504
  /** Public read access to rules (for OpenAPI generation, Rust bridge). */
228
505
  get rules(): readonly RuleDef[] {
@@ -231,77 +508,161 @@ export class RuleChain {
231
508
  get isOptionalField(): boolean {
232
509
  return this.#isOptional;
233
510
  }
511
+ /** Public read access to the `.nullable()` flag (keeps such schemas off the native path). */
512
+ get isNullable(): boolean {
513
+ return this.#isNullable;
514
+ }
234
515
  get transforms(): ReadonlyArray<{
235
516
  name: string;
236
- fn: (value: unknown) => unknown;
517
+ fn: (value: unknown, field: FieldContext) => unknown;
237
518
  }> {
238
519
  return this.#transforms;
239
520
  }
521
+ /** Public read access to `.use()` rules (used to keep such schemas off the native path). */
522
+ get useRules(): readonly CompiledRule[] {
523
+ return this.#useRules;
524
+ }
525
+ /** Public read access to `.parse()` pre-transforms (kept off the native path). */
526
+ get preTransforms(): ReadonlyArray<(value: unknown) => unknown> {
527
+ return this.#preTransforms;
528
+ }
529
+ /** Whether this chain carries a `requiredWhen`-family condition. */
530
+ get hasConditionalRequired(): boolean {
531
+ return this.#requiredConditions.length > 0;
532
+ }
533
+
534
+ /**
535
+ * Re-type this chain to a new phantom output while carrying its runtime state
536
+ * forward. Cast-free: `new RuleChain<U>()` is genuinely `RuleChain<U>` because
537
+ * the brand is `declare`-only. State arrays are copied so the abandoned source
538
+ * chain can't be mutated through the new one.
539
+ */
540
+ #retype<U>(): RuleChain<U> {
541
+ const next = new RuleChain<U>();
542
+ next.#rules = [...this.#rules];
543
+ next.#isOptional = this.#isOptional;
544
+ next.#isNullable = this.#isNullable;
545
+ next.#bail = this.#bail;
546
+ next.#transforms = [...this.#transforms];
547
+ next.#preTransforms = [...this.#preTransforms];
548
+ next.#nestedSchema = this.#nestedSchema;
549
+ next.#arrayItemChain = this.#arrayItemChain;
550
+ next.#useRules = [...this.#useRules];
551
+ next.#requiredConditions = [...this.#requiredConditions];
552
+ return next;
553
+ }
240
554
 
241
- /** Mark field as optional. */
242
- optional(): this {
555
+ /** Mark field as optional (absent / `undefined` allowed). */
556
+ optional(): RuleChain<Output | undefined> {
243
557
  this.#isOptional = true;
244
558
  return this;
245
559
  }
246
560
 
561
+ /** Mark field as nullable (`null` allowed, kept in the output). */
562
+ nullable(): RuleChain<Output | null> {
563
+ this.#isNullable = true;
564
+ return this;
565
+ }
566
+
567
+ /** Mark field as both optional and nullable. */
568
+ nullish(): RuleChain<Output | null | undefined> {
569
+ this.#isOptional = true;
570
+ this.#isNullable = true;
571
+ return this;
572
+ }
573
+
574
+ /** Stop at the first failing rule for this field (VineJS bail). */
575
+ bail(enabled = true): this {
576
+ this.#bail = enabled;
577
+ return this;
578
+ }
579
+
247
580
  /** Must be an object matching a nested schema. */
248
- object(shape: Record<string, RuleChain>): this {
581
+ object<Sh extends Record<string, RuleChain>>(
582
+ shape: Sh,
583
+ ): RuleChain<Infer<Sh>> {
249
584
  this.#rules.push({
250
585
  name: "object",
251
- validate: (v) => typeof v === "object" && v !== null && !Array.isArray(v),
586
+ validate: (v) => isPlainObject(v),
252
587
  message: "Must be an object",
253
588
  });
254
589
  this.#nestedSchema = shape;
255
- return this;
590
+ return this.#retype<Infer<Sh>>();
256
591
  }
257
592
 
258
593
  /** Must be an array. Items validated by the provided chain. */
259
- array(itemChain?: RuleChain): this {
594
+ array<Item extends RuleChain>(itemChain?: Item): RuleChain<OutputOf<Item>[]> {
260
595
  this.#rules.push({
261
596
  name: "array",
262
597
  validate: (v) => Array.isArray(v),
263
598
  message: "Must be an array",
264
599
  });
265
600
  this.#arrayItemChain = itemChain ?? null;
266
- return this;
601
+ return this.#retype<OutputOf<Item>[]>();
267
602
  }
268
603
 
269
604
  /** Must be a string. */
270
- string(): this {
605
+ string(): RuleChain<string> {
271
606
  this.#rules.push({
272
607
  name: "string",
273
608
  validate: (v) => typeof v === "string",
274
609
  message: "Must be a string",
275
610
  });
276
- return this;
611
+ return this.#retype<string>();
277
612
  }
278
613
 
279
614
  /** Must be a number. */
280
- number(): this {
615
+ number(): RuleChain<number> {
281
616
  this.#rules.push({
282
617
  name: "number",
283
618
  validate: (v) =>
284
619
  typeof v === "number" && !Number.isNaN(v) && Number.isFinite(v),
285
620
  message: "Must be a number",
286
621
  });
287
- return this;
622
+ return this.#retype<number>();
288
623
  }
289
624
 
290
625
  /** Must be a boolean. */
291
- boolean(): this {
626
+ boolean(): RuleChain<boolean> {
292
627
  this.#rules.push({
293
628
  name: "boolean",
294
629
  validate: (v) => typeof v === "boolean",
295
630
  message: "Must be a boolean",
296
631
  });
297
- return this;
632
+ return this.#retype<boolean>();
633
+ }
634
+
635
+ /** Must equal one of `values` (enum). Narrows the output to the union. */
636
+ enum<const V extends readonly (string | number | boolean)[]>(
637
+ values: V,
638
+ ): RuleChain<V[number]> {
639
+ const allowed = [...values];
640
+ this.#rules.push({
641
+ name: "enum",
642
+ args: { values: allowed },
643
+ validate: (v) => allowed.includes(asPrimitive(v)),
644
+ message: "Invalid value",
645
+ });
646
+ return this.#retype<V[number]>();
298
647
  }
299
648
 
300
- /** Minimum length (string) or minimum value (number). */
649
+ /** Must equal a literal value. */
650
+ literal<V extends string | number | boolean>(value: V): RuleChain<V> {
651
+ this.#rules.push({
652
+ name: "literal",
653
+ args: { expectedValue: value },
654
+ validate: (v) => v === value,
655
+ message: `Must be ${String(value)}`,
656
+ });
657
+ return this.#retype<V>();
658
+ }
659
+
660
+ /** Minimum length (string) or minimum value (number). Alias of min/minLength. */
301
661
  min(n: number): this {
302
662
  this.#rules.push({
303
663
  name: "min",
304
664
  param: n,
665
+ args: { min: n },
305
666
  validate: (v) =>
306
667
  typeof v === "string"
307
668
  ? [...v].length >= n
@@ -313,11 +674,12 @@ export class RuleChain {
313
674
  return this;
314
675
  }
315
676
 
316
- /** Maximum length (string) or maximum value (number). */
677
+ /** Maximum length (string) or maximum value (number). Alias of max/maxLength. */
317
678
  max(n: number): this {
318
679
  this.#rules.push({
319
680
  name: "max",
320
681
  param: n,
682
+ args: { max: n },
321
683
  validate: (v) =>
322
684
  typeof v === "string"
323
685
  ? [...v].length <= n
@@ -329,14 +691,51 @@ export class RuleChain {
329
691
  return this;
330
692
  }
331
693
 
694
+ /** Minimum length for a string or array (VineJS `minLength`). */
695
+ minLength(n: number): this {
696
+ this.#rules.push({
697
+ name: "minLength",
698
+ param: n,
699
+ args: { min: n },
700
+ validate: (v) => sizedLength(v) >= n,
701
+ message: `Must have at least ${n} characters`,
702
+ });
703
+ return this;
704
+ }
705
+
706
+ /** Maximum length for a string or array (VineJS `maxLength`). */
707
+ maxLength(n: number): this {
708
+ this.#rules.push({
709
+ name: "maxLength",
710
+ param: n,
711
+ args: { max: n },
712
+ validate: (v) => {
713
+ const len = sizedLength(v);
714
+ return len >= 0 && len <= n;
715
+ },
716
+ message: `Must not exceed ${n} characters`,
717
+ });
718
+ return this;
719
+ }
720
+
721
+ /** Exact length for a string or array (VineJS `fixedLength`). */
722
+ fixedLength(n: number): this {
723
+ this.#rules.push({
724
+ name: "fixedLength",
725
+ param: n,
726
+ args: { size: n },
727
+ validate: (v) => sizedLength(v) === n,
728
+ message: `Must be exactly ${n} characters`,
729
+ });
730
+ return this;
731
+ }
732
+
332
733
  /** Must be a valid email. */
333
734
  email(): this {
334
735
  this.#rules.push({
335
736
  name: "email",
336
- // Mirror the Rust engine's regex exactly (crates/rune-engine/src/engine.rs)
337
- // so the SAME schema validates identically whether or not the native
338
- // binary loaded: no whitespace anywhere (the old TS rule rejected only
339
- // \r\n, silently accepting interior spaces), a single @, dotted domain.
737
+ // Mirror the Rust engine's regex exactly so the SAME schema validates
738
+ // identically whether or not the native binary loaded.
340
739
  validate: (v) =>
341
740
  typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
342
741
  message: "Must be a valid email",
@@ -344,7 +743,105 @@ export class RuleChain {
344
743
  return this;
345
744
  }
346
745
 
347
- /** Must be positive (> 0) and finite. */
746
+ /** Must match a regular expression (TS-only never dispatched to Rust). */
747
+ regex(pattern: RegExp): this {
748
+ this.#rules.push({
749
+ name: "regex",
750
+ validate: (v) => typeof v === "string" && pattern.test(v),
751
+ message: "Invalid format",
752
+ });
753
+ return this;
754
+ }
755
+
756
+ /** Must be a valid URL (TS-only — uses the WHATWG URL parser). */
757
+ url(): this {
758
+ this.#rules.push({
759
+ name: "url",
760
+ validate: (v) => typeof v === "string" && isValidUrl(v),
761
+ message: "Must be a valid URL",
762
+ });
763
+ return this;
764
+ }
765
+
766
+ /** Must be a valid UUID. */
767
+ uuid(): this {
768
+ this.#rules.push({
769
+ name: "uuid",
770
+ validate: (v) => typeof v === "string" && UUID_RE.test(v),
771
+ message: "Must be a valid UUID",
772
+ });
773
+ return this;
774
+ }
775
+
776
+ /** Must contain only ASCII letters. */
777
+ alpha(): this {
778
+ this.#rules.push({
779
+ name: "alpha",
780
+ validate: (v) =>
781
+ typeof v === "string" && v.length > 0 && /^[a-zA-Z]+$/.test(v),
782
+ message: "Must contain only letters",
783
+ });
784
+ return this;
785
+ }
786
+
787
+ /** Must contain only ASCII letters and digits. */
788
+ alphaNumeric(): this {
789
+ this.#rules.push({
790
+ name: "alphaNumeric",
791
+ validate: (v) =>
792
+ typeof v === "string" && v.length > 0 && /^[a-zA-Z0-9]+$/.test(v),
793
+ message: "Must contain only letters and numbers",
794
+ });
795
+ return this;
796
+ }
797
+
798
+ /** String must start with `substring`. */
799
+ startsWith(substring: string): this {
800
+ this.#rules.push({
801
+ name: "startsWith",
802
+ args: { substring },
803
+ validate: (v) => typeof v === "string" && v.startsWith(substring),
804
+ message: `Must start with ${substring}`,
805
+ });
806
+ return this;
807
+ }
808
+
809
+ /** String must end with `substring`. */
810
+ endsWith(substring: string): this {
811
+ this.#rules.push({
812
+ name: "endsWith",
813
+ args: { substring },
814
+ validate: (v) => typeof v === "string" && v.endsWith(substring),
815
+ message: `Must end with ${substring}`,
816
+ });
817
+ return this;
818
+ }
819
+
820
+ /** Value must be one of `values`. */
821
+ in(values: ReadonlyArray<string | number | boolean>): this {
822
+ const allowed = [...values];
823
+ this.#rules.push({
824
+ name: "in",
825
+ args: { values: allowed },
826
+ validate: (v) => allowed.includes(asPrimitive(v)),
827
+ message: "Invalid value",
828
+ });
829
+ return this;
830
+ }
831
+
832
+ /** Value must NOT be one of `values`. */
833
+ notIn(values: ReadonlyArray<string | number | boolean>): this {
834
+ const denied = [...values];
835
+ this.#rules.push({
836
+ name: "notIn",
837
+ args: { values: denied },
838
+ validate: (v) => !denied.includes(asPrimitive(v)),
839
+ message: "Invalid value",
840
+ });
841
+ return this;
842
+ }
843
+
844
+ /** Number must be positive (> 0) and finite. */
348
845
  positive(): this {
349
846
  this.#rules.push({
350
847
  name: "positive",
@@ -354,6 +851,109 @@ export class RuleChain {
354
851
  return this;
355
852
  }
356
853
 
854
+ /** Number must be negative (< 0) and finite. */
855
+ negative(): this {
856
+ this.#rules.push({
857
+ name: "negative",
858
+ validate: (v) => typeof v === "number" && Number.isFinite(v) && v < 0,
859
+ message: "Must be negative",
860
+ });
861
+ return this;
862
+ }
863
+
864
+ /** Number must be >= 0 and finite. */
865
+ nonNegative(): this {
866
+ this.#rules.push({
867
+ name: "nonNegative",
868
+ validate: (v) => typeof v === "number" && Number.isFinite(v) && v >= 0,
869
+ message: "Must be positive or zero",
870
+ });
871
+ return this;
872
+ }
873
+
874
+ /** Number must fall within `[min, max]` (inclusive). */
875
+ range(min: number, max: number): this {
876
+ this.#rules.push({
877
+ name: "range",
878
+ args: { min, max },
879
+ validate: (v) =>
880
+ typeof v === "number" && Number.isFinite(v) && v >= min && v <= max,
881
+ message: `Must be between ${min} and ${max}`,
882
+ });
883
+ return this;
884
+ }
885
+
886
+ /** Number must have at most `digits` decimal places (TS-only). */
887
+ decimal(digits: number): this {
888
+ this.#rules.push({
889
+ name: "decimal",
890
+ args: { digits },
891
+ validate: (v) => {
892
+ if (typeof v !== "number" || !Number.isFinite(v)) return false;
893
+ const parts = String(v).split(".");
894
+ return (parts[1]?.length ?? 0) <= digits;
895
+ },
896
+ message: `Must have at most ${digits} decimal places`,
897
+ });
898
+ return this;
899
+ }
900
+
901
+ /** Must equal a sibling field (VineJS `sameAs`). Cross-field → TS-only. */
902
+ sameAs(otherField: string): this {
903
+ this.#useRules.push({
904
+ __rune: "rule",
905
+ run: (value, field) => {
906
+ const other = readSibling(field, otherField);
907
+ if (value !== other) {
908
+ field.report(`Must match ${otherField}`, "sameAs");
909
+ }
910
+ },
911
+ });
912
+ return this;
913
+ }
914
+
915
+ /** Must equal its `<field>_confirmation` sibling (VineJS `confirmed`). */
916
+ confirmed(options?: { confirmationField?: string }): this {
917
+ this.#useRules.push({
918
+ __rune: "rule",
919
+ run: (value, field) => {
920
+ const leaf = field.field.split(".").pop() ?? field.field;
921
+ const other = options?.confirmationField ?? `${leaf}_confirmation`;
922
+ if (value !== readSibling(field, other)) {
923
+ field.report("Confirmation does not match", "confirmed");
924
+ }
925
+ },
926
+ });
927
+ return this;
928
+ }
929
+
930
+ /** Required only when `otherField` is present (non-null) — else optional. */
931
+ requiredIfExists(otherField: string): this {
932
+ this.#requiredConditions.push({ kind: "exists", otherField });
933
+ return this;
934
+ }
935
+
936
+ /** Required only when `otherField` is absent/null — else optional. */
937
+ requiredIfMissing(otherField: string): this {
938
+ this.#requiredConditions.push({ kind: "missing", otherField });
939
+ return this;
940
+ }
941
+
942
+ /** Required only when `otherField <op> value` holds — else optional. */
943
+ requiredWhen(
944
+ otherField: string,
945
+ operator: RequiredCondition["operator"],
946
+ value: unknown,
947
+ ): this {
948
+ this.#requiredConditions.push({
949
+ kind: "when",
950
+ otherField,
951
+ operator,
952
+ value,
953
+ });
954
+ return this;
955
+ }
956
+
357
957
  /** Trim whitespace (transform). */
358
958
  trim(): this {
359
959
  this.#transforms.push({
@@ -363,6 +963,23 @@ export class RuleChain {
363
963
  return this;
364
964
  }
365
965
 
966
+ /**
967
+ * Post-validation transform changing the output type (VineJS `transform`).
968
+ * `value` is `unknown` — narrow it in the callback (the no-cast rule forbids
969
+ * lying about a dynamically-produced value's static type).
970
+ */
971
+ transform<U>(fn: (value: unknown, field: FieldContext) => U): RuleChain<U> {
972
+ const next = this.#retype<U>();
973
+ next.#transforms.push({ name: "transform", fn: (v, f) => fn(v, f) });
974
+ return next;
975
+ }
976
+
977
+ /** Pre-validation transform of the raw input (VineJS `parse`). */
978
+ parse(fn: (value: unknown) => unknown): this {
979
+ this.#preTransforms.push(fn);
980
+ return this;
981
+ }
982
+
366
983
  /** Custom validation rule. */
367
984
  custom(
368
985
  name: string,
@@ -377,68 +994,119 @@ export class RuleChain {
377
994
  return this;
378
995
  }
379
996
 
997
+ /**
998
+ * Attach a `.use()` rule (from {@link createRule}). Unlike `custom`, the rule
999
+ * receives a {@link FieldContext} with the root `data` and `parent`, so it can
1000
+ * validate across fields. Runs after this field's type/value rules.
1001
+ */
1002
+ use(rule: CompiledRule): this {
1003
+ if (rule?.__rune !== "rule" || typeof rule.run !== "function") {
1004
+ throw new RuneError(
1005
+ "INVALID_RULE",
1006
+ "use() expects a compiled rule — call the factory first",
1007
+ { hint: "use(myRule()) or use(myRule(options)), not use(myRule)" },
1008
+ );
1009
+ }
1010
+ this.#useRules.push(rule);
1011
+ return this;
1012
+ }
1013
+
380
1014
  /** Set custom error message for the last rule. */
381
1015
  message(msg: string): this {
382
1016
  if (this.#rules.length === 0) {
383
1017
  throw new RuneError("NO_RULE", "message() must be called after a rule");
384
1018
  }
385
- this.#rules[this.#rules.length - 1].message = msg;
1019
+ const last = this.#rules[this.#rules.length - 1];
1020
+ last.message = msg;
1021
+ last.hasCustomMessage = true;
386
1022
  return this;
387
1023
  }
388
1024
 
1025
+ /** Whether the field is required given the surrounding data (conditionals). */
1026
+ #isRequired(ctx: RunContext): boolean {
1027
+ if (this.#requiredConditions.length === 0) return true;
1028
+ return this.#requiredConditions.some((cond) =>
1029
+ evalRequiredCondition(cond, ctx),
1030
+ );
1031
+ }
1032
+
389
1033
  /** Internal: validate a field value and return errors + transformed value. */
390
1034
  _validateWithTransform(
391
1035
  field: string,
392
- value: unknown,
1036
+ rawValue: unknown,
1037
+ ctx: RunContext = EMPTY_RUN_CONTEXT,
393
1038
  ): { errors: ValidationError[]; transformed: unknown } {
394
- if (value === undefined || value === null) {
395
- if (this.#isOptional) return { errors: [], transformed: value };
396
- return { errors: [this.#requiredError(field)], transformed: value };
1039
+ // 0. Pre-validation parse() transforms run on the raw value first.
1040
+ let value = rawValue;
1041
+ for (const pre of this.#preTransforms) {
1042
+ value = pre(value);
1043
+ }
1044
+
1045
+ if (value === undefined) {
1046
+ if (this.#isOptional || !this.#isRequired(ctx)) {
1047
+ return { errors: [], transformed: value };
1048
+ }
1049
+ return { errors: [this.#requiredError(field, ctx)], transformed: value };
1050
+ }
1051
+ if (value === null) {
1052
+ // rune treats a `null` value as "absent" for optional/nullable/
1053
+ // non-required fields (a deliberate, cross-engine-conformance-tested
1054
+ // choice — see the Rust↔TS parity suite). `.nullable()` additionally
1055
+ // keeps it in the output. This unifies null/undefined for optional
1056
+ // fields, a documented deviation from VineJS's stricter split.
1057
+ if (this.#isNullable || this.#isOptional || !this.#isRequired(ctx)) {
1058
+ return { errors: [], transformed: value };
1059
+ }
1060
+ return { errors: [this.#requiredError(field, ctx)], transformed: value };
397
1061
  }
398
1062
 
399
1063
  // 1. Type rules first on the raw value — bail on type mismatch.
400
- const typeError = this.#runTypeRules(field, value);
1064
+ const typeError = this.#runTypeRules(field, value, ctx);
401
1065
  if (typeError) return { errors: [typeError], transformed: value };
402
1066
 
403
1067
  // 2. Apply transforms (trim, etc.), then run value rules on the result.
404
- let transformed = this.#applyTransformsTo(value);
405
- const errors = this.#runValueRules(field, transformed);
1068
+ let transformed = this.#applyTransformsTo(value, field, ctx);
1069
+ const errors = this.#runValueRules(field, transformed, ctx);
1070
+
1071
+ // 3. Vine-style .use() rules — run with a FieldContext exposing the root
1072
+ // `data` and `parent`, so a rule can validate across fields.
1073
+ if (this.#useRules.length > 0 && !(this.#bail && errors.length > 0)) {
1074
+ this.#runUseRules(field, transformed, ctx, errors);
1075
+ }
406
1076
 
407
1077
  // 4. Nested object validation (only if type check passed — not arrays)
408
- if (
409
- this.#nestedSchema &&
410
- typeof transformed === "object" &&
411
- transformed !== null &&
412
- !Array.isArray(transformed)
413
- ) {
414
- transformed = { ...(transformed as Record<string, unknown>) };
1078
+ if (this.#nestedSchema && isPlainObject(transformed)) {
1079
+ const obj: Record<string, unknown> = { ...transformed };
1080
+ transformed = obj;
415
1081
  for (const [nestedField, chain] of Object.entries(this.#nestedSchema)) {
416
- const nestedValue = (transformed as Record<string, unknown>)[
417
- nestedField
418
- ];
419
1082
  const nestedResult = chain._validateWithTransform(
420
1083
  `${field}.${nestedField}`,
421
- nestedValue,
1084
+ obj[nestedField],
1085
+ { ...ctx, parent: obj },
422
1086
  );
423
1087
  errors.push(...nestedResult.errors);
424
1088
  if (nestedResult.transformed !== undefined) {
425
- (transformed as Record<string, unknown>)[nestedField] =
426
- nestedResult.transformed;
1089
+ obj[nestedField] = nestedResult.transformed;
427
1090
  }
428
1091
  }
429
1092
  }
430
1093
 
431
1094
  // 5. Array item validation
432
1095
  if (this.#arrayItemChain && Array.isArray(transformed)) {
433
- transformed = [...transformed];
434
- for (let i = 0; i < (transformed as unknown[]).length; i++) {
1096
+ const arr: unknown[] = [...transformed];
1097
+ transformed = arr;
1098
+ for (let i = 0; i < arr.length; i++) {
435
1099
  const itemResult = this.#arrayItemChain._validateWithTransform(
436
1100
  `${field}.${i}`,
437
- (transformed as unknown[])[i],
1101
+ arr[i],
1102
+ { ...ctx, parent: arr },
438
1103
  );
1104
+ for (const e of itemResult.errors) {
1105
+ if (e.index === undefined) e.index = i;
1106
+ }
439
1107
  errors.push(...itemResult.errors);
440
1108
  if (itemResult.transformed !== undefined) {
441
- (transformed as unknown[])[i] = itemResult.transformed;
1109
+ arr[i] = itemResult.transformed;
442
1110
  }
443
1111
  }
444
1112
  }
@@ -446,26 +1114,51 @@ export class RuleChain {
446
1114
  return { errors, transformed };
447
1115
  }
448
1116
 
449
- #requiredError(field: string): ValidationError {
1117
+ /** Run `.use()` rules on the transformed value with a fresh FieldContext. */
1118
+ #runUseRules(
1119
+ field: string,
1120
+ transformed: unknown,
1121
+ ctx: RunContext,
1122
+ errors: ValidationError[],
1123
+ ): void {
1124
+ const fieldCtx: FieldContext = {
1125
+ value: transformed,
1126
+ data: ctx.data,
1127
+ parent: ctx.parent,
1128
+ field,
1129
+ meta: ctx.meta,
1130
+ isValid: errors.length === 0,
1131
+ report(message: string, rule: string): void {
1132
+ errors.push({ field, rule, message });
1133
+ },
1134
+ };
1135
+ for (const rule of this.#useRules) {
1136
+ fieldCtx.isValid = errors.length === 0;
1137
+ rule.run(transformed, fieldCtx);
1138
+ }
1139
+ }
1140
+
1141
+ #requiredError(field: string, ctx: RunContext): ValidationError {
450
1142
  return {
451
1143
  field,
452
1144
  rule: "required",
453
- message: resolveValidationMessage(
454
- "validation.required",
455
- `${field} is required`,
456
- { field },
457
- ),
1145
+ message: resolveRequiredMessage(field, ctx),
458
1146
  };
459
1147
  }
460
1148
 
461
1149
  /** Run the type rules (string/number/…) on the raw value; first failure bails. */
462
- #runTypeRules(field: string, value: unknown): ValidationError | null {
1150
+ #runTypeRules(
1151
+ field: string,
1152
+ value: unknown,
1153
+ ctx: RunContext,
1154
+ ): ValidationError | null {
463
1155
  for (const rule of this.#rules) {
464
1156
  if (TYPE_RULE_NAMES.has(rule.name) && !rule.validate(value)) {
465
1157
  return {
466
1158
  field,
467
1159
  rule: rule.name,
468
- message: resolveRuleMessage(field, rule),
1160
+ message: resolveRuleMessage(field, rule, ctx),
1161
+ ...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
469
1162
  };
470
1163
  }
471
1164
  }
@@ -473,14 +1166,21 @@ export class RuleChain {
473
1166
  }
474
1167
 
475
1168
  /** Run the non-type rules (min/max/email/…) on the transformed value. */
476
- #runValueRules(field: string, transformed: unknown): ValidationError[] {
1169
+ #runValueRules(
1170
+ field: string,
1171
+ transformed: unknown,
1172
+ ctx: RunContext,
1173
+ ): ValidationError[] {
477
1174
  const errors: ValidationError[] = [];
478
1175
  for (const rule of this.#rules) {
479
- if (!TYPE_RULE_NAMES.has(rule.name) && !rule.validate(transformed)) {
1176
+ if (TYPE_RULE_NAMES.has(rule.name)) continue;
1177
+ if (this.#bail && errors.length > 0) break;
1178
+ if (!rule.validate(transformed)) {
480
1179
  errors.push({
481
1180
  field,
482
1181
  rule: rule.name,
483
- message: resolveRuleMessage(field, rule),
1182
+ message: resolveRuleMessage(field, rule, ctx),
1183
+ ...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
484
1184
  });
485
1185
  }
486
1186
  }
@@ -494,31 +1194,148 @@ export class RuleChain {
494
1194
 
495
1195
  /** Internal: apply transforms. */
496
1196
  _transform(value: unknown): unknown {
497
- return this.#applyTransformsTo(value);
1197
+ return this.#applyTransformsTo(value, "", EMPTY_RUN_CONTEXT);
498
1198
  }
499
1199
 
500
- #applyTransformsTo(value: unknown): unknown {
1200
+ #applyTransformsTo(value: unknown, field: string, ctx: RunContext): unknown {
501
1201
  let result = value;
502
1202
  for (const transform of this.#transforms) {
503
- result = transform.fn(result);
1203
+ LAST_FIELD.value = result;
1204
+ LAST_FIELD.data = ctx.data;
1205
+ LAST_FIELD.parent = ctx.parent;
1206
+ LAST_FIELD.field = field;
1207
+ LAST_FIELD.meta = ctx.meta;
1208
+ result = transform.fn(result, LAST_FIELD);
504
1209
  }
505
1210
  return result;
506
1211
  }
507
1212
  }
508
1213
 
1214
+ /**
1215
+ * Scratch FieldContext reused for `.transform()` callbacks — transforms run
1216
+ * inline in {@link RuleChain.#applyTransformsTo}, which repopulates it before
1217
+ * each call. A shared object avoids per-transform allocation; it is never
1218
+ * retained across calls.
1219
+ */
1220
+ const LAST_FIELD: FieldContext = {
1221
+ value: undefined,
1222
+ data: {},
1223
+ parent: {},
1224
+ field: "",
1225
+ meta: {},
1226
+ isValid: true,
1227
+ report(): void {},
1228
+ };
1229
+
1230
+ /** Coerce a value to a comparable primitive for `in`/`enum` membership. */
1231
+ function asPrimitive(value: unknown): string | number | boolean {
1232
+ if (
1233
+ typeof value === "string" ||
1234
+ typeof value === "number" ||
1235
+ typeof value === "boolean"
1236
+ ) {
1237
+ return value;
1238
+ }
1239
+ // Non-primitive values can never be a member of a primitive set; return a
1240
+ // sentinel that no allowed entry equals.
1241
+ return Symbol.iterator.toString();
1242
+ }
1243
+
1244
+ /** Code-point length for a string, element count for an array, else -1. */
1245
+ function sizedLength(value: unknown): number {
1246
+ if (typeof value === "string") return [...value].length;
1247
+ if (Array.isArray(value)) return value.length;
1248
+ return -1;
1249
+ }
1250
+
1251
+ /** WHATWG URL validity — accepts only http/https to avoid `mailto:` etc. */
1252
+ function isValidUrl(value: string): boolean {
1253
+ try {
1254
+ const url = new URL(value);
1255
+ return url.protocol === "http:" || url.protocol === "https:";
1256
+ } catch {
1257
+ return false;
1258
+ }
1259
+ }
1260
+
1261
+ /** Read a sibling field's value from the immediate parent (object only). */
1262
+ function readSibling(field: FieldContext, name: string): unknown {
1263
+ const parent = field.parent;
1264
+ if (Array.isArray(parent)) return undefined;
1265
+ return parent[name];
1266
+ }
1267
+
1268
+ /** Evaluate whether a `requiredWhen`-family condition makes the field required. */
1269
+ function evalRequiredCondition(
1270
+ cond: RequiredCondition,
1271
+ ctx: RunContext,
1272
+ ): boolean {
1273
+ const other = isPlainObject(ctx.parent)
1274
+ ? ctx.parent[cond.otherField]
1275
+ : ctx.data[cond.otherField];
1276
+ const present = other !== undefined && other !== null;
1277
+
1278
+ if (cond.kind === "exists") return present;
1279
+ if (cond.kind === "missing") return !present;
1280
+
1281
+ switch (cond.operator) {
1282
+ case "=":
1283
+ return other === cond.value;
1284
+ case "!=":
1285
+ return other !== cond.value;
1286
+ case ">":
1287
+ return typeof other === "number" && typeof cond.value === "number"
1288
+ ? other > cond.value
1289
+ : false;
1290
+ case "<":
1291
+ return typeof other === "number" && typeof cond.value === "number"
1292
+ ? other < cond.value
1293
+ : false;
1294
+ case ">=":
1295
+ return typeof other === "number" && typeof cond.value === "number"
1296
+ ? other >= cond.value
1297
+ : false;
1298
+ case "<=":
1299
+ return typeof other === "number" && typeof cond.value === "number"
1300
+ ? other <= cond.value
1301
+ : false;
1302
+ case "in":
1303
+ return Array.isArray(cond.value) && cond.value.includes(other);
1304
+ case "notIn":
1305
+ return Array.isArray(cond.value) && !cond.value.includes(other);
1306
+ default:
1307
+ return false;
1308
+ }
1309
+ }
1310
+
1311
+ /** No-op alias of {@link schema} — VineJS `vine.compile()` API parity. */
1312
+ export function compile<T extends ValidationSchema>(s: T): T {
1313
+ return s;
1314
+ }
1315
+
509
1316
  /** Entry point for building rules. */
510
1317
  export const rules = {
511
- string: () => new RuleChain().string(),
512
- number: () => new RuleChain().number(),
513
- boolean: () => new RuleChain().boolean(),
514
- any: () => new RuleChain(),
1318
+ string: (): RuleChain<string> => new RuleChain().string(),
1319
+ number: (): RuleChain<number> => new RuleChain().number(),
1320
+ boolean: (): RuleChain<boolean> => new RuleChain().boolean(),
1321
+ any: (): RuleChain<unknown> => new RuleChain(),
1322
+ object: <Sh extends Record<string, RuleChain>>(
1323
+ shape: Sh,
1324
+ ): RuleChain<Infer<Sh>> => new RuleChain().object(shape),
1325
+ array: <Item extends RuleChain>(item?: Item): RuleChain<OutputOf<Item>[]> =>
1326
+ new RuleChain().array(item),
1327
+ enum: <const V extends readonly (string | number | boolean)[]>(
1328
+ values: V,
1329
+ ): RuleChain<V[number]> => new RuleChain().enum(values),
1330
+ literal: <V extends string | number | boolean>(value: V): RuleChain<V> =>
1331
+ new RuleChain().literal(value),
515
1332
  };
516
1333
 
517
1334
  /** Serialize schema + data and validate via Rust NAPI. */
518
- function validateWithRust<T>(
1335
+ function validateWithRust(
519
1336
  fields: Record<string, RuleChain>,
520
1337
  data: Record<string, unknown>,
521
- ): ValidationResult<T> {
1338
+ ): ValidationResult<Record<string, unknown>> {
522
1339
  const schemaDesc: Record<
523
1340
  string,
524
1341
  {
@@ -529,20 +1346,14 @@ function validateWithRust<T>(
529
1346
  > = {};
530
1347
 
531
1348
  for (const [field, chain] of Object.entries(fields)) {
532
- const rules = chain.rules.map((r) => ({
1349
+ const ruleDescs = chain.rules.map((r) => ({
533
1350
  name: r.name,
534
- // Serialize THIS rule's own param. Previously a find-first lookup by
535
- // rule name returned the first matching rule's param, so min(3).min(5)
536
- // sent both Rust entries as min=3, dropping the 5 bound (audit 2026-06-13).
537
- params:
538
- r.name === "min"
539
- ? { min: r.param }
540
- : r.name === "max"
541
- ? { max: r.param }
542
- : null,
1351
+ // Serialize THIS rule's own args (per-rule, so min(3).min(5) keeps both
1352
+ // bounds a find-first lookup previously collapsed them).
1353
+ params: r.args ?? null,
543
1354
  }));
544
1355
  schemaDesc[field] = {
545
- rules,
1356
+ rules: ruleDescs,
546
1357
  optional: chain.isOptionalField,
547
1358
  transforms: chain.transforms.map((t) => t.name),
548
1359
  };
@@ -551,7 +1362,7 @@ function validateWithRust<T>(
551
1362
  const request = JSON.stringify({ schema: schemaDesc, data });
552
1363
  const native = validateNative(request);
553
1364
  if (native.valid && native.data !== undefined) {
554
- return { valid: true, errors: native.errors, data: native.data as T };
1365
+ return { valid: true, errors: native.errors, data: native.data };
555
1366
  }
556
1367
  return { valid: false, errors: native.errors };
557
1368
  }