@jarenjs/validate 0.9.2 → 0.34.2

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/index.js CHANGED
@@ -21,6 +21,8 @@ import {
21
21
  import {
22
22
  isBoolOrObjectClass,
23
23
  hasSchemaRef,
24
+ hasUnevaluatedPropertiesCoverage,
25
+ hasUnevaluatedItemsCoverage,
24
26
  EvalLog,
25
27
  } from './tools.js';
26
28
  import { wrapUnevaluated } from './unevaluated.js';
@@ -32,6 +34,20 @@ export {
32
34
  registerFormatCompilers
33
35
  } from './format.js';
34
36
 
37
+ import {
38
+ convertInternalErrors,
39
+ } from './messages.js';
40
+
41
+ export {
42
+ ValidationError,
43
+ messagesEn,
44
+ compileMessageTemplate,
45
+ compileMessageCatalog,
46
+ renderErrorMessage,
47
+ localizeErrors,
48
+ } from './messages.js';
49
+
50
+
35
51
  export { TraverseOptions };
36
52
 
37
53
  /**
@@ -206,43 +222,6 @@ class InternalValidationError {
206
222
  }
207
223
  }
208
224
 
209
- /**
210
- * JSON Schema Validation Error
211
- * Represents a validation error according to the JSON Schema specification.
212
- * @see https://json-schema.org/draft/2020-12/json-schema-core.html#output
213
- */
214
- export class ValidationError {
215
- /**
216
- * @param {object} options - Error options
217
- * @param {string} options.keyword - The keyword that failed validation
218
- * @param {string} options.instancePath - JSON Pointer to the data location
219
- * @param {string} options.schemaPath - JSON Pointer to the schema location
220
- * @param {object} options.params - Keyword-specific parameters
221
- * @param {string} [options.message] - Human-readable error message
222
- */
223
- constructor(options) {
224
- this.keyword = options.keyword;
225
- this.instancePath = options.instancePath || '';
226
- this.schemaPath = options.schemaPath || '';
227
- this.params = options.params || {};
228
- this.message = options.message || '';
229
- }
230
-
231
- /**
232
- * Convert error to a plain object
233
- * @returns {object} Plain object representation
234
- */
235
- toJSON() {
236
- return {
237
- keyword: this.keyword,
238
- instancePath: this.instancePath,
239
- schemaPath: this.schemaPath,
240
- params: this.params,
241
- message: this.message,
242
- };
243
- }
244
- }
245
-
246
225
  /**
247
226
  * ValidationOptions configures the behavior of the validation process.
248
227
  * @class
@@ -257,6 +236,8 @@ export class ValidationOptions {
257
236
  * @param {number} [draftVersion=7] - The JSON Schema draft version (6, 7, 2019, or 2020)
258
237
  * @param {boolean} [vocabValidation=true] - Whether the validation vocabulary is enabled (false when the schema's metaschema omits it via $vocabulary)
259
238
  * @param {boolean|null} [formatAssertion=null] - Whether format asserts (null = auto: asserts below draft 2020-12, annotation-only from 2020-12 on)
239
+ * @param {boolean} [messages=true] - Whether collected errors carry rendered message text; false skips rendering (message: '', params/msgid still set)
240
+ * @param {'error'|'ignore'} [unknownFormats='ignore'] - What to do when an ASSERTING `format` names something no compiler is registered for: 'ignore' (the default, and what the specification requires) accepts it as an annotation; 'error' throws at COMPILE time. Never affects instance validation, and never applies where format is annotation-only anyway.
260
241
  */
261
242
  constructor(
262
243
  skipErrors = true,
@@ -265,7 +246,9 @@ export class ValidationOptions {
265
246
  contentValidation = null,
266
247
  draftVersion = 7,
267
248
  vocabValidation = true,
268
- formatAssertion = null
249
+ formatAssertion = null,
250
+ messages = true,
251
+ unknownFormats = 'ignore'
269
252
  ) {
270
253
  /** @type {boolean} Whether to stop at first error or continue */
271
254
  this.skipErrors = skipErrors;
@@ -281,6 +264,10 @@ export class ValidationOptions {
281
264
  this.vocabValidation = vocabValidation;
282
265
  /** @type {boolean|null} Whether the format keyword asserts (null = auto by draft) */
283
266
  this.formatAssertion = formatAssertion;
267
+ /** @type {boolean} Whether collected errors carry rendered message text */
268
+ this.messages = messages;
269
+ /** @type {'error'|'ignore'} What an asserting `format` with no registered compiler does */
270
+ this.unknownFormats = unknownFormats;
284
271
  }
285
272
  }
286
273
 
@@ -343,6 +330,8 @@ export class ValidationRoot {
343
330
  #evalLog = new EvalLog();
344
331
  /** @type {object|null} The JarenValidator instance this compilation belongs to, or null when constructed standalone */
345
332
  #owner = null;
333
+ /** @type {Map<string, object>|null} Compiled 'errorMessage' specs by schema path; null when the schema set has none */
334
+ #errorMessages = null;
346
335
 
347
336
  /** Keywords whose value is a map of arbitrary names to schemas; those
348
337
  * names must not be mistaken for keywords (e.g. a metaschema declaring
@@ -352,6 +341,27 @@ export class ValidationRoot {
352
341
  '$defs', 'definitions',
353
342
  ]);
354
343
 
344
+ /**
345
+ * Whether an unevaluatedProperties/unevaluatedItems occurrence can force
346
+ * runtime annotation tracking. Two shapes never can (in skipErrors mode):
347
+ * the literal `true` form asserts nothing and only produces annotations,
348
+ * which matter only when a checking occurrence elsewhere consumes them;
349
+ * and a check whose sibling keywords already evaluate every property/item
350
+ * (see hasUnevaluatedPropertiesCoverage/hasUnevaluatedItemsCoverage) is
351
+ * unreachable, because reaching it means those siblings passed. When no
352
+ * occurrence forces tracking, the evaluation log has no consumers and
353
+ * annotation logging is skipped entirely.
354
+ * @param {object} node - The schema object holding the keyword
355
+ * @param {string} key - 'unevaluatedProperties' or 'unevaluatedItems'
356
+ * @returns {boolean} True when this occurrence requires annotation tracking
357
+ */
358
+ static #unevaluatedForcesTracking(node, key) {
359
+ if (node[key] === true) return false;
360
+ return key === 'unevaluatedProperties'
361
+ ? !hasUnevaluatedPropertiesCoverage(node)
362
+ : !hasUnevaluatedItemsCoverage(node);
363
+ }
364
+
355
365
  /**
356
366
  * Recursively scans a schema (sub)tree for keys that require special
357
367
  * runtime support: '$data' references and 'unevaluatedProperties'/
@@ -390,7 +400,16 @@ export class ValidationRoot {
390
400
  flags.dollarData = true;
391
401
  continue;
392
402
  }
393
- else if (key === 'unevaluatedProperties' || key === 'unevaluatedItems') flags.unevaluated = true;
403
+ else if (key === 'errorMessage') {
404
+ // 'errorMessage' is report-time metadata; its value is a message
405
+ // spec whose map form may spell keys like '$query' that must not
406
+ // register as keywords of this compilation.
407
+ continue;
408
+ }
409
+ else if (key === 'unevaluatedProperties' || key === 'unevaluatedItems') {
410
+ if (!flags.canElide || ValidationRoot.#unevaluatedForcesTracking(node, key))
411
+ flags.unevaluated = true;
412
+ }
394
413
  if (ValidationRoot.#SCAN_MAP_KEYWORDS.has(key)) {
395
414
  // The value is a name->schema map: its keys are names, its values schemas.
396
415
  ValidationRoot.#scanSchemaFeatures(node[key], seen, flags, false);
@@ -429,7 +448,10 @@ export class ValidationRoot {
429
448
  // Detect $data references and unevaluated* keywords once, so fast paths
430
449
  // can skip path building / annotation logging when nothing consumes them.
431
450
  // Must run before validators are compiled below.
432
- const flags = { dollarData: false, unevaluated: false };
451
+ // Elision of unreachable unevaluated* checks relies on validators
452
+ // short-circuiting at the first failure, so it only holds in
453
+ // skipErrors mode (see #unevaluatedForcesTracking).
454
+ const flags = { dollarData: false, unevaluated: false, canElide: opts.skipErrors === true };
433
455
  const seen = new Set();
434
456
  for (const value of schemas.values()) {
435
457
  ValidationRoot.#scanSchemaFeatures(value, seen, flags);
@@ -455,9 +477,6 @@ export class ValidationRoot {
455
477
  .filter(anchor => anchor.schema !== rootSchema);
456
478
  }
457
479
 
458
- /** @returns {string} The root schema origin/URI */
459
- get rootOrigin() { return this.#rootOrigin; }
460
-
461
480
  /** @returns {TraverseOptions} Schema traversal options */
462
481
  get traverse() { return this.#traverse; }
463
482
 
@@ -470,9 +489,6 @@ export class ValidationRoot {
470
489
  /** @returns {Array} Array of validation errors */
471
490
  get errors() { return this.#errors; }
472
491
 
473
- /** @returns {ValidationObject} The root schema's ValidationObject */
474
- get firstSchema() { return this.#firstSchema; }
475
-
476
492
  /** @returns {boolean} Whether any schema in this compilation contains a $data reference */
477
493
  get usesDollarData() { return this.#usesDollarData; }
478
494
 
@@ -485,6 +501,21 @@ export class ValidationRoot {
485
501
  /** @returns {object|null} The owning JarenValidator instance, or null when constructed standalone */
486
502
  get owner() { return this.#owner; }
487
503
 
504
+ /** @returns {Map<string, object>|null} Compiled 'errorMessage' specs by schema path, or null when the schema set has none */
505
+ get errorMessages() { return this.#errorMessages; }
506
+
507
+ /**
508
+ * Register a compiled 'errorMessage' spec for a schema location.
509
+ * Called at schema compile time (see compileSchemaObject); the registry
510
+ * is only consulted at report time, over the already-failed set.
511
+ * @param {string} path - The schema path (ValidationObject.path)
512
+ * @param {object} spec - The compiled spec (see messages.js compileErrorMessageSpec)
513
+ */
514
+ registerErrorMessage(path, spec) {
515
+ if (this.#errorMessages === null) this.#errorMessages = new Map();
516
+ this.#errorMessages.set(path, spec);
517
+ }
518
+
488
519
  /**
489
520
  * Creates a new ValidationObject for the given path and schema.
490
521
  * @param {string} path - The URI path for this schema object
@@ -510,24 +541,6 @@ export class ValidationRoot {
510
541
  return null;
511
542
  }
512
543
 
513
- /**
514
- * Gets the raw schema object for a given reference without compiling it.
515
- * Used to check schema properties (like $recursiveAnchor) at compile time.
516
- * @param {string} ref - The reference URI to resolve
517
- * @param {string} path - The current path (for error messages)
518
- * @param {object} schema - The schema containing the $ref
519
- * @returns {{id: string, schema: object}|null} The resolved schema info or null
520
- */
521
- getRawSchema(ref, path, schema) {
522
- try {
523
- const schemas = this.#schemas;
524
- const traverse = this.#traverse;
525
- return resolveRefSchemaDeep(schemas, path, schema, traverse);
526
- } catch (e) {
527
- return null;
528
- }
529
- }
530
-
531
544
  /**
532
545
  * Gets the raw schema object by its URI/ID directly from the schemas map.
533
546
  * This performs a direct lookup without following references.
@@ -574,6 +587,30 @@ export class ValidationRoot {
574
587
  return false;
575
588
  }
576
589
 
590
+ /**
591
+ * A checkpoint in the collected-error list.
592
+ *
593
+ * A SPECULATIVE applicator - an `anyOf` branch, an `if` condition, the
594
+ * subschema of a `not`, a `contains` candidate - runs a validator whose
595
+ * failure may be entirely expected. Those failures still call `addError`,
596
+ * so without a checkpoint they leak into the caller's issue list and blame
597
+ * a document for not matching a branch it was never required to match.
598
+ * Marking before the probe and rolling back after is the same discipline
599
+ * `EvalLog` already uses for annotations.
600
+ * @returns {number} The mark to pass to {@link rollbackErrors}
601
+ */
602
+ errorMark() {
603
+ return this.#errors.length;
604
+ }
605
+
606
+ /**
607
+ * Discard every error collected since `mark`.
608
+ * @param {number} mark - A value from {@link errorMark}
609
+ */
610
+ rollbackErrors(mark) {
611
+ if (this.#errors.length > mark) this.#errors.length = mark;
612
+ }
613
+
577
614
  /**
578
615
  * Validates data against the root schema.
579
616
  * @param {unknown} data - The data to validate
@@ -624,6 +661,36 @@ export class ValidationRoot {
624
661
  return rootValidator(data, '', data);
625
662
  }
626
663
 
664
+ /**
665
+ * Returns the fastest repeated-validation entry point for this root.
666
+ * Error collection and root-level dynamic anchors need the per-call
667
+ * bookkeeping of validate(); without them the compiled root validator
668
+ * only needs the annotation log cleared (when tracking is on) and can
669
+ * otherwise be invoked directly. Dynamic anchors pushed during
670
+ * validation are balanced by try/finally, so the anchor map needs no
671
+ * per-call clearing here.
672
+ * @returns {(data: unknown) => boolean} The validation entry point
673
+ */
674
+ createValidateFn() {
675
+ if (!this.#options.skipErrors || this.#options.collectErrors
676
+ || this.#rootAnchorName !== null
677
+ || this.#rootDynamicAnchors.length !== 0) {
678
+ return (data) => this.validate(data);
679
+ }
680
+
681
+ const rootValidator = this.#rootValidator;
682
+ if (this.#usesUnevaluated) {
683
+ const evalLog = this.#evalLog;
684
+ return function validateRootTracked(data) {
685
+ evalLog.reset();
686
+ return rootValidator(data, '', data);
687
+ };
688
+ }
689
+ return function validateRoot(data) {
690
+ return rootValidator(data, '', data);
691
+ };
692
+ }
693
+
627
694
  /**
628
695
  * Get the stored validator for a dynamic anchor.
629
696
  * Used by $dynamicRef for runtime resolution.
@@ -729,7 +796,13 @@ export class ValidationObject {
729
796
 
730
797
  // In draft 2019-09+, $ref can have sibling keywords that are applied together.
731
798
  // In draft 7 and earlier, $ref overrides siblings.
732
- const draftVersion = root.options.draftVersion || 7;
799
+ //
800
+ // The draft that decides this is the one declared by the schema RESOURCE
801
+ // holding the `$ref`, not the one the root document happens to use. A
802
+ // 2020-12 resource embedded in a draft-07 document has to assert its
803
+ // siblings, and a draft-07 resource inside a 2020-12 document must not —
804
+ // reading the root's draft got both backwards.
805
+ const draftVersion = self.declaredDraft ?? root.options.draftVersion ?? 7;
733
806
 
734
807
  // Base URI for resolving $ref:
735
808
  // - Draft 7 and earlier: $ref replaces the schema entirely, so a sibling
@@ -795,7 +868,8 @@ export class ValidationObject {
795
868
  root.pushDynamicAnchorValidator(anchorName, refValidator);
796
869
  }
797
870
  try {
798
- return refValidator(data, dataPath, dataRoot) && siblingValidator(data, dataPath, dataRoot);
871
+ return combineRefAndSiblings(refValidator, siblingValidator, self.options.skipErrors,
872
+ data, dataPath, dataRoot);
799
873
  } finally {
800
874
  // Pop in reverse order
801
875
  if (hasRecAnchor || dynAnchorName) {
@@ -839,7 +913,8 @@ export class ValidationObject {
839
913
  // unevaluated* keywords must see annotations produced by the $ref
840
914
  // target, so the wrapper goes around the combined validator.
841
915
  return wrapUnevaluated(self, schema, function validateRefWithSiblings(data, dataPath, dataRoot) {
842
- return refValidator(data, dataPath, dataRoot) && siblingValidator(data, dataPath, dataRoot);
916
+ return combineRefAndSiblings(refValidator, siblingValidator, self.options.skipErrors,
917
+ data, dataPath, dataRoot);
843
918
  });
844
919
  }
845
920
 
@@ -898,7 +973,8 @@ export class ValidationObject {
898
973
  try {
899
974
  // If there are sibling validators (draft 2019-09+), combine them with the ref validator
900
975
  if (boundSiblingValidator) {
901
- return refValidator(data, dataPath, dataRoot) && boundSiblingValidator(data, dataPath, dataRoot);
976
+ return combineRefAndSiblings(refValidator, boundSiblingValidator,
977
+ self.options.skipErrors, data, dataPath, dataRoot);
902
978
  }
903
979
  return refValidator(data, dataPath, dataRoot);
904
980
  } finally {
@@ -908,7 +984,8 @@ export class ValidationObject {
908
984
 
909
985
  // If there are sibling validators (draft 2019-09+), combine them with the ref validator
910
986
  if (boundSiblingValidator) {
911
- return refValidator(data, dataPath, dataRoot) && boundSiblingValidator(data, dataPath, dataRoot);
987
+ return combineRefAndSiblings(refValidator, boundSiblingValidator,
988
+ self.options.skipErrors, data, dataPath, dataRoot);
912
989
  }
913
990
 
914
991
  // Cache the validator directly (skipping this resolver) only when
@@ -937,8 +1014,6 @@ export class ValidationObject {
937
1014
  #schema = null;
938
1015
  /** @type {function|null} The compiled validator function */
939
1016
  #validator = null;
940
- /** @type {string} The base URI passed during construction */
941
- #baseUri = null;
942
1017
  /** @type {string} The effective base URI for child $ref resolution */
943
1018
  #effectiveBaseUri = null;
944
1019
  /** @type {number|null} Draft version declared by this schema's document ($schema), inherited by subschemas; null when never declared */
@@ -977,7 +1052,6 @@ export class ValidationObject {
977
1052
  // No $id - inherit parent's base
978
1053
  this.#effectiveBaseUri = baseUri;
979
1054
  }
980
- this.#baseUri = baseUri;
981
1055
 
982
1056
  this.#validator = ValidationObject.compileValidator(this, path, schema, baseUri);
983
1057
  }
@@ -992,11 +1066,6 @@ export class ValidationObject {
992
1066
  return this.#effectiveBaseUri;
993
1067
  }
994
1068
 
995
- /** @returns {Array} The current validation errors from the root */
996
- get errors() {
997
- return this.#root.errors;
998
- }
999
-
1000
1069
  /** @returns {function} The compiled validator function */
1001
1070
  get validate() {
1002
1071
  return this.#validator;
@@ -1040,13 +1109,13 @@ export class ValidationObject {
1040
1109
  // Just return false immediately to avoid the overhead of error creation
1041
1110
  if (self.#root.options.skipErrors) {
1042
1111
  if (!Array.isArray(key)) {
1043
- return function addNormalErrorFast(data, ...meta) {
1112
+ return function addNormalErrorFast(_data, ..._meta) {
1044
1113
  // Just return false without creating error object
1045
1114
  return false;
1046
1115
  };
1047
1116
  }
1048
1117
  else {
1049
- return function addKeyedErrorFast(dataKey, data, ...meta) {
1118
+ return function addKeyedErrorFast(_dataKey, _data, ..._meta) {
1050
1119
  // Just return false without creating error object
1051
1120
  return false;
1052
1121
  };
@@ -1118,6 +1187,26 @@ export class ValidationObject {
1118
1187
  }
1119
1188
  }
1120
1189
 
1190
+ /**
1191
+ * Run a `$ref` and its sibling keywords, which are INDEPENDENT of each other:
1192
+ * a document can fail the referenced schema and its siblings for unrelated
1193
+ * reasons, and reporting only the first is the same short-circuit that used to
1194
+ * hide half of every issue list. Boolean mode keeps the early exit.
1195
+ * @param {Function} refValidator
1196
+ * @param {Function} siblingValidator
1197
+ * @param {boolean} stopAtFirst
1198
+ * @param {any} data
1199
+ * @param {string} dataPath
1200
+ * @param {any} dataRoot
1201
+ * @returns {boolean}
1202
+ */
1203
+ function combineRefAndSiblings(refValidator, siblingValidator, stopAtFirst, data, dataPath, dataRoot) {
1204
+ if (stopAtFirst)
1205
+ return refValidator(data, dataPath, dataRoot) && siblingValidator(data, dataPath, dataRoot);
1206
+ const target = refValidator(data, dataPath, dataRoot);
1207
+ return siblingValidator(data, dataPath, dataRoot) && target;
1208
+ }
1209
+
1121
1210
  /**
1122
1211
  * ValidatorOptions configures the JarenValidator instance.
1123
1212
  * Can be created with positional arguments or an options object.
@@ -1156,17 +1245,22 @@ export class ValidatorOptions {
1156
1245
  /** @type {object[]} Initial schemas to register */
1157
1246
  this.schemas = opts.schemas || [];
1158
1247
  // If collectErrors is passed directly, create ValidationOptions with it
1159
- if (opts.collectErrors != null || opts.skipErrors != null || opts.useGrapheme != null || opts.contentValidation != null || opts.draftVersion != null || opts.formatAssertion != null) {
1248
+ if (opts.collectErrors != null || opts.skipErrors != null || opts.useGrapheme != null || opts.contentValidation != null || opts.draftVersion != null || opts.formatAssertion != null || opts.messages != null || opts.unknownFormats != null) {
1160
1249
  const collectErrors = opts.collectErrors ?? false;
1161
1250
  this.validation = new ValidationOptions(
1162
1251
  // collecting errors implies actually recording them
1163
1252
  opts.skipErrors ?? !collectErrors,
1164
1253
  opts.useGrapheme ?? true,
1165
1254
  collectErrors,
1166
- opts.contentValidation ?? false,
1255
+ // null, not false: an unset option must stay unset so `compile`
1256
+ // can apply the per-draft default. Coercing it here would make
1257
+ // any options object silently disable content assertion.
1258
+ opts.contentValidation ?? null,
1167
1259
  opts.draftVersion ?? 7,
1168
1260
  true,
1169
- opts.formatAssertion ?? null
1261
+ opts.formatAssertion ?? null,
1262
+ opts.messages ?? true,
1263
+ opts.unknownFormats ?? 'ignore'
1170
1264
  );
1171
1265
  } else {
1172
1266
  /** @type {ValidationOptions} Validation behavior options */
@@ -1183,9 +1277,51 @@ export class ValidatorOptions {
1183
1277
  }
1184
1278
  }
1185
1279
 
1280
+ /**
1281
+ * The object a compiled validator returns when `collectErrors` is enabled.
1282
+ * @typedef {{ valid: boolean, errors: import("./messages.js").ValidationError[] }} ValidationResultObject
1283
+ */
1284
+
1285
+ /**
1286
+ * A compiled validator in the default boolean mode. It is a type guard, so
1287
+ * `T` is whatever the caller asserts the schema describes; with no `T` it
1288
+ * behaves as an ordinary boolean predicate.
1289
+ * @template T
1290
+ * @typedef {(data: unknown) => data is T} CompiledPredicate
1291
+ */
1292
+
1293
+ /**
1294
+ * A compiled validator in collect-errors mode.
1295
+ * @typedef {(data: unknown) => ValidationResultObject} CompiledCollector
1296
+ */
1297
+
1298
+ /**
1299
+ * The plain-object form accepted by the JarenValidator constructor, mixing
1300
+ * validator-level settings with the ValidationOptions fields.
1301
+ * @template {boolean} [TCollect=false]
1302
+ * @typedef {object} ValidatorInit
1303
+ * @property {Record<string, FormatCompiler>} [formats] - Format compilers to register
1304
+ * @property {(JSONSchema | boolean)[]} [schemas] - Schemas to register
1305
+ * @property {ValidationOptions} [validation] - Validation behavior options
1306
+ * @property {TraverseOptions} [traverse] - Schema traversal options
1307
+ * @property {TCollect} [collectErrors] - Return `{ valid, errors }` instead of a boolean
1308
+ * @property {boolean} [skipErrors] - Stop at the first failure (defaults to `!collectErrors`)
1309
+ * @property {boolean} [useGrapheme] - Count grapheme clusters for string length
1310
+ * @property {boolean} [contentValidation] - Assert contentEncoding/contentMediaType
1311
+ * @property {number} [draftVersion] - The JSON Schema draft version
1312
+ * @property {boolean} [formatAssertion] - Assert the format keyword
1313
+ * @property {boolean} [messages] - Render English message text on collected errors
1314
+ * @property {'error'|'ignore'} [unknownFormats] - What an ASSERTING `format` with no registered compiler does: 'ignore' (default, per spec) accepts it as an annotation, 'error' throws at compile time
1315
+ */
1316
+
1186
1317
  /**
1187
1318
  * JarenValidator is the main entry point for JSON Schema validation.
1188
1319
  * It manages schema registration, format registration, and compilation.
1320
+ *
1321
+ * The `collectErrors` option decides what a compiled validator returns, and
1322
+ * it is carried in the type parameter so the two shapes never have to be
1323
+ * distinguished at runtime.
1324
+ * @template {boolean} [TCollect=false]
1189
1325
  * @class
1190
1326
  * @example
1191
1327
  * const validator = new JarenValidator();
@@ -1205,7 +1341,7 @@ export class JarenValidator {
1205
1341
 
1206
1342
  /**
1207
1343
  * Creates a new JarenValidator instance.
1208
- * @param {ValidatorOptions} [options] - Validator options including formats, schemas, validation options, and traverse options
1344
+ * @param {ValidatorOptions | ValidatorInit<TCollect>} [options] - Validator options including formats, schemas, validation options, and traverse options
1209
1345
  */
1210
1346
  constructor(options = new ValidatorOptions()) {
1211
1347
  // Accept a plain options object ({ skipErrors, collectErrors, ... })
@@ -1223,7 +1359,7 @@ export class JarenValidator {
1223
1359
  * Adds a format validator.
1224
1360
  * @param {string} name - The format name (e.g., 'email', 'uri', 'date-time')
1225
1361
  * @param {FormatCompiler} formatCompiler - A function that compiles format validators
1226
- * @returns {JarenValidator} This validator instance for chaining
1362
+ * @returns {this} This validator instance for chaining (the polymorphic `this` keeps the collectErrors type parameter across a chain)
1227
1363
  * @example
1228
1364
  * validator.addFormat('custom', (schemaObj, schema) => {
1229
1365
  * return (data) => data.startsWith('custom:');
@@ -1240,7 +1376,7 @@ export class JarenValidator {
1240
1376
  /**
1241
1377
  * Adds multiple format validators at once.
1242
1378
  * @param {Record<string, FormatCompiler>} formatCompilers - Object mapping format names to compiler functions
1243
- * @returns {JarenValidator} This validator instance for chaining
1379
+ * @returns {this} This validator instance for chaining (the polymorphic `this` keeps the collectErrors type parameter across a chain)
1244
1380
  */
1245
1381
  addFormats(formatCompilers) {
1246
1382
  registerFormatCompilers(
@@ -1286,7 +1422,7 @@ export class JarenValidator {
1286
1422
  * Dependencies can be added in any order, and circular dependencies are supported.
1287
1423
  * @param {JSONSchema | boolean | (JSONSchema | boolean)[]} schema - The schema(s) to add
1288
1424
  * @param {string} [key] - Optional key/URI to register the schema under
1289
- * @returns {JarenValidator} This validator instance for chaining
1425
+ * @returns {this} This validator instance for chaining (the polymorphic `this` keeps the collectErrors type parameter across a chain)
1290
1426
  * @example
1291
1427
  * // Add a single schema
1292
1428
  * validator.addSchema({ $id: 'http://example.com/user', type: 'object' });
@@ -1348,7 +1484,7 @@ export class JarenValidator {
1348
1484
  const newSchemas = new Map();
1349
1485
  try {
1350
1486
  storeSchemaIdsInMap(newSchemas, baseUri, schema, scopedOpts);
1351
- } catch (e) {
1487
+ } catch (_e) {
1352
1488
  // Ignore errors for already-existing schemas at the root level
1353
1489
  }
1354
1490
 
@@ -1360,144 +1496,19 @@ export class JarenValidator {
1360
1496
  }
1361
1497
  }
1362
1498
 
1363
- /**
1364
- * Convert internal validation errors to public ValidationError format
1365
- * @param {InternalValidationError[]} internalErrors
1366
- * @returns {ValidationError[]}
1367
- */
1368
- static #convertErrors(internalErrors) {
1369
- return internalErrors.map(err => {
1370
- const keyword = Array.isArray(err.key) ? err.key[err.key.length - 1] : err.key;
1371
-
1372
- // Build params based on error type
1373
- const params = {};
1374
- if (keyword === 'required') {
1375
- params.missingProperty = err.dataKey;
1376
- } else if (keyword === 'type') {
1377
- if (Array.isArray(err.expected)) {
1378
- params.types = err.expected;
1379
- } else {
1380
- params.type = err.expected;
1381
- }
1382
- } else if (['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minLength', 'maxLength', 'minProperties', 'maxProperties', 'minItems', 'maxItems'].includes(keyword)) {
1383
- params.limit = err.expected;
1384
- if (keyword === 'minimum' || keyword === 'maximum') {
1385
- params.comparison = keyword === 'minimum' ? '>=' : '<=';
1386
- } else if (keyword === 'exclusiveMinimum' || keyword === 'exclusiveMaximum') {
1387
- params.comparison = keyword === 'exclusiveMinimum' ? '>' : '<';
1388
- }
1389
- } else if (keyword === 'multipleOf') {
1390
- params.multipleOf = err.expected;
1391
- } else if (keyword === 'pattern') {
1392
- params.pattern = err.expected?.source || err.expected;
1393
- } else if (keyword === 'additionalProperties') {
1394
- params.additionalProperty = err.dataKey;
1395
- } else if (keyword === '$query') {
1396
- // A '$query' runtime failure passes the JQ2xxx code and the query
1397
- // document pointer as extra meta arguments after the data path;
1398
- // a plain EBV-false failure passes neither.
1399
- if (err.rest != null && err.rest.length > 1) {
1400
- params.code = err.rest[1];
1401
- params.docPath = err.rest[2];
1402
- }
1403
- }
1404
-
1405
- // Generate message
1406
- let message = `validation failed for keyword '${keyword}'`;
1407
- if (keyword === 'required') {
1408
- message = params.missingProperty
1409
- ? `must have required property '${params.missingProperty}'`
1410
- : 'must have required properties';
1411
- } else if (keyword === 'type') {
1412
- message = params.types
1413
- ? `must be one of the following types: ${params.types.join(', ')}`
1414
- : `must be ${params.type === 'integer' ? 'an' : 'a'} ${params.type}`;
1415
- } else if (keyword === 'minimum' || keyword === 'maximum') {
1416
- message = `must be ${params.comparison} ${params.limit}`;
1417
- } else if (keyword === 'exclusiveMinimum' || keyword === 'exclusiveMaximum') {
1418
- message = `must be ${params.comparison} ${params.limit}`;
1419
- } else if (keyword === 'multipleOf') {
1420
- message = `must be multiple of ${params.multipleOf}`;
1421
- } else if (keyword === 'minLength') {
1422
- message = `must NOT have fewer than ${params.limit} characters`;
1423
- } else if (keyword === 'maxLength') {
1424
- message = `must NOT have more than ${params.limit} characters`;
1425
- } else if (keyword === 'pattern') {
1426
- message = `must match pattern "${params.pattern}"`;
1427
- } else if (keyword === 'additionalProperties') {
1428
- message = params.additionalProperty
1429
- ? `must NOT have additional property '${params.additionalProperty}'`
1430
- : 'must NOT have additional properties';
1431
- } else if (keyword === 'minProperties') {
1432
- message = `must NOT have fewer than ${params.limit} properties`;
1433
- } else if (keyword === 'maxProperties') {
1434
- message = `must NOT have more than ${params.limit} properties`;
1435
- } else if (keyword === 'minItems') {
1436
- message = `must NOT have fewer than ${params.limit} items`;
1437
- } else if (keyword === 'maxItems') {
1438
- message = `must NOT have more than ${params.limit} items`;
1439
- } else if (keyword === 'uniqueItems') {
1440
- message = 'must NOT have duplicate items';
1441
- } else if (keyword === 'contains') {
1442
- message = 'must contain at least one valid item';
1443
- } else if (keyword === 'items') {
1444
- message = 'array items are invalid';
1445
- } else if (keyword === 'allOf') {
1446
- message = 'must match all of the subschemas';
1447
- } else if (keyword === 'anyOf') {
1448
- message = 'must match a subschema in anyOf';
1449
- } else if (keyword === 'oneOf') {
1450
- message = 'must match exactly one subschema in oneOf';
1451
- } else if (keyword === 'not') {
1452
- message = 'must NOT match the subschema';
1453
- } else if (keyword === 'format') {
1454
- const formatName = err.expected || err.value;
1455
- params.format = formatName;
1456
- message = `must match format "${formatName}"`;
1457
- } else if (keyword === 'if') {
1458
- message = 'must match "if" schema';
1459
- } else if (keyword === 'then') {
1460
- message = 'must match "then" schema';
1461
- } else if (keyword === 'else') {
1462
- message = 'must match "else" schema';
1463
- } else if (keyword === 'false schema') {
1464
- message = 'boolean schema false is always invalid';
1465
- } else if (keyword === '$query') {
1466
- message = params.code
1467
- ? `'$query' assertion raised ${params.code} at '${params.docPath}'`
1468
- : "must satisfy the '$query' assertion";
1469
- }
1470
-
1471
- // Validators pass the data path as the first meta argument to the
1472
- // error handler; use it when it looks like a JSON pointer.
1473
- const meta0 = err.rest?.[0];
1474
- const instancePath = (typeof meta0 === 'string' && (meta0 === '' || meta0.charCodeAt(0) === 0x2f))
1475
- ? meta0
1476
- : '';
1477
-
1478
- return new ValidationError({
1479
- keyword,
1480
- instancePath,
1481
- schemaPath: err.object?.path || '',
1482
- params,
1483
- message,
1484
- });
1485
- });
1486
- }
1487
-
1488
1499
  /**
1489
1500
  *
1490
1501
  * @param {JarenValidator} self
1491
1502
  * @param {string} origin
1492
1503
  * @param {Map} schemas
1493
- * @returns {(data) => boolean | {valid: boolean, errors: ValidationError[]}}
1504
+ * @returns {(data) => boolean | {valid: boolean, errors: import("./messages.js").ValidationError[]}}
1494
1505
  */
1495
- static #compileSchema(self, origin, schemas) {
1506
+ static #compileSchema(self, origin, schemas, validation = self.#options.validation) {
1496
1507
  const root = new ValidationRoot(
1497
1508
  origin,
1498
1509
  schemas,
1499
1510
  self.#formats,
1500
- self.#options.validation,
1511
+ validation,
1501
1512
  self.#options.traverse,
1502
1513
  self);
1503
1514
 
@@ -1508,16 +1519,12 @@ export class JarenValidator {
1508
1519
  if (collectErrors) {
1509
1520
  return {
1510
1521
  valid,
1511
- errors: valid ? [] : JarenValidator.#convertErrors(root.errors)
1522
+ errors: valid ? [] : convertInternalErrors(root.errors)
1512
1523
  };
1513
1524
  }
1514
1525
  return valid;
1515
1526
  }
1516
1527
 
1517
- Object.defineProperty(jarenValidateSchema, "errors", {
1518
- get: function () { return root.errors }
1519
- })
1520
-
1521
1528
  return jarenValidateSchema;
1522
1529
  }
1523
1530
 
@@ -1527,20 +1534,25 @@ export class JarenValidator {
1527
1534
  * @param {string} origin
1528
1535
  * @param {Map} schemas
1529
1536
  * @param {ValidationRoot} root - Pre-created root with pre-compiled refs
1530
- * @returns {(data) => boolean | {valid: boolean, errors: ValidationError[]}}
1537
+ * @returns {(data) => boolean | {valid: boolean, errors: import("./messages.js").ValidationError[]}}
1531
1538
  */
1532
1539
  static #compileSchemaWithRoot(self, origin, schemas, root) {
1533
1540
  const collectErrors = self.#options.validation?.collectErrors || false;
1534
1541
 
1542
+ if (!collectErrors) {
1543
+ const jarenValidateSchema = root.createValidateFn();
1544
+ Object.defineProperty(jarenValidateSchema, "errors", {
1545
+ get: function () { return root.errors }
1546
+ })
1547
+ return jarenValidateSchema;
1548
+ }
1549
+
1535
1550
  function jarenValidateSchema(data) {
1536
1551
  const valid = root.validate(data);
1537
- if (collectErrors) {
1538
- return {
1539
- valid,
1540
- errors: valid ? [] : JarenValidator.#convertErrors(root.errors)
1541
- };
1542
- }
1543
- return valid;
1552
+ return {
1553
+ valid,
1554
+ errors: valid ? [] : convertInternalErrors(root.errors)
1555
+ };
1544
1556
  }
1545
1557
 
1546
1558
  Object.defineProperty(jarenValidateSchema, "errors", {
@@ -1559,28 +1571,45 @@ export class JarenValidator {
1559
1571
  * Meta-schemas are schemas that describe the structure of valid JSON schemas.
1560
1572
  * @param {JSONSchema | boolean | (JSONSchema | boolean)[]} schema - The meta-schema(s) to add
1561
1573
  * @param {string} [key] - Optional key/URI for the meta-schema
1562
- * @returns {JarenValidator} This validator instance for chaining
1574
+ * @returns {this} This validator instance for chaining (the polymorphic `this` keeps the collectErrors type parameter across a chain)
1563
1575
  * @example
1564
1576
  * validator.addMetaSchema(draft7MetaSchema, 'http://json-schema.org/draft-07/schema');
1565
1577
  */
1566
1578
  addMetaSchema(schema, key = undefined) {
1567
1579
  key = JarenValidator.normalizeUriKey(key)
1580
+ // A meta-schema is infrastructure, not something the caller authored:
1581
+ // every JSON Schema meta-schema declares `format: "uri-reference"` on
1582
+ // `$id`/`$ref`, and nobody registers formats in order to check that a
1583
+ // SCHEMA is well-formed. The unknownFormats guard protects an author
1584
+ // from a keyword of their own that silently checks nothing, so it does
1585
+ // not apply here — otherwise merely registering draft-07 would throw.
1586
+ const validation = JarenValidator.#withOption(this.#options.validation,
1587
+ 'unknownFormats', 'ignore');
1568
1588
  if (Array.isArray(schema)) {
1569
1589
  const first = schema.shift();
1570
1590
  const { origin, map } = JarenValidator.#traverseSchema(first, schema, undefined, new TraverseOptions(key));
1571
- const compiled = JarenValidator.#compileSchema(this, origin, map);
1591
+ const compiled = JarenValidator.#compileSchema(this, origin, map, validation);
1572
1592
  this.#metaSchemas.set(origin, compiled);
1573
1593
  mergeMap(this.#schemas, map);
1574
1594
  }
1575
1595
  else if (isBoolOrObjectClass(schema)) {
1576
1596
  const { origin, map } = JarenValidator.#traverseSchema(schema, undefined, undefined, new TraverseOptions(key));
1577
- const compiled = JarenValidator.#compileSchema(this, origin, map);
1597
+ const compiled = JarenValidator.#compileSchema(this, origin, map, validation);
1578
1598
  this.#metaSchemas.set(origin, compiled);
1579
1599
  mergeMap(this.#schemas, map);
1580
1600
  }
1581
1601
  return this;
1582
1602
  }
1583
1603
 
1604
+ /** A ValidationOptions with one member replaced, leaving the original
1605
+ * untouched (the validator's own options must not drift). */
1606
+ static #withOption(validation, name, value) {
1607
+ const next = new ValidationOptions();
1608
+ Object.assign(next, validation ?? new ValidationOptions());
1609
+ next[name] = value;
1610
+ return next;
1611
+ }
1612
+
1584
1613
  /**
1585
1614
  * Retrieves a registered schema by its key/URI.
1586
1615
  * @param {string} key - The schema URI/key
@@ -1645,7 +1674,7 @@ export class JarenValidator {
1645
1674
  // This is a canonical $id path - create with origin as base
1646
1675
  try {
1647
1676
  root.createObject(id, schema, origin);
1648
- } catch (e) {
1677
+ } catch (_e) {
1649
1678
  // May fail if dependencies not resolved yet
1650
1679
  }
1651
1680
  }
@@ -1751,7 +1780,7 @@ export class JarenValidator {
1751
1780
  baseUri = candidateBase;
1752
1781
  break;
1753
1782
  }
1754
- } catch (e) {
1783
+ } catch (_e) {
1755
1784
  // Invalid URL, skip this candidate
1756
1785
  }
1757
1786
  }
@@ -1760,7 +1789,7 @@ export class JarenValidator {
1760
1789
 
1761
1790
  try {
1762
1791
  root.createObject(id, schema, baseUri);
1763
- } catch (e) {
1792
+ } catch (_e) {
1764
1793
  // Ref may not be resolvable yet, that's ok
1765
1794
  }
1766
1795
  }
@@ -1770,9 +1799,16 @@ export class JarenValidator {
1770
1799
  * Compiles a schema into a validation function.
1771
1800
  * This is the main method for creating validators. It resolves all $ref references,
1772
1801
  * compiles the schema structure, and returns a function that validates data.
1802
+ * The return type follows the instance's `collectErrors` setting: a type
1803
+ * guard over `unknown` by default, or a function producing
1804
+ * `{ valid, errors }` when errors are collected. Jaren does not infer `T`
1805
+ * from the schema — the caller asserts what the schema describes, which is
1806
+ * what a checked contract wrapper wants; pair it with a schema-to-type
1807
+ * generator if you need the shape derived mechanically.
1808
+ * @template [T=unknown]
1773
1809
  * @param {JSONSchema | boolean} schema - The schema to compile
1774
1810
  * @param {(JSONSchema | boolean)[]} [schemas] - Additional schemas to reference during compilation
1775
- * @returns {(data: any) => boolean | {valid: boolean, errors: ValidationError[]}} A validation function
1811
+ * @returns {TCollect extends true ? CompiledCollector : CompiledPredicate<T>} A validation function
1776
1812
  * @example
1777
1813
  * const validate = validator.compile({
1778
1814
  * type: 'object',
@@ -1784,9 +1820,13 @@ export class JarenValidator {
1784
1820
  * const valid = validate({ name: 'John' }); // true
1785
1821
  * const invalid = validate({ name: 123 }); // false
1786
1822
  *
1823
+ * // Narrowing to a caller-asserted type
1824
+ * const isUser = validator.compile<{ name: string }>(userSchema);
1825
+ * if (isUser(input)) input.name; // input is { name: string } here
1826
+ *
1787
1827
  * // With error collection
1788
- * validator = new JarenValidator({ collectErrors: true });
1789
- * const result = validate({ name: 123 });
1828
+ * const collecting = new JarenValidator({ collectErrors: true });
1829
+ * const result = collecting.compile(schema)({ name: 123 });
1790
1830
  * // result = { valid: false, errors: [...] }
1791
1831
  */
1792
1832
  compile(schema, schemas = undefined) {
@@ -1831,7 +1871,9 @@ export class JarenValidator {
1831
1871
  existingValidation.contentValidation ?? contentValidationDefault,
1832
1872
  draftVersion,
1833
1873
  vocabValidation,
1834
- formatAssertion
1874
+ formatAssertion,
1875
+ existingValidation.messages ?? true,
1876
+ existingValidation.unknownFormats ?? 'ignore'
1835
1877
  );
1836
1878
 
1837
1879
  // Pre-compile all refs before returning the validator