@c9up/rune 0.1.8 → 0.1.9

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
@@ -54,9 +54,9 @@ import {
54
54
  readHead,
55
55
  } from "./magic.js";
56
56
  import {
57
+ assertNativeAvailable,
57
58
  isNativeAvailable,
58
59
  validateNative,
59
- warnNativeUnavailableOnce,
60
60
  } from "./native.js";
61
61
 
62
62
  export type ValidationMessageParams = Record<string, string | number | boolean>;
@@ -816,6 +816,21 @@ export interface ConditionalGroup {
816
816
  }>;
817
817
  }
818
818
 
819
+ /**
820
+ * Called when no union branch matched (VineJS `UnionNoMatchCallback`). Report
821
+ * through the field context; reporting nothing suppresses the generic error.
822
+ */
823
+ export type UnionNoMatchCallback = (
824
+ value: unknown,
825
+ field: FieldContext,
826
+ ) => void;
827
+
828
+ /**
829
+ * Called with a record's keys (VineJS `RecordKeysCallback`). Report through the
830
+ * field context; reporting nothing accepts the key set.
831
+ */
832
+ export type RecordKeysCallback = (keys: string[], field: FieldContext) => void;
833
+
819
834
  /** Per-field introspection returned inside `toJSON().schema`. */
820
835
  export type SchemaIntrospection = Record<
821
836
  string,
@@ -1405,10 +1420,10 @@ export function schema(
1405
1420
  reporterError = nativeReporter?.createError;
1406
1421
  return native;
1407
1422
  }
1408
- // This schema would have used the native engine, but it isn't loaded —
1409
- // surface the platform-dependent TS fallback once instead of diverging
1410
- // silently.
1411
- warnNativeUnavailableOnce();
1423
+ // This schema carries nothing the engine cannot run, so the engine is
1424
+ // what must run it. Falling back to the TypeScript validator here made
1425
+ // the verdict depend on whether a binary loaded.
1426
+ assertNativeAvailable();
1412
1427
  }
1413
1428
 
1414
1429
  const errors: ValidationError[] = [];
@@ -1739,7 +1754,12 @@ export interface RuleDef {
1739
1754
  param?: number;
1740
1755
  /** Interpolation args exposed to i18n/messages providers (e.g. `{ min: 3 }`). */
1741
1756
  args?: Record<string, unknown>;
1742
- validate: (value: unknown) => boolean;
1757
+ /**
1758
+ * `field` is the context VineJS hands a rule — a callback-valued rule
1759
+ * (`in`, `notIn`, `enum`) reads `meta`/`parent` off it to compute its list
1760
+ * per request. Rules that do not need it simply declare one parameter.
1761
+ */
1762
+ validate: (value: unknown, field: FieldContext) => boolean;
1743
1763
  message: string;
1744
1764
  /** Set when `.message()` overrode this rule's default text. */
1745
1765
  hasCustomMessage?: boolean;
@@ -1776,12 +1796,12 @@ const UUID_RE =
1776
1796
  */
1777
1797
  export type AllowedValues =
1778
1798
  | ReadonlyArray<string | number | boolean>
1779
- | (() => ReadonlyArray<string | number | boolean>);
1799
+ | ((field: FieldContext) => ReadonlyArray<string | number | boolean>);
1780
1800
 
1781
1801
  /** Normalise a static list or a callback into a getter. */
1782
1802
  function allowedValuesResolver(
1783
1803
  values: AllowedValues,
1784
- ): () => ReadonlyArray<string | number | boolean> {
1804
+ ): (field: FieldContext) => ReadonlyArray<string | number | boolean> {
1785
1805
  if (typeof values === "function") return values;
1786
1806
  const snapshot = [...values];
1787
1807
  return () => snapshot;
@@ -1829,8 +1849,14 @@ export class RuleChain<Output = unknown> {
1829
1849
  #camelCaseKeys = false;
1830
1850
  #groups: ConditionalGroup[] = [];
1831
1851
  #recordValueChain: RuleChain | null = null;
1852
+ #enumChoices:
1853
+ | ReadonlyArray<string | number | boolean>
1854
+ | ((field: FieldContext) => ReadonlyArray<string | number | boolean>)
1855
+ | null = null;
1856
+ #recordKeysCheck: RecordKeysCallback | null = null;
1832
1857
  #tupleChains: RuleChain[] | null = null;
1833
1858
  #unionChains: ConditionalBranch[] | null = null;
1859
+ #unionNoMatch: UnionNoMatchCallback | null = null;
1834
1860
  #useRules: CompiledRule[] = [];
1835
1861
  #asyncRules: AsyncCompiledRule[] = [];
1836
1862
  /** Last rule added, whichever register it landed in — the `message()` target. */
@@ -1950,8 +1976,11 @@ export class RuleChain<Output = unknown> {
1950
1976
  next.#camelCaseKeys = this.#camelCaseKeys;
1951
1977
  next.#groups = [...this.#groups];
1952
1978
  next.#recordValueChain = this.#recordValueChain;
1979
+ next.#enumChoices = this.#enumChoices;
1980
+ next.#recordKeysCheck = this.#recordKeysCheck;
1953
1981
  next.#tupleChains = this.#tupleChains;
1954
1982
  next.#unionChains = this.#unionChains;
1983
+ next.#unionNoMatch = this.#unionNoMatch;
1955
1984
  next.#ruleMessages = new Map(this.#ruleMessages);
1956
1985
  next.#lastRule = this.#lastRule;
1957
1986
  next.#preTransforms = [...this.#preTransforms];
@@ -2406,6 +2435,18 @@ export class RuleChain<Output = unknown> {
2406
2435
  return this.#retype<Record<string, OutputOf<Item>>>();
2407
2436
  }
2408
2437
 
2438
+ /**
2439
+ * Check the record's KEYS, not its values (VineJS `record().validateKeys()`).
2440
+ *
2441
+ * The callback receives every key at once and reports through the field
2442
+ * context — the set is what matters when keys must be exclusive, exhaustive,
2443
+ * or drawn from a list only known at runtime.
2444
+ */
2445
+ validateKeys(callback: RecordKeysCallback): this {
2446
+ this.#recordKeysCheck = callback;
2447
+ return this;
2448
+ }
2449
+
2409
2450
  /**
2410
2451
  * Fixed-length array with a schema per position (VineJS `tuple`). Extra
2411
2452
  * items are rejected — a tuple that silently ignores a trailing element is
@@ -2436,6 +2477,20 @@ export class RuleChain<Output = unknown> {
2436
2477
  * - bare chains: tried in order, first match wins, and a total miss reports a
2437
2478
  * single `union` error rather than every losing branch's noise.
2438
2479
  */
2480
+ /**
2481
+ * What to do when NO union branch matched (VineJS `union().otherwise()`).
2482
+ *
2483
+ * The callback receives the value and the field, and reports the error it
2484
+ * wants — the point being that "matches nothing" is a useless message when
2485
+ * the caller knows which shapes were on offer. Reporting nothing from the
2486
+ * callback suppresses the generic error entirely, which is how a union
2487
+ * folded into a larger check stays quiet.
2488
+ */
2489
+ otherwise(callback: UnionNoMatchCallback): this {
2490
+ this.#unionNoMatch = callback;
2491
+ return this;
2492
+ }
2493
+
2439
2494
  union(chains: readonly UnionBranch[]): this {
2440
2495
  this.#unionChains = chains.map(toUnionBranch);
2441
2496
  // Marker rule: its name is not in NATIVE_RULES, which is what keeps a
@@ -2669,20 +2724,45 @@ export class RuleChain<Output = unknown> {
2669
2724
  return this;
2670
2725
  }
2671
2726
 
2672
- /** Must equal one of `values` (enum). Narrows the output to the union. */
2727
+ /**
2728
+ * Must equal one of `values` (enum). Narrows the output to the union.
2729
+ *
2730
+ * `values` may be a callback receiving the field, which is how a list that
2731
+ * depends on the request — the roles this tenant allows, the statuses this
2732
+ * user may set — is computed per validation instead of frozen at import.
2733
+ */
2673
2734
  enum<const V extends readonly (string | number | boolean)[]>(
2674
- values: V,
2735
+ values: V | ((field: FieldContext) => V),
2675
2736
  ): RuleChain<V[number]> {
2676
- const allowed = [...values];
2737
+ const lazy = typeof values === "function";
2738
+ const resolve = allowedValuesResolver(values);
2739
+ this.#enumChoices = lazy ? values : [...values];
2677
2740
  this.#pushRule({
2678
2741
  name: "enum",
2679
- args: { values: allowed },
2680
- validate: (v) => allowed.includes(asPrimitive(v)),
2742
+ args: lazy ? {} : { values: [...values] },
2743
+ // A computed list is per-call; the native engine only ever sees a
2744
+ // static array, so it must not run this rule.
2745
+ tsOnly: lazy,
2746
+ validate: (v, field) => resolve(field).includes(asPrimitive(v)),
2681
2747
  message: "Invalid value",
2682
2748
  });
2683
2749
  return this.#retype<V[number]>();
2684
2750
  }
2685
2751
 
2752
+ /**
2753
+ * The choices this enum was declared with (VineJS `getChoices()`) — the list
2754
+ * itself, or the callback when it is computed per request.
2755
+ *
2756
+ * Reading them back is what lets a form render the same options the
2757
+ * validator will accept, from one declaration instead of two.
2758
+ */
2759
+ getChoices():
2760
+ | ReadonlyArray<string | number | boolean>
2761
+ | ((field: FieldContext) => ReadonlyArray<string | number | boolean>)
2762
+ | undefined {
2763
+ return this.#enumChoices ?? undefined;
2764
+ }
2765
+
2686
2766
  /** Must equal a literal value. */
2687
2767
  literal<V extends string | number | boolean>(value: V): RuleChain<V> {
2688
2768
  this.#pushRule({
@@ -2922,8 +3002,13 @@ export class RuleChain<Output = unknown> {
2922
3002
  }
2923
3003
 
2924
3004
  /**
2925
- * Must be a mobile number in E.164 form. Named deviation from VineJS: rune
2926
- * carries no per-locale numbering plans, so there is no `locale` option.
3005
+ * Must be a mobile number (VineJS/Adonis `mobile()`).
3006
+ *
3007
+ * With no `locale`, the number must be in E.164 form. With one or more,
3008
+ * it must match one of their numbering plans, as in VineJS. rune carries
3009
+ * its own plans rather than validator.js', so it knows fewer locales — an
3010
+ * unknown one raises `UNSUPPORTED_LOCALE` at schema build, naming the ones
3011
+ * it does know, rather than silently accepting anything at request time.
2927
3012
  */
2928
3013
  mobile(options?: { locale?: string | string[]; strictMode?: boolean }): this {
2929
3014
  const locales = options?.locale ? [options.locale].flat() : null;
@@ -3308,7 +3393,7 @@ export class RuleChain<Output = unknown> {
3308
3393
  // A callback list is computed per call — the native engine only ever
3309
3394
  // sees a static array, so it must not run this rule.
3310
3395
  tsOnly: typeof values === "function",
3311
- validate: (v) => resolve().includes(asPrimitive(v)),
3396
+ validate: (v, field) => resolve(field).includes(asPrimitive(v)),
3312
3397
  message: "Invalid value",
3313
3398
  });
3314
3399
  return this;
@@ -3321,7 +3406,7 @@ export class RuleChain<Output = unknown> {
3321
3406
  name: "notIn",
3322
3407
  args: typeof values === "function" ? {} : { values: [...values] },
3323
3408
  tsOnly: typeof values === "function",
3324
- validate: (v) => !resolve().includes(asPrimitive(v)),
3409
+ validate: (v, field) => !resolve(field).includes(asPrimitive(v)),
3325
3410
  message: "Invalid value",
3326
3411
  });
3327
3412
  return this;
@@ -3904,6 +3989,14 @@ export class RuleChain<Output = unknown> {
3904
3989
  }
3905
3990
 
3906
3991
  // 4b. Record values — same shape as the nested-object walk, arbitrary keys.
3992
+ if (this.#recordKeysCheck && isPlainObject(transformed)) {
3993
+ // Keys first: a key-level rule that rejects the shape makes the
3994
+ // per-value errors that would follow noise.
3995
+ this.#recordKeysCheck(
3996
+ Object.keys(transformed),
3997
+ this.#makeFieldContext(field, transformed, ctx, errors, () => {}),
3998
+ );
3999
+ }
3907
4000
  if (this.#recordValueChain && isPlainObject(transformed)) {
3908
4001
  const obj: Record<string, unknown> = { ...transformed };
3909
4002
  transformed = obj;
@@ -3985,19 +4078,30 @@ export class RuleChain<Output = unknown> {
3985
4078
  }
3986
4079
  }
3987
4080
  if (!matched) {
3988
- errors.push({
3989
- field,
3990
- rule: "union",
3991
- message: resolveRuleMessage(
4081
+ if (this.#unionNoMatch) {
4082
+ // The callback owns the reporting: whatever it pushes is the
4083
+ // error, and pushing nothing means it handled the case itself.
4084
+ const reported: ValidationError[] = [];
4085
+ this.#unionNoMatch(
4086
+ transformed,
4087
+ this.#makeFieldContext(field, transformed, ctx, reported, () => {}),
4088
+ );
4089
+ errors.push(...reported);
4090
+ } else {
4091
+ errors.push({
3992
4092
  field,
3993
- {
3994
- name: "union",
3995
- validate: () => false,
3996
- message: "Does not match any allowed shape",
3997
- },
3998
- ctx,
3999
- ),
4000
- });
4093
+ rule: "union",
4094
+ message: resolveRuleMessage(
4095
+ field,
4096
+ {
4097
+ name: "union",
4098
+ validate: () => false,
4099
+ message: "Does not match any allowed shape",
4100
+ },
4101
+ ctx,
4102
+ ),
4103
+ });
4104
+ }
4001
4105
  }
4002
4106
  }
4003
4107
 
@@ -4126,8 +4230,14 @@ export class RuleChain<Output = unknown> {
4126
4230
  value: unknown,
4127
4231
  ctx: RunContext,
4128
4232
  ): ValidationError | null {
4233
+ let context: FieldContext | undefined;
4234
+ const fieldContext = (): FieldContext =>
4235
+ (context ??= this.#makeFieldContext(field, value, ctx, [], () => {}));
4129
4236
  for (const rule of this.#rules) {
4130
- if (TYPE_RULE_NAMES.has(rule.name) && !rule.validate(value)) {
4237
+ if (
4238
+ TYPE_RULE_NAMES.has(rule.name) &&
4239
+ !rule.validate(value, fieldContext())
4240
+ ) {
4131
4241
  return {
4132
4242
  field,
4133
4243
  rule: rule.name,
@@ -4146,10 +4256,21 @@ export class RuleChain<Output = unknown> {
4146
4256
  ctx: RunContext,
4147
4257
  ): ValidationError[] {
4148
4258
  const errors: ValidationError[] = [];
4259
+ // Built once and only if a rule actually reads it: most rules take the
4260
+ // value alone, and a context per rule per field is pure waste.
4261
+ let context: FieldContext | undefined;
4262
+ const fieldContext = (): FieldContext =>
4263
+ (context ??= this.#makeFieldContext(
4264
+ field,
4265
+ transformed,
4266
+ ctx,
4267
+ [],
4268
+ () => {},
4269
+ ));
4149
4270
  for (const rule of this.#rules) {
4150
4271
  if (TYPE_RULE_NAMES.has(rule.name)) continue;
4151
4272
  if (this.#bail && errors.length > 0) break;
4152
- if (!rule.validate(transformed)) {
4273
+ if (!rule.validate(transformed, fieldContext())) {
4153
4274
  errors.push({
4154
4275
  field,
4155
4276
  rule: rule.name,
@@ -4400,7 +4521,10 @@ export const rules = {
4400
4521
  const typeRule = chain.rules.find((rule) =>
4401
4522
  TYPE_RULE_NAMES.has(rule.name),
4402
4523
  );
4403
- return unionIf((value) => typeRule?.validate(value) === true, chain);
4524
+ return unionIf(
4525
+ (value, field) => typeRule?.validate(value, field) === true,
4526
+ chain,
4527
+ );
4404
4528
  }),
4405
4529
  );
4406
4530
  },
@@ -4410,7 +4534,7 @@ export const rules = {
4410
4534
  array: <Item extends RuleChain>(item?: Item): RuleChain<OutputOf<Item>[]> =>
4411
4535
  new RuleChain().array(item),
4412
4536
  enum: <const V extends readonly (string | number | boolean)[]>(
4413
- values: V,
4537
+ values: V | ((field: FieldContext) => V),
4414
4538
  ): RuleChain<V[number]> => new RuleChain().enum(values),
4415
4539
  literal: <V extends string | number | boolean>(value: V): RuleChain<V> =>
4416
4540
  new RuleChain().literal(value),
package/src/formats.ts CHANGED
@@ -157,9 +157,8 @@ export function isPostalCode(v: string, countryCode: string): boolean | null {
157
157
  }
158
158
 
159
159
  /**
160
- * Mobile numbers in E.164 form. Named deviation from VineJS: rune does NOT
161
- * carry per-locale numbering plans, so `locale` is not accepted — a caller that
162
- * needs one writes a `.regex()` or a custom rule.
160
+ * Mobile numbers in E.164 form the locale-less check. Per-locale plans live
161
+ * in `MOBILE_LOCALES`; see {@link isMobileForLocale}.
163
162
  */
164
163
  export const isMobile = (v: string): boolean =>
165
164
  E164_RE.test(v.replace(/[ .-]/g, ""));
@@ -490,12 +489,62 @@ export interface EmailOptions {
490
489
  domain_specific_validation?: boolean;
491
490
  }
492
491
 
492
+ /** Hard bound on anything handed to the email parser (RFC 5322 line limit). */
493
+ const MAX_EMAIL_INPUT = 998;
494
+
493
495
  /** Unquoted local part: dot-separated atoms of RFC 5322 atext. */
494
496
  const ATEXT = "[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+";
495
497
  const DOT_ATOM_RE = new RegExp(`^${ATEXT}(?:\\.${ATEXT})*$`);
496
498
  /** Quoted local part: `"anything but bare quote/backslash, or escaped"`. */
497
499
  const QUOTED_LOCAL_RE = /^"(?:[^"\\]|\\.)*"$/;
498
- const DISPLAY_NAME_RE = /^\s*(?:"(?:[^"\\]|\\.)*"|[^<>@]*?)\s*<(.+)>\s*$/;
500
+ /**
501
+ * Split `Display Name <address@host>` into its address.
502
+ *
503
+ * Parsed rather than matched: the obvious pattern —
504
+ * `^\s*(?:"..."|[^<>@]*?)\s*<(.+)>\s*$` — lets `\s*` and `[^<>@]*?` both
505
+ * claim a space, so an input of N spaces has N ways to be split and the engine
506
+ * tries them all. Measured at O(n³): 8 KB of spaces blocked the event loop for
507
+ * 67 seconds, which turns any route validating an email into a denial of
508
+ * service. This walk is linear and answers the same question.
509
+ *
510
+ * Returns the address, or null when the input is not in display-name form.
511
+ */
512
+ function displayNameAddress(input: string): string | null {
513
+ const trimmed = input.trim();
514
+ if (!trimmed.endsWith(">")) return null;
515
+
516
+ let open: number;
517
+ if (trimmed.startsWith('"')) {
518
+ // A quoted display name may contain anything, `<` included, so the
519
+ // address opens at the first `<` AFTER the closing quote.
520
+ const closingQuote = closingQuoteIndex(trimmed);
521
+ if (closingQuote === -1) return null;
522
+ open = trimmed.indexOf("<", closingQuote + 1);
523
+ if (open === -1) return null;
524
+ if (trimmed.slice(closingQuote + 1, open).trim() !== "") return null;
525
+ } else {
526
+ open = trimmed.indexOf("<");
527
+ if (open === -1) return null;
528
+ // An unquoted display name carries none of `<`, `>` or `@` — the same
529
+ // restriction the pattern expressed.
530
+ if (/[<>@]/.test(trimmed.slice(0, open))) return null;
531
+ }
532
+
533
+ const address = trimmed.slice(open + 1, -1);
534
+ return address.length > 0 ? address : null;
535
+ }
536
+
537
+ /** Index of the quote closing the one at position 0, or -1. */
538
+ function closingQuoteIndex(input: string): number {
539
+ for (let i = 1; i < input.length; i++) {
540
+ if (input[i] === "\\") {
541
+ i++;
542
+ continue;
543
+ }
544
+ if (input[i] === '"') return i;
545
+ }
546
+ return -1;
547
+ }
499
548
 
500
549
  /** A single DNS label: alphanumerics and inner hyphens, 1..63 chars. */
501
550
  function isDnsLabel(label: string): boolean {
@@ -525,9 +574,15 @@ function isIpDomainLiteral(domain: string): boolean {
525
574
  export function isEmail(value: string, options: EmailOptions = {}): boolean {
526
575
  let candidate = value;
527
576
 
577
+ // Bound the input BEFORE any parsing. A caller may opt out of the 254-char
578
+ // address cap, but never out of a bound: an unbounded string reaching the
579
+ // parser is how a validator becomes an outage. RFC 5322 caps a whole line
580
+ // at 998 octets, so a display-name form has no business being longer.
581
+ if (value.length > MAX_EMAIL_INPUT) return false;
582
+
528
583
  if (options.allow_display_name) {
529
- const match = DISPLAY_NAME_RE.exec(candidate);
530
- if (match) candidate = match[1];
584
+ const address = displayNameAddress(candidate);
585
+ if (address !== null) candidate = address;
531
586
  } else if (/[<>]/.test(candidate)) {
532
587
  return false;
533
588
  }
package/src/index.ts CHANGED
@@ -21,6 +21,11 @@ export type {
21
21
  } from "./formats.js";
22
22
  export type { MessagesProviderContract } from "./MessagesProvider.js";
23
23
  export { SimpleMessagesProvider } from "./MessagesProvider.js";
24
+ export {
25
+ assertNativeAvailable,
26
+ isNativeAvailable,
27
+ RuneNativeRequiredError,
28
+ } from "./native.js";
24
29
  export type {
25
30
  AsyncCompiledRule,
26
31
  AsyncRuleValidator,
@@ -31,8 +36,10 @@ export type {
31
36
  DatabaseRuleOptions,
32
37
  FieldContext,
33
38
  Infer,
39
+ RecordKeysCallback,
34
40
  RuleChain,
35
41
  RuleValidator,
42
+ UnionNoMatchCallback,
36
43
  ValidateOptions,
37
44
  ValidationError,
38
45
  ValidationMessageParams,
package/src/native.ts CHANGED
@@ -58,28 +58,43 @@ export function isNativeAvailable(): boolean {
58
58
  return native !== undefined;
59
59
  }
60
60
 
61
- let warnedFallback = false;
61
+ /** Why the engine could not be loaded, phrased for whoever has to fix it. */
62
+ function unavailableReason(): string {
63
+ const target = `${platform}-${arch}`;
64
+ if (loadError !== undefined) {
65
+ return `failed to load (${loadError instanceof Error ? loadError.message : String(loadError)})`;
66
+ }
67
+ return platformMap[target] !== undefined
68
+ ? "binary not found"
69
+ : `no prebuilt binary for ${target}`;
70
+ }
71
+
72
+ /** Raised when a schema needs the Rust engine and it is not there. */
73
+ export class RuneNativeRequiredError extends Error {
74
+ readonly code = "RUNE_NAPI_REQUIRED" as const;
75
+ constructor() {
76
+ super(
77
+ `[RUNE_NAPI_REQUIRED] The Rust validation engine is required but not loaded — ${unavailableReason()}.\n` +
78
+ "Install the prebuilt binary for this platform, or build it with `pnpm build:napi`.",
79
+ );
80
+ this.name = "RuneNativeRequiredError";
81
+ }
82
+ }
62
83
 
63
84
  /**
64
- * Surface exactly once per process — that the native engine is unavailable so
65
- * the schema is falling back to the TypeScript validator. The two paths can
66
- * differ subtly, so a silent fallback makes validation results depend on whether
67
- * the prebuilt binary loaded (platform-dependent). Callers invoke this only when
68
- * they WOULD have used the native engine (no custom rules / translator).
85
+ * Refuse to validate without the native engine.
86
+ *
87
+ * There is a TypeScript validator, and it used to take over silently with a
88
+ * one-time warning. That made a schema's verdict depend on whether a prebuilt
89
+ * binary happened to load: two deployments of the same code could disagree on
90
+ * whether a payload is valid, and the one that fell back lost the reason to
91
+ * have Rust at all. A schema the engine can run is now run BY the engine or
92
+ * not at all.
93
+ *
94
+ * The TypeScript path stays for what the engine genuinely cannot do — custom
95
+ * rules, a translator, a messages provider — where it is the only
96
+ * implementation, not a second one.
69
97
  */
70
- export function warnNativeUnavailableOnce(): void {
71
- if (warnedFallback) return;
72
- warnedFallback = true;
73
- const target = `${platform}-${arch}`;
74
- const reason =
75
- loadError !== undefined
76
- ? `failed to load (${loadError instanceof Error ? loadError.message : String(loadError)})`
77
- : platformMap[target] !== undefined
78
- ? "binary not found"
79
- : `no prebuilt binary for ${target}`;
80
- console.warn(
81
- `[rune] native validation engine unavailable — ${reason}. Falling back to the ` +
82
- "TypeScript validator, whose results can differ subtly from the Rust engine — " +
83
- "validation is platform-dependent for this process.",
84
- );
98
+ export function assertNativeAvailable(): void {
99
+ if (native === undefined) throw new RuneNativeRequiredError();
85
100
  }