@c9up/rune 0.1.6 → 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,10 @@ 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>;
24
30
  }
25
31
 
26
32
  /**
@@ -107,11 +113,24 @@ export type ValidationResult<T = Record<string, unknown>> =
107
113
  export interface ValidateOptions {
108
114
  /** Runtime metadata exposed to `.use()` rules via `field.meta` (VineJS parity). */
109
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;
110
122
  }
111
123
 
112
124
  export interface ValidationSchema<T = Record<string, unknown>> {
113
125
  fields: Record<string, RuleChain>;
126
+ /** Result-based validation (superset) — never throws. */
114
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;
115
134
  }
116
135
 
117
136
  /** Context threaded through validation so field rules can reach root/parent/meta. */
@@ -119,6 +138,7 @@ interface RunContext {
119
138
  data: Record<string, unknown>;
120
139
  parent: Record<string, unknown> | unknown[];
121
140
  meta: Record<string, unknown>;
141
+ messagesProvider?: MessagesProviderContract;
122
142
  }
123
143
 
124
144
  /** Default context for internal callers that don't supply one (no root available). */
@@ -129,7 +149,17 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
129
149
  return typeof value === "object" && value !== null && !Array.isArray(value);
130
150
  }
131
151
 
132
- /** 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
+ */
133
163
  const STANDARD_RULES: ReadonlySet<string> = new Set([
134
164
  "string",
135
165
  "number",
@@ -138,9 +168,23 @@ const STANDARD_RULES: ReadonlySet<string> = new Set([
138
168
  "max",
139
169
  "email",
140
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",
141
185
  ]);
142
186
 
143
- /** Default messages for standard rules — used to detect custom-message overrides. */
187
+ /** Default messages for standard rules — used only for translator-key fallback. */
144
188
  const STANDARD_MSGS: Readonly<Record<string, string>> = {
145
189
  string: "Must be a string",
146
190
  number: "Must be a number",
@@ -149,6 +193,20 @@ const STANDARD_MSGS: Readonly<Record<string, string>> = {
149
193
  max: "Maximum",
150
194
  email: "Must be a valid email",
151
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",
152
210
  };
153
211
 
154
212
  const TYPE_RULE_NAMES: ReadonlySet<string> = new Set([
@@ -160,15 +218,8 @@ const TYPE_RULE_NAMES: ReadonlySet<string> = new Set([
160
218
  ]);
161
219
  let validationTranslator: ValidationTranslator | undefined;
162
220
 
163
- function hasDefaultMessage(rule: RuleDef): boolean {
164
- if (rule.name === "min" || rule.name === "max") {
165
- if (typeof rule.param !== "number") return false;
166
- const expected = `${rule.name === "min" ? "Minimum" : "Maximum"} ${rule.param}`;
167
- return rule.message === expected;
168
- }
169
-
170
- const defaultMsg = STANDARD_MSGS[rule.name];
171
- return defaultMsg !== undefined && rule.message === defaultMsg;
221
+ function hasCustomMessage(rule: RuleDef): boolean {
222
+ return rule.hasCustomMessage === true;
172
223
  }
173
224
 
174
225
  function resolveValidationMessage(
@@ -183,112 +234,213 @@ function resolveValidationMessage(
183
234
  return fallback;
184
235
  }
185
236
 
186
- function resolveRuleMessage(field: string, rule: RuleDef): string {
187
- const fallback = rule.message;
188
- if (!STANDARD_RULES.has(rule.name)) {
189
- 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;
190
256
  }
191
- if (!hasDefaultMessage(rule)) {
192
- 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
+ );
193
266
  }
194
267
 
195
- const params: ValidationMessageParams = { field };
196
- if (rule.name === "min" && typeof rule.param === "number") {
197
- params.min = rule.param;
268
+ if (!STANDARD_RULES.has(rule.name)) {
269
+ return rule.message;
198
270
  }
199
- if (rule.name === "max" && typeof rule.param === "number") {
200
- 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;
201
278
  }
279
+ return resolveValidationMessage(
280
+ `validation.${rule.name}`,
281
+ rule.message,
282
+ params,
283
+ );
284
+ }
202
285
 
203
- 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
+ );
204
300
  }
205
301
 
206
302
  /** Compute once: does any field rule prevent dispatching to Rust? */
207
303
  function detectHasCustomRules(fields: Record<string, RuleChain>): boolean {
208
304
  return Object.values(fields).some((chain) => {
209
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
210
310
  return chain.rules.some((r) => {
211
311
  if (!STANDARD_RULES.has(r.name)) return true; // custom rule
212
- if (!hasDefaultMessage(r)) return true; // custom message
312
+ if (hasCustomMessage(r)) return true; // custom message
213
313
  return false;
214
314
  });
215
315
  });
216
316
  }
217
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
+
218
341
  /**
219
342
  * Create a validation schema.
220
343
  *
221
- * Pass `T` explicitly when the caller wants `result.data` typed as a concrete
222
- * shape after `result.valid === true` narrows the union — runtime validation
223
- * 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.
224
347
  *
225
- * const RegisterValidator = schema<{ email: string; password: string }>({
348
+ * const RegisterValidator = schema({
226
349
  * email: rules.string().email(),
227
- * password: rules.string().min(8),
350
+ * age: rules.number().optional(),
228
351
  * });
352
+ * // Infer<typeof RegisterValidator> not needed — result.data is typed.
229
353
  *
230
- * The default `Record<string, unknown>` matches the historical untyped surface
231
- * so existing call sites that read `result.data` field-by-field with their
232
- * own narrowing continue to compile.
354
+ * An explicit generic is still accepted for back-compat
355
+ * (`schema<MyType>({ ... })`), overriding inference.
233
356
  */
357
+ export function schema<S extends Record<string, RuleChain>>(
358
+ fields: S,
359
+ ): ValidationSchema<Infer<S>>;
234
360
  export function schema<T = Record<string, unknown>>(
235
361
  fields: Record<string, RuleChain>,
236
- ): ValidationSchema<T> {
362
+ ): ValidationSchema<T>;
363
+ export function schema(
364
+ fields: Record<string, RuleChain>,
365
+ ): ValidationSchema<Record<string, unknown>> {
237
366
  // Computed once at construction time, not per validate() call.
238
367
  const hasCustomRules = detectHasCustomRules(fields);
239
368
 
240
- return {
241
- fields,
242
- validate(data: unknown, options?: ValidateOptions): ValidationResult<T> {
243
- if (!isPlainObject(data)) {
244
- return {
245
- valid: false,
246
- errors: [
247
- {
248
- field: "_root",
249
- rule: "type",
250
- message: "Input must be an object",
251
- },
252
- ],
253
- };
254
- }
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
+ }
255
381
 
256
- if (!hasCustomRules && !validationTranslator) {
257
- if (isNativeAvailable()) {
258
- return validateWithRust<T>(fields, data);
259
- }
260
- // This schema would have used the native engine, but it isn't
261
- // loaded — surface the platform-dependent TS fallback once instead
262
- // of diverging silently. (Schemas with custom rules / a translator
263
- // always run on TS by design and don't warn.)
264
- warnNativeUnavailableOnce();
382
+ const provider = options?.messagesProvider;
383
+ if (!hasCustomRules && !validationTranslator && !provider) {
384
+ if (isNativeAvailable()) {
385
+ return validateWithRust(fields, data);
265
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
+ }
266
392
 
267
- const errors: ValidationError[] = [];
268
- const validated: Record<string, unknown> = {};
269
- // Root context: `data` is the root, `parent` of a top-level field is the
270
- // root too; nested/array recursion narrows `parent` as it descends.
271
- const rootCtx: RunContext = {
272
- data,
273
- parent: data,
274
- meta: options?.meta ?? {},
275
- };
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
+ };
276
401
 
277
- for (const [field, chain] of Object.entries(fields)) {
278
- const value = data[field];
279
- const result = chain._validateWithTransform(field, value, rootCtx);
280
- errors.push(...result.errors);
281
- if (result.errors.length === 0 && value !== undefined) {
282
- validated[field] = result.transformed;
283
- }
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;
284
411
  }
412
+ }
285
413
 
286
- if (errors.length === 0) {
287
- return { valid: true, errors, data: validated as T };
288
- }
289
- return { valid: false, errors };
290
- },
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,
291
440
  };
441
+ if (error.index !== undefined) node.index = error.index;
442
+ if (error.meta !== undefined) node.meta = error.meta;
443
+ return node;
292
444
  }
293
445
 
294
446
  export function setValidationTranslator(
@@ -307,18 +459,47 @@ export function bindRosetta(rosetta: {
307
459
  export interface RuleDef {
308
460
  name: string;
309
461
  param?: number;
462
+ /** Interpolation args exposed to i18n/messages providers (e.g. `{ min: 3 }`). */
463
+ args?: Record<string, unknown>;
310
464
  validate: (value: unknown) => boolean;
311
465
  message: string;
466
+ /** Set when `.message()` overrode this rule's default text. */
467
+ hasCustomMessage?: boolean;
312
468
  }
313
469
 
314
- /** Rule chain fluent validation builder. */
315
- 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
+
316
490
  #rules: RuleDef[] = [];
317
491
  #isOptional = false;
318
- #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> = [];
319
499
  #nestedSchema: Record<string, RuleChain> | null = null;
320
500
  #arrayItemChain: RuleChain | null = null;
321
501
  #useRules: CompiledRule[] = [];
502
+ #requiredConditions: RequiredCondition[] = [];
322
503
 
323
504
  /** Public read access to rules (for OpenAPI generation, Rust bridge). */
324
505
  get rules(): readonly RuleDef[] {
@@ -327,9 +508,13 @@ export class RuleChain {
327
508
  get isOptionalField(): boolean {
328
509
  return this.#isOptional;
329
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
+ }
330
515
  get transforms(): ReadonlyArray<{
331
516
  name: string;
332
- fn: (value: unknown) => unknown;
517
+ fn: (value: unknown, field: FieldContext) => unknown;
333
518
  }> {
334
519
  return this.#transforms;
335
520
  }
@@ -337,71 +522,147 @@ export class RuleChain {
337
522
  get useRules(): readonly CompiledRule[] {
338
523
  return this.#useRules;
339
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
+ }
554
+
555
+ /** Mark field as optional (absent / `undefined` allowed). */
556
+ optional(): RuleChain<Output | undefined> {
557
+ this.#isOptional = true;
558
+ return this;
559
+ }
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
+ }
340
566
 
341
- /** Mark field as optional. */
342
- optional(): this {
567
+ /** Mark field as both optional and nullable. */
568
+ nullish(): RuleChain<Output | null | undefined> {
343
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;
344
577
  return this;
345
578
  }
346
579
 
347
580
  /** Must be an object matching a nested schema. */
348
- object(shape: Record<string, RuleChain>): this {
581
+ object<Sh extends Record<string, RuleChain>>(
582
+ shape: Sh,
583
+ ): RuleChain<Infer<Sh>> {
349
584
  this.#rules.push({
350
585
  name: "object",
351
- validate: (v) => typeof v === "object" && v !== null && !Array.isArray(v),
586
+ validate: (v) => isPlainObject(v),
352
587
  message: "Must be an object",
353
588
  });
354
589
  this.#nestedSchema = shape;
355
- return this;
590
+ return this.#retype<Infer<Sh>>();
356
591
  }
357
592
 
358
593
  /** Must be an array. Items validated by the provided chain. */
359
- array(itemChain?: RuleChain): this {
594
+ array<Item extends RuleChain>(itemChain?: Item): RuleChain<OutputOf<Item>[]> {
360
595
  this.#rules.push({
361
596
  name: "array",
362
597
  validate: (v) => Array.isArray(v),
363
598
  message: "Must be an array",
364
599
  });
365
600
  this.#arrayItemChain = itemChain ?? null;
366
- return this;
601
+ return this.#retype<OutputOf<Item>[]>();
367
602
  }
368
603
 
369
604
  /** Must be a string. */
370
- string(): this {
605
+ string(): RuleChain<string> {
371
606
  this.#rules.push({
372
607
  name: "string",
373
608
  validate: (v) => typeof v === "string",
374
609
  message: "Must be a string",
375
610
  });
376
- return this;
611
+ return this.#retype<string>();
377
612
  }
378
613
 
379
614
  /** Must be a number. */
380
- number(): this {
615
+ number(): RuleChain<number> {
381
616
  this.#rules.push({
382
617
  name: "number",
383
618
  validate: (v) =>
384
619
  typeof v === "number" && !Number.isNaN(v) && Number.isFinite(v),
385
620
  message: "Must be a number",
386
621
  });
387
- return this;
622
+ return this.#retype<number>();
388
623
  }
389
624
 
390
625
  /** Must be a boolean. */
391
- boolean(): this {
626
+ boolean(): RuleChain<boolean> {
392
627
  this.#rules.push({
393
628
  name: "boolean",
394
629
  validate: (v) => typeof v === "boolean",
395
630
  message: "Must be a boolean",
396
631
  });
397
- return this;
632
+ return this.#retype<boolean>();
398
633
  }
399
634
 
400
- /** Minimum length (string) or minimum value (number). */
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]>();
647
+ }
648
+
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. */
401
661
  min(n: number): this {
402
662
  this.#rules.push({
403
663
  name: "min",
404
664
  param: n,
665
+ args: { min: n },
405
666
  validate: (v) =>
406
667
  typeof v === "string"
407
668
  ? [...v].length >= n
@@ -413,11 +674,12 @@ export class RuleChain {
413
674
  return this;
414
675
  }
415
676
 
416
- /** Maximum length (string) or maximum value (number). */
677
+ /** Maximum length (string) or maximum value (number). Alias of max/maxLength. */
417
678
  max(n: number): this {
418
679
  this.#rules.push({
419
680
  name: "max",
420
681
  param: n,
682
+ args: { max: n },
421
683
  validate: (v) =>
422
684
  typeof v === "string"
423
685
  ? [...v].length <= n
@@ -429,14 +691,51 @@ export class RuleChain {
429
691
  return this;
430
692
  }
431
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
+
432
733
  /** Must be a valid email. */
433
734
  email(): this {
434
735
  this.#rules.push({
435
736
  name: "email",
436
- // Mirror the Rust engine's regex exactly (crates/rune-engine/src/engine.rs)
437
- // so the SAME schema validates identically whether or not the native
438
- // binary loaded: no whitespace anywhere (the old TS rule rejected only
439
- // \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.
440
739
  validate: (v) =>
441
740
  typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
442
741
  message: "Must be a valid email",
@@ -444,7 +743,105 @@ export class RuleChain {
444
743
  return this;
445
744
  }
446
745
 
447
- /** 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. */
448
845
  positive(): this {
449
846
  this.#rules.push({
450
847
  name: "positive",
@@ -454,6 +851,109 @@ export class RuleChain {
454
851
  return this;
455
852
  }
456
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
+
457
957
  /** Trim whitespace (transform). */
458
958
  trim(): this {
459
959
  this.#transforms.push({
@@ -463,6 +963,23 @@ export class RuleChain {
463
963
  return this;
464
964
  }
465
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
+
466
983
  /** Custom validation rule. */
467
984
  custom(
468
985
  name: string,
@@ -499,32 +1016,61 @@ export class RuleChain {
499
1016
  if (this.#rules.length === 0) {
500
1017
  throw new RuneError("NO_RULE", "message() must be called after a rule");
501
1018
  }
502
- 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;
503
1022
  return this;
504
1023
  }
505
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
+
506
1033
  /** Internal: validate a field value and return errors + transformed value. */
507
1034
  _validateWithTransform(
508
1035
  field: string,
509
- value: unknown,
1036
+ rawValue: unknown,
510
1037
  ctx: RunContext = EMPTY_RUN_CONTEXT,
511
1038
  ): { errors: ValidationError[]; transformed: unknown } {
512
- if (value === undefined || value === null) {
513
- if (this.#isOptional) return { errors: [], transformed: value };
514
- 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 };
515
1061
  }
516
1062
 
517
1063
  // 1. Type rules first on the raw value — bail on type mismatch.
518
- const typeError = this.#runTypeRules(field, value);
1064
+ const typeError = this.#runTypeRules(field, value, ctx);
519
1065
  if (typeError) return { errors: [typeError], transformed: value };
520
1066
 
521
1067
  // 2. Apply transforms (trim, etc.), then run value rules on the result.
522
- let transformed = this.#applyTransformsTo(value);
523
- const errors = this.#runValueRules(field, transformed);
1068
+ let transformed = this.#applyTransformsTo(value, field, ctx);
1069
+ const errors = this.#runValueRules(field, transformed, ctx);
524
1070
 
525
1071
  // 3. Vine-style .use() rules — run with a FieldContext exposing the root
526
1072
  // `data` and `parent`, so a rule can validate across fields.
527
- if (this.#useRules.length > 0) {
1073
+ if (this.#useRules.length > 0 && !(this.#bail && errors.length > 0)) {
528
1074
  this.#runUseRules(field, transformed, ctx, errors);
529
1075
  }
530
1076
 
@@ -536,7 +1082,7 @@ export class RuleChain {
536
1082
  const nestedResult = chain._validateWithTransform(
537
1083
  `${field}.${nestedField}`,
538
1084
  obj[nestedField],
539
- { data: ctx.data, parent: obj, meta: ctx.meta },
1085
+ { ...ctx, parent: obj },
540
1086
  );
541
1087
  errors.push(...nestedResult.errors);
542
1088
  if (nestedResult.transformed !== undefined) {
@@ -553,8 +1099,11 @@ export class RuleChain {
553
1099
  const itemResult = this.#arrayItemChain._validateWithTransform(
554
1100
  `${field}.${i}`,
555
1101
  arr[i],
556
- { data: ctx.data, parent: arr, meta: ctx.meta },
1102
+ { ...ctx, parent: arr },
557
1103
  );
1104
+ for (const e of itemResult.errors) {
1105
+ if (e.index === undefined) e.index = i;
1106
+ }
558
1107
  errors.push(...itemResult.errors);
559
1108
  if (itemResult.transformed !== undefined) {
560
1109
  arr[i] = itemResult.transformed;
@@ -584,32 +1133,32 @@ export class RuleChain {
584
1133
  },
585
1134
  };
586
1135
  for (const rule of this.#useRules) {
587
- // Refresh isValid so a rule can early-return once the field has failed.
588
1136
  fieldCtx.isValid = errors.length === 0;
589
1137
  rule.run(transformed, fieldCtx);
590
1138
  }
591
1139
  }
592
1140
 
593
- #requiredError(field: string): ValidationError {
1141
+ #requiredError(field: string, ctx: RunContext): ValidationError {
594
1142
  return {
595
1143
  field,
596
1144
  rule: "required",
597
- message: resolveValidationMessage(
598
- "validation.required",
599
- `${field} is required`,
600
- { field },
601
- ),
1145
+ message: resolveRequiredMessage(field, ctx),
602
1146
  };
603
1147
  }
604
1148
 
605
1149
  /** Run the type rules (string/number/…) on the raw value; first failure bails. */
606
- #runTypeRules(field: string, value: unknown): ValidationError | null {
1150
+ #runTypeRules(
1151
+ field: string,
1152
+ value: unknown,
1153
+ ctx: RunContext,
1154
+ ): ValidationError | null {
607
1155
  for (const rule of this.#rules) {
608
1156
  if (TYPE_RULE_NAMES.has(rule.name) && !rule.validate(value)) {
609
1157
  return {
610
1158
  field,
611
1159
  rule: rule.name,
612
- message: resolveRuleMessage(field, rule),
1160
+ message: resolveRuleMessage(field, rule, ctx),
1161
+ ...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
613
1162
  };
614
1163
  }
615
1164
  }
@@ -617,14 +1166,21 @@ export class RuleChain {
617
1166
  }
618
1167
 
619
1168
  /** Run the non-type rules (min/max/email/…) on the transformed value. */
620
- #runValueRules(field: string, transformed: unknown): ValidationError[] {
1169
+ #runValueRules(
1170
+ field: string,
1171
+ transformed: unknown,
1172
+ ctx: RunContext,
1173
+ ): ValidationError[] {
621
1174
  const errors: ValidationError[] = [];
622
1175
  for (const rule of this.#rules) {
623
- 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)) {
624
1179
  errors.push({
625
1180
  field,
626
1181
  rule: rule.name,
627
- message: resolveRuleMessage(field, rule),
1182
+ message: resolveRuleMessage(field, rule, ctx),
1183
+ ...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
628
1184
  });
629
1185
  }
630
1186
  }
@@ -638,31 +1194,148 @@ export class RuleChain {
638
1194
 
639
1195
  /** Internal: apply transforms. */
640
1196
  _transform(value: unknown): unknown {
641
- return this.#applyTransformsTo(value);
1197
+ return this.#applyTransformsTo(value, "", EMPTY_RUN_CONTEXT);
642
1198
  }
643
1199
 
644
- #applyTransformsTo(value: unknown): unknown {
1200
+ #applyTransformsTo(value: unknown, field: string, ctx: RunContext): unknown {
645
1201
  let result = value;
646
1202
  for (const transform of this.#transforms) {
647
- 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);
648
1209
  }
649
1210
  return result;
650
1211
  }
651
1212
  }
652
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
+
653
1316
  /** Entry point for building rules. */
654
1317
  export const rules = {
655
- string: () => new RuleChain().string(),
656
- number: () => new RuleChain().number(),
657
- boolean: () => new RuleChain().boolean(),
658
- 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),
659
1332
  };
660
1333
 
661
1334
  /** Serialize schema + data and validate via Rust NAPI. */
662
- function validateWithRust<T>(
1335
+ function validateWithRust(
663
1336
  fields: Record<string, RuleChain>,
664
1337
  data: Record<string, unknown>,
665
- ): ValidationResult<T> {
1338
+ ): ValidationResult<Record<string, unknown>> {
666
1339
  const schemaDesc: Record<
667
1340
  string,
668
1341
  {
@@ -673,20 +1346,14 @@ function validateWithRust<T>(
673
1346
  > = {};
674
1347
 
675
1348
  for (const [field, chain] of Object.entries(fields)) {
676
- const rules = chain.rules.map((r) => ({
1349
+ const ruleDescs = chain.rules.map((r) => ({
677
1350
  name: r.name,
678
- // Serialize THIS rule's own param. Previously a find-first lookup by
679
- // rule name returned the first matching rule's param, so min(3).min(5)
680
- // sent both Rust entries as min=3, dropping the 5 bound (audit 2026-06-13).
681
- params:
682
- r.name === "min"
683
- ? { min: r.param }
684
- : r.name === "max"
685
- ? { max: r.param }
686
- : 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,
687
1354
  }));
688
1355
  schemaDesc[field] = {
689
- rules,
1356
+ rules: ruleDescs,
690
1357
  optional: chain.isOptionalField,
691
1358
  transforms: chain.transforms.map((t) => t.name),
692
1359
  };
@@ -695,7 +1362,7 @@ function validateWithRust<T>(
695
1362
  const request = JSON.stringify({ schema: schemaDesc, data });
696
1363
  const native = validateNative(request);
697
1364
  if (native.valid && native.data !== undefined) {
698
- return { valid: true, errors: native.errors, data: native.data as T };
1365
+ return { valid: true, errors: native.errors, data: native.data };
699
1366
  }
700
1367
  return { valid: false, errors: native.errors };
701
1368
  }