@amritk/generate-validators 0.10.1 → 0.11.1

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.
@@ -2,7 +2,7 @@ import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
2
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
3
3
  import { refToName } from '@amritk/helpers/ref-to-name';
4
4
  import { safeAccessor } from '@amritk/helpers/safe-accessor';
5
- import { hasAdditionalProperties, hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaximum, hasMaxLength, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasStrictExclusiveMaximum, hasStrictExclusiveMinimum, hasType, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
5
+ import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasStrictExclusiveMaximum, hasStrictExclusiveMinimum, hasType, hasUniqueItems, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
6
6
  import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
7
7
  /**
8
8
  * Derives the validator function name from a type name.
@@ -38,12 +38,15 @@ const wrongTypeCondition = (accessor, type) => {
38
38
  case 'string':
39
39
  return `typeof ${accessor} !== 'string'`;
40
40
  case 'number':
41
- case 'integer':
42
41
  return `typeof ${accessor} !== 'number'`;
42
+ case 'integer':
43
+ return `typeof ${accessor} !== 'number' || !Number.isInteger(${accessor})`;
43
44
  case 'boolean':
44
45
  return `typeof ${accessor} !== 'boolean'`;
45
46
  case 'array':
46
47
  return `!Array.isArray(${accessor})`;
48
+ case 'null':
49
+ return `${accessor} !== null`;
47
50
  case 'object':
48
51
  return `typeof ${accessor} !== 'object' || ${accessor} === null || Array.isArray(${accessor})`;
49
52
  default:
@@ -101,6 +104,28 @@ const generateStrictKeyChecks = (schema, ctx) => {
101
104
  ` }`,
102
105
  ];
103
106
  };
107
+ /**
108
+ * Emits presence checks for `required` keys that have no `properties` entry.
109
+ * Keys present in `properties` get their missing-property check from
110
+ * {@link generatePropertyChecks}; a required key with no schema of its own would
111
+ * otherwise go unchecked, so its presence is enforced here to match the
112
+ * interpreter and Ajv.
113
+ */
114
+ const generateMissingRequiredChecks = (schema, ctx) => {
115
+ if (!isSchemaObject(schema) || !hasRequired(schema))
116
+ return [];
117
+ const props = hasProperties(schema) ? schema.properties : {};
118
+ const parentPath = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
119
+ const lines = [];
120
+ for (const key of schema.required) {
121
+ if (Object.hasOwn(props, key))
122
+ continue;
123
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
124
+ lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
125
+ lines.push(` }`);
126
+ }
127
+ return lines;
128
+ };
104
129
  /**
105
130
  * Generates validation lines for a single property in an object schema.
106
131
  * Handles $ref delegation, enum checks, type checks, string/number constraints,
@@ -225,92 +250,391 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
225
250
  lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`);
226
251
  lines.push(` }`);
227
252
  }
228
- // String constraints
229
- if (t === 'string') {
230
- if (hasPattern(propSchema)) {
231
- const re = escapeRegexPattern(propSchema.pattern);
232
- const msg = JSON.stringify(`must match pattern ${propSchema.pattern}`);
233
- lines.push(` if (typeof ${raw} === 'string' && !/${re}/.test(${raw})) {`);
234
- lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
235
- lines.push(` }`);
236
- }
237
- if (hasMinLength(propSchema)) {
238
- lines.push(` if (typeof ${raw} === 'string' && ${raw}.length < ${propSchema.minLength}) {`);
239
- lines.push(` errors.push({ message: 'must have at least ${propSchema.minLength} characters', path: ${path} })`);
240
- lines.push(` }`);
241
- }
242
- if (hasMaxLength(propSchema)) {
243
- lines.push(` if (typeof ${raw} === 'string' && ${raw}.length > ${propSchema.maxLength}) {`);
244
- lines.push(` errors.push({ message: 'must have at most ${propSchema.maxLength} characters', path: ${path} })`);
245
- lines.push(` }`);
246
- }
253
+ lines.push(...generateConstraintChecks(key, raw, path, propSchema, suffix, ctx));
254
+ }
255
+ // Keywords that can sit alongside or instead of `type`: combinators
256
+ // (`allOf`/`anyOf`/`oneOf`/`not`/`if`) for any schema, plus the constraint
257
+ // checks for a *type-less* schema (e.g. a bare `{ required: [...] }` or
258
+ // `{ minItems: 2 }` property). A typed schema already ran its constraints in
259
+ // the `hasType` branch above, so it only needs the combinators here.
260
+ const extraLines = hasType(propSchema)
261
+ ? generateCombinatorChecks(key, raw, path, propSchema, suffix, ctx)
262
+ : [
263
+ ...generateConstraintChecks(key, raw, path, propSchema, suffix, ctx),
264
+ ...generateCombinatorChecks(key, raw, path, propSchema, suffix, ctx),
265
+ ];
266
+ if (extraLines.length > 0) {
267
+ if (!hasType(propSchema) && isRequired) {
268
+ // No `type` to anchor a missing-property check, so enforce presence here.
269
+ lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
270
+ lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
271
+ lines.push(` } else {`);
272
+ lines.push(...extraLines);
273
+ lines.push(` }`);
247
274
  }
248
- // Number constraints
249
- if (t === 'number' || t === 'integer') {
250
- if (hasMinimum(propSchema)) {
251
- // Draft-04 `exclusiveMinimum: true` makes the paired `minimum` strict.
252
- const strict = hasStrictExclusiveMinimum(propSchema);
253
- const op = strict ? '<=' : '<';
254
- const rel = strict ? '>' : '>=';
255
- lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.minimum}) {`);
256
- lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.minimum}', path: ${path} })`);
257
- lines.push(` }`);
258
- }
259
- if (hasMaximum(propSchema)) {
260
- const strict = hasStrictExclusiveMaximum(propSchema);
261
- const op = strict ? '>=' : '>';
262
- const rel = strict ? '<' : '<=';
263
- lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.maximum}) {`);
264
- lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.maximum}', path: ${path} })`);
265
- lines.push(` }`);
266
- }
267
- if (hasExclusiveMinimum(propSchema)) {
268
- lines.push(` if (typeof ${raw} === 'number' && ${raw} <= ${propSchema.exclusiveMinimum}) {`);
269
- lines.push(` errors.push({ message: 'must be > ${propSchema.exclusiveMinimum}', path: ${path} })`);
270
- lines.push(` }`);
271
- }
272
- if (hasExclusiveMaximum(propSchema)) {
273
- lines.push(` if (typeof ${raw} === 'number' && ${raw} >= ${propSchema.exclusiveMaximum}) {`);
274
- lines.push(` errors.push({ message: 'must be < ${propSchema.exclusiveMaximum}', path: ${path} })`);
275
- lines.push(` }`);
276
- }
277
- if (hasMultipleOf(propSchema)) {
278
- lines.push(` if (typeof ${raw} === 'number' && ${raw} % ${propSchema.multipleOf} !== 0) {`);
279
- lines.push(` errors.push({ message: 'must be a multiple of ${propSchema.multipleOf}', path: ${path} })`);
280
- lines.push(` }`);
281
- }
275
+ else {
276
+ lines.push(` if (${raw} !== undefined) {`);
277
+ lines.push(...extraLines);
278
+ lines.push(` }`);
282
279
  }
283
- // Array with typed items
284
- if (t === 'array' && hasItems(propSchema)) {
285
- const itemSchema = propSchema.items;
286
- if (hasRef(itemSchema)) {
287
- const vName = validatorName(refToName(itemSchema.$ref, suffix));
280
+ }
281
+ return lines;
282
+ };
283
+ /**
284
+ * Emits the value-shape constraints for a typed value: string (pattern,
285
+ * min/maxLength), number/integer (bounds, multipleOf), typed/`$ref` array items,
286
+ * and recursion into an inline nested object. Shared by the named-property path
287
+ * ({@link generatePropertyChecks}) and the dynamic-key path
288
+ * ({@link generateValueChecks}) so both enforce identical rules. `raw` and `path`
289
+ * are arbitrary expressions, so the same logic serves a static `obj.key` and a
290
+ * `patternProperties` / `additionalProperties` value read at a runtime key.
291
+ */
292
+ const generateConstraintChecks = (key, raw, path, propSchema, suffix, ctx) => {
293
+ if (!isSchemaObject(propSchema))
294
+ return [];
295
+ const sp = propSchema;
296
+ const lines = [];
297
+ // Each block is gated on the *presence of its keywords*, not a declared `type`,
298
+ // and every emitted check carries its own runtime-type guard (`typeof` /
299
+ // `Array.isArray`). So a type-less schema (e.g. an `allOf` / `anyOf` / `not`
300
+ // branch that is just `{ required: [...] }` or `{ minItems: 2 }`) is validated
301
+ // against the value's runtime type, matching the interpreter.
302
+ // String constraints
303
+ if (hasPattern(propSchema) || hasMinLength(propSchema) || hasMaxLength(propSchema)) {
304
+ if (hasPattern(propSchema)) {
305
+ const re = escapeRegexPattern(propSchema.pattern);
306
+ const msg = JSON.stringify(`must match pattern ${propSchema.pattern}`);
307
+ lines.push(` if (typeof ${raw} === 'string' && !/${re}/.test(${raw})) {`);
308
+ lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
309
+ lines.push(` }`);
310
+ }
311
+ if (hasMinLength(propSchema)) {
312
+ lines.push(` if (typeof ${raw} === 'string' && ${raw}.length < ${propSchema.minLength}) {`);
313
+ lines.push(` errors.push({ message: 'must have at least ${propSchema.minLength} characters', path: ${path} })`);
314
+ lines.push(` }`);
315
+ }
316
+ if (hasMaxLength(propSchema)) {
317
+ lines.push(` if (typeof ${raw} === 'string' && ${raw}.length > ${propSchema.maxLength}) {`);
318
+ lines.push(` errors.push({ message: 'must have at most ${propSchema.maxLength} characters', path: ${path} })`);
319
+ lines.push(` }`);
320
+ }
321
+ }
322
+ // Number constraints
323
+ if (hasMinimum(propSchema) ||
324
+ hasMaximum(propSchema) ||
325
+ hasExclusiveMinimum(propSchema) ||
326
+ hasExclusiveMaximum(propSchema) ||
327
+ hasMultipleOf(propSchema)) {
328
+ if (hasMinimum(propSchema)) {
329
+ // Draft-04 `exclusiveMinimum: true` makes the paired `minimum` strict.
330
+ const strict = hasStrictExclusiveMinimum(propSchema);
331
+ const op = strict ? '<=' : '<';
332
+ const rel = strict ? '>' : '>=';
333
+ lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.minimum}) {`);
334
+ lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.minimum}', path: ${path} })`);
335
+ lines.push(` }`);
336
+ }
337
+ if (hasMaximum(propSchema)) {
338
+ const strict = hasStrictExclusiveMaximum(propSchema);
339
+ const op = strict ? '>=' : '>';
340
+ const rel = strict ? '<' : '<=';
341
+ lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.maximum}) {`);
342
+ lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.maximum}', path: ${path} })`);
343
+ lines.push(` }`);
344
+ }
345
+ if (hasExclusiveMinimum(propSchema)) {
346
+ lines.push(` if (typeof ${raw} === 'number' && ${raw} <= ${propSchema.exclusiveMinimum}) {`);
347
+ lines.push(` errors.push({ message: 'must be > ${propSchema.exclusiveMinimum}', path: ${path} })`);
348
+ lines.push(` }`);
349
+ }
350
+ if (hasExclusiveMaximum(propSchema)) {
351
+ lines.push(` if (typeof ${raw} === 'number' && ${raw} >= ${propSchema.exclusiveMaximum}) {`);
352
+ lines.push(` errors.push({ message: 'must be < ${propSchema.exclusiveMaximum}', path: ${path} })`);
353
+ lines.push(` }`);
354
+ }
355
+ if (hasMultipleOf(propSchema)) {
356
+ lines.push(` if (typeof ${raw} === 'number' && ${raw} % ${propSchema.multipleOf} !== 0) {`);
357
+ lines.push(` errors.push({ message: 'must be a multiple of ${propSchema.multipleOf}', path: ${path} })`);
358
+ lines.push(` }`);
359
+ }
360
+ }
361
+ // Array with typed items
362
+ if (hasItems(propSchema)) {
363
+ const itemSchema = propSchema.items;
364
+ if (hasRef(itemSchema)) {
365
+ const vName = validatorName(refToName(itemSchema.$ref, suffix));
366
+ lines.push(` if (Array.isArray(${raw})) {`);
367
+ lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
368
+ lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}/\${_i}\`)`);
369
+ lines.push(` if (_ir !== true) errors.push(..._ir.errors)`);
370
+ lines.push(` }`);
371
+ lines.push(` }`);
372
+ }
373
+ else if (hasType(itemSchema)) {
374
+ const itemType = itemSchema.type;
375
+ const itemWrong = wrongTypeCondition('_item', itemType);
376
+ const itemLabel = typeofString(itemType);
377
+ if (itemWrong) {
288
378
  lines.push(` if (Array.isArray(${raw})) {`);
289
379
  lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
290
- lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}/\${_i}\`)`);
291
- lines.push(` if (_ir !== true) errors.push(..._ir.errors)`);
380
+ lines.push(` const _item = ${raw}[_i]`);
381
+ lines.push(` if (${itemWrong}) errors.push({ message: 'items must be ${itemLabel}', path: \`${path.slice(1, -1)}/\${_i}\` })`);
292
382
  lines.push(` }`);
293
383
  lines.push(` }`);
294
384
  }
295
- else if (hasType(itemSchema)) {
296
- const itemType = itemSchema.type;
297
- const itemWrong = wrongTypeCondition('_item', itemType);
298
- const itemLabel = typeofString(itemType);
299
- if (itemWrong) {
300
- lines.push(` if (Array.isArray(${raw})) {`);
301
- lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
302
- lines.push(` const _item = ${raw}[_i]`);
303
- lines.push(` if (${itemWrong}) errors.push({ message: 'items must be ${itemLabel}', path: \`${path.slice(1, -1)}/\${_i}\` })`);
385
+ }
386
+ }
387
+ // Array length / uniqueness. `uniqueItems` dedupes by a JSON projection — exact
388
+ // for primitives (what the type guard also uses); deep-but-key-ordered for
389
+ // objects, the same pragmatic trade-off the rest of the generator makes.
390
+ if (hasMinItems(propSchema) ||
391
+ hasMaxItems(propSchema) ||
392
+ (hasUniqueItems(propSchema) && propSchema.uniqueItems === true) ||
393
+ isSchemaObject(sp['contains']) ||
394
+ Array.isArray(sp['prefixItems'])) {
395
+ if (hasMinItems(propSchema)) {
396
+ lines.push(` if (Array.isArray(${raw}) && ${raw}.length < ${propSchema.minItems}) {`);
397
+ lines.push(` errors.push({ message: 'must have at least ${propSchema.minItems} items', path: ${path} })`);
398
+ lines.push(` }`);
399
+ }
400
+ if (hasMaxItems(propSchema)) {
401
+ lines.push(` if (Array.isArray(${raw}) && ${raw}.length > ${propSchema.maxItems}) {`);
402
+ lines.push(` errors.push({ message: 'must have at most ${propSchema.maxItems} items', path: ${path} })`);
403
+ lines.push(` }`);
404
+ }
405
+ if (hasUniqueItems(propSchema) && propSchema.uniqueItems === true) {
406
+ lines.push(` if (Array.isArray(${raw}) && new Set((${raw} as unknown[]).map((_u) => JSON.stringify(_u))).size !== ${raw}.length) {`);
407
+ lines.push(` errors.push({ message: 'must NOT have duplicate items', path: ${path} })`);
408
+ lines.push(` }`);
409
+ }
410
+ // `contains` — at least `minContains` (default 1) and at most `maxContains`
411
+ // items must match the subschema. `minContains: 0` makes any array (even
412
+ // empty) satisfy the lower bound.
413
+ if (isSchemaObject(sp['contains'])) {
414
+ const min = typeof sp['minContains'] === 'number' ? sp['minContains'] : 1;
415
+ const max = typeof sp['maxContains'] === 'number' ? sp['maxContains'] : undefined;
416
+ const matchExpr = generateMatchesExpr('_c', sp['contains'], suffix, ctx);
417
+ const bound = max !== undefined ? `_cn < ${min} || _cn > ${max}` : `_cn < ${min}`;
418
+ lines.push(` if (Array.isArray(${raw})) {`);
419
+ lines.push(` const _cn = (${raw} as unknown[]).filter((_c) => ${matchExpr}).length`);
420
+ lines.push(` if (${bound}) {`);
421
+ lines.push(` errors.push({ message: 'array does not contain the required matching items', path: ${path} })`);
422
+ lines.push(` }`);
423
+ lines.push(` }`);
424
+ }
425
+ // Tuple `prefixItems` — each position validated against its own subschema; a
426
+ // sibling `items: false` (or draft `additionalItems: false`) caps the length.
427
+ const prefix = sp['prefixItems'];
428
+ if (Array.isArray(prefix)) {
429
+ lines.push(` if (Array.isArray(${raw})) {`);
430
+ for (let i = 0; i < prefix.length; i++) {
431
+ const itemChecks = generateValueChecks('', `${raw}[${i}]`, `\`${path.slice(1, -1)}/${i}\``, prefix[i], suffix, ctx);
432
+ if (itemChecks.length > 0) {
433
+ lines.push(` if (${raw}.length > ${i}) {`);
434
+ lines.push(...itemChecks.map((l) => ` ${l}`));
304
435
  lines.push(` }`);
305
- lines.push(` }`);
306
436
  }
307
437
  }
438
+ if (sp['items'] === false || sp['additionalItems'] === false) {
439
+ lines.push(` if (${raw}.length > ${prefix.length}) {`);
440
+ lines.push(` errors.push({ message: 'must NOT have more than ${prefix.length} items', path: ${path} })`);
441
+ lines.push(` }`);
442
+ }
443
+ lines.push(` }`);
444
+ }
445
+ }
446
+ // Inline nested object — recurse so the nested fields are actually validated.
447
+ // Unconditional: `generateInlineObjectChecks` self-gates (returns `[]` when the
448
+ // schema has no object keywords) and each check is guarded by an `isObject`
449
+ // runtime check, so this is a no-op for non-object schemas.
450
+ lines.push(...generateInlineObjectChecks(key, propSchema, raw, suffix, ctx));
451
+ return lines;
452
+ };
453
+ /**
454
+ * Validates a value located at a *dynamic* key (a `patternProperties` or
455
+ * `additionalProperties` schema value) against `propSchema`. Mirrors the
456
+ * optional-property branch of {@link generatePropertyChecks} — the value is
457
+ * always present, so each check is the same `!== undefined`-guarded form — but
458
+ * `raw` and `path` are caller-supplied expressions (e.g. `obj[_k]` and
459
+ * `` `${_path}/${_k}` ``) so the checks read a runtime key.
460
+ */
461
+ const generateValueChecks = (key, raw, path, propSchema, suffix, ctx) => {
462
+ if (!isSchemaObject(propSchema))
463
+ return [];
464
+ const lines = [];
465
+ if (hasRef(propSchema)) {
466
+ const vName = validatorName(refToName(propSchema.$ref, suffix));
467
+ lines.push(` if (${raw} !== undefined) {`);
468
+ lines.push(` const _r = ${vName}(${raw}, ${path})`);
469
+ lines.push(` if (_r !== true) errors.push(..._r.errors)`);
470
+ lines.push(` }`);
471
+ return lines;
472
+ }
473
+ const instanceOf = getMjstInstanceOf(propSchema);
474
+ if (instanceOf) {
475
+ lines.push(` if (${raw} !== undefined && !(${raw} instanceof ${instanceOf})) {`);
476
+ lines.push(` errors.push({ message: 'must be ${instanceOf}', path: ${path} })`);
477
+ lines.push(` }`);
478
+ return lines;
479
+ }
480
+ const primitive = getMjstPrimitive(propSchema);
481
+ if (primitive) {
482
+ lines.push(` if (${raw} !== undefined && typeof ${raw} !== "${primitive}") {`);
483
+ lines.push(` errors.push({ message: 'must be ${primitive}', path: ${path} })`);
484
+ lines.push(` }`);
485
+ return lines;
486
+ }
487
+ if (hasConst(propSchema)) {
488
+ const mismatch = constMismatchCondition(raw, propSchema.const);
489
+ const msg = JSON.stringify(`must be ${JSON.stringify(propSchema.const)}`);
490
+ lines.push(` if (${raw} !== undefined && ${mismatch}) {`);
491
+ lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
492
+ lines.push(` }`);
493
+ return lines;
494
+ }
495
+ if (hasEnum(propSchema)) {
496
+ const allowed = JSON.stringify(propSchema.enum);
497
+ const label = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
498
+ lines.push(` if (${raw} !== undefined && !(${allowed} as unknown[]).includes(${raw})) {`);
499
+ lines.push(` errors.push({ message: \`must be one of: ${label}\`, path: ${path} })`);
500
+ lines.push(` }`);
501
+ return lines;
502
+ }
503
+ if (hasType(propSchema)) {
504
+ const t = propSchema.type;
505
+ const wrongType = wrongTypeCondition(raw, t);
506
+ const typLabel = typeofString(t);
507
+ if (wrongType) {
508
+ lines.push(` if (${raw} !== undefined && (${wrongType})) {`);
509
+ lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`);
510
+ lines.push(` }`);
308
511
  }
309
- // Inline nested object — recurse so the nested fields are actually
310
- // validated. Without this only the "must be object" shape check above
311
- // runs and everything inside the nested object silently passes.
312
- if (t === 'object') {
313
- lines.push(...generateInlineObjectChecks(key, propSchema, raw, suffix, ctx));
512
+ }
513
+ // Constraint and combinator checks run regardless of a declared `type`: they
514
+ // gate on keyword presence + a runtime-type guard, so a type-less subschema
515
+ // (a combinator branch like `{ required: [...] }` or `{ minItems: 2 }`) is
516
+ // still validated rather than collapsing to "matches everything".
517
+ lines.push(...generateConstraintChecks(key, raw, path, propSchema, suffix, ctx));
518
+ lines.push(...generateCombinatorChecks(key, raw, path, propSchema, suffix, ctx));
519
+ return lines;
520
+ };
521
+ /**
522
+ * A boolean expression that is `true` when `raw` matches `sub`. Reuses the value
523
+ * checks but collects their errors into a throwaway local buffer, so the same
524
+ * logic that produces error messages also answers the yes/no question the
525
+ * combinators (`anyOf`/`oneOf`/`not`/`if`) and `contains` need.
526
+ */
527
+ const generateMatchesExpr = (raw, sub, suffix, ctx) => {
528
+ if (sub === true)
529
+ return 'true';
530
+ if (sub === false)
531
+ return 'false';
532
+ if (!isSchemaObject(sub))
533
+ return 'true';
534
+ const checks = generateValueChecks('', raw, '`${_path}`', sub, suffix, ctx);
535
+ if (checks.length === 0)
536
+ return 'true';
537
+ // The checks push to `errors`; redirect them to the IIFE-local `_m`. The outer
538
+ // validator's `errors.push` → `(errors ??= [])` rewrite never sees these (they
539
+ // are already `_m.push`), and nested match IIFEs each shadow their own `_m`.
540
+ const body = checks.join('\n').replaceAll('errors.push(', '_m.push(');
541
+ return `((): boolean => { const _m: ValidationError[] = []\n${body}\n return _m.length === 0 })()`;
542
+ };
543
+ /**
544
+ * Emits the combinator keywords (`allOf`, `anyOf`, `oneOf`, `not`,
545
+ * `if`/`then`/`else`). `allOf` surfaces each branch's errors directly; the others
546
+ * evaluate branch membership as a boolean via {@link generateMatchesExpr}.
547
+ */
548
+ const generateCombinatorChecks = (key, raw, path, schema, suffix, ctx) => {
549
+ if (!isSchemaObject(schema))
550
+ return [];
551
+ const lines = [];
552
+ if (hasAllOf(schema)) {
553
+ for (const branch of schema.allOf)
554
+ lines.push(...generateValueChecks(key, raw, path, branch, suffix, ctx));
555
+ }
556
+ if (hasAnyOf(schema) && schema.anyOf.length > 0) {
557
+ const conds = schema.anyOf.map((b) => generateMatchesExpr(raw, b, suffix, ctx));
558
+ lines.push(` if (!(${conds.join(' || ')})) {`);
559
+ lines.push(` errors.push({ message: 'must match a schema in anyOf', path: ${path} })`);
560
+ lines.push(` }`);
561
+ }
562
+ if (hasOneOf(schema) && schema.oneOf.length > 0) {
563
+ const conds = schema.oneOf.map((b) => `(${generateMatchesExpr(raw, b, suffix, ctx)} ? 1 : 0)`);
564
+ lines.push(` if ((${conds.join(' + ')}) !== 1) {`);
565
+ lines.push(` errors.push({ message: 'must match exactly one schema in oneOf', path: ${path} })`);
566
+ lines.push(` }`);
567
+ }
568
+ const not = schema['not'];
569
+ if (not !== undefined && (isSchemaObject(not) || typeof not === 'boolean')) {
570
+ const cond = generateMatchesExpr(raw, not, suffix, ctx);
571
+ lines.push(` if (${cond}) {`);
572
+ lines.push(` errors.push({ message: 'must NOT match the schema in not', path: ${path} })`);
573
+ lines.push(` }`);
574
+ }
575
+ const ifSchema = schema['if'];
576
+ if (ifSchema !== undefined && (isSchemaObject(ifSchema) || typeof ifSchema === 'boolean')) {
577
+ const thenSchema = schema['then'];
578
+ const elseSchema = schema['else'];
579
+ const thenLines = thenSchema !== undefined ? generateValueChecks(key, raw, path, thenSchema, suffix, ctx) : [];
580
+ const elseLines = elseSchema !== undefined ? generateValueChecks(key, raw, path, elseSchema, suffix, ctx) : [];
581
+ if (thenLines.length > 0 || elseLines.length > 0) {
582
+ lines.push(` if (${generateMatchesExpr(raw, ifSchema, suffix, ctx)}) {`);
583
+ lines.push(...thenLines);
584
+ lines.push(` } else {`);
585
+ lines.push(...elseLines);
586
+ lines.push(` }`);
587
+ }
588
+ }
589
+ return lines;
590
+ };
591
+ /**
592
+ * Emits validation for `patternProperties` and a schema-form
593
+ * `additionalProperties` (the `false` form is handled by
594
+ * {@link generateStrictKeyChecks}). For each object key, every matching
595
+ * `patternProperties` subschema runs against the value; keys reached by neither
596
+ * `properties` nor any pattern fall through to `additionalProperties`. This
597
+ * mirrors the runtime interpreter, which validates these values rather than only
598
+ * gating extra keys.
599
+ */
600
+ const generatePatternAndAdditionalChecks = (schema, suffix, ctx) => {
601
+ if (!isSchemaObject(schema))
602
+ return [];
603
+ const obj = ctx.objVar;
604
+ const d = ctx.depth;
605
+ const lines = [];
606
+ const patternsRecord = 'patternProperties' in schema && typeof schema.patternProperties === 'object' && schema.patternProperties !== null
607
+ ? schema.patternProperties
608
+ : {};
609
+ const patternEntries = Object.entries(patternsRecord);
610
+ for (const [pattern, sub] of patternEntries) {
611
+ const re = escapeRegexPattern(pattern);
612
+ const kv = `_pk${d}`;
613
+ const valueChecks = generateValueChecks(`\${${kv}}`, `${obj}[${kv}]`, `\`${ctx.pathPrefix}/\${${kv}}\``, sub, suffix, ctx);
614
+ if (valueChecks.length === 0)
615
+ continue;
616
+ lines.push(` for (const ${kv} in ${obj}) {`);
617
+ lines.push(` if (/${re}/.test(${kv})) {`);
618
+ lines.push(...valueChecks.map((line) => ` ${line}`));
619
+ lines.push(` }`);
620
+ lines.push(` }`);
621
+ }
622
+ // Schema-form `additionalProperties` validates every key reached by neither a
623
+ // declared property nor any `patternProperties` regex.
624
+ if (hasAdditionalProperties(schema) && isSchemaObject(schema.additionalProperties)) {
625
+ const additional = schema.additionalProperties;
626
+ const kv = `_ak${d}`;
627
+ const valueChecks = generateValueChecks(`\${${kv}}`, `${obj}[${kv}]`, `\`${ctx.pathPrefix}/\${${kv}}\``, additional, suffix, ctx);
628
+ if (valueChecks.length > 0) {
629
+ const known = Object.keys(hasProperties(schema) ? schema.properties : {});
630
+ lines.push(` for (const ${kv} in ${obj}) {`);
631
+ if (known.length > 0)
632
+ lines.push(` if (${JSON.stringify(known)}.includes(${kv})) continue`);
633
+ for (const pattern of Object.keys(patternsRecord)) {
634
+ lines.push(` if (/${escapeRegexPattern(pattern)}/.test(${kv})) continue`);
635
+ }
636
+ lines.push(...valueChecks.map((line) => ` ${line}`));
637
+ lines.push(` }`);
314
638
  }
315
639
  }
316
640
  return lines;
@@ -337,7 +661,13 @@ const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
337
661
  for (const [childKey, childSchema] of Object.entries(properties)) {
338
662
  innerLines.push(...generatePropertyChecks(childKey, childSchema, required.has(childKey), suffix, child));
339
663
  }
664
+ innerLines.push(...generateMissingRequiredChecks(propSchema, child));
665
+ innerLines.push(...generatePatternAndAdditionalChecks(propSchema, suffix, child));
340
666
  innerLines.push(...generateStrictKeyChecks(propSchema, child));
667
+ innerLines.push(...generateDependentRequiredChecks(propSchema, child));
668
+ if (hasPropertyNames(propSchema) && isSchemaObject(propSchema.propertyNames)) {
669
+ innerLines.push(...generatePropertyNameChecks(propSchema.propertyNames, suffix, child));
670
+ }
341
671
  if (innerLines.length === 0)
342
672
  return [];
343
673
  // The shape check for the property itself already ran (or the property is
@@ -355,10 +685,10 @@ const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
355
685
  * validator). This keeps the generator in step with the interpreter, which runs
356
686
  * the whole subschema against each key — not just the `pattern` form.
357
687
  */
358
- const generatePropertyNameChecks = (nameSchema, suffix) => {
688
+ const generatePropertyNameChecks = (nameSchema, suffix, ctx) => {
359
689
  if (!isSchemaObject(nameSchema))
360
690
  return [];
361
- const at = '`${_path}/${_name}`';
691
+ const at = `\`${ctx.pathPrefix}/\${_name}\``;
362
692
  const checks = [];
363
693
  if (hasRef(nameSchema)) {
364
694
  const vName = validatorName(refToName(nameSchema.$ref, suffix));
@@ -392,7 +722,30 @@ const generatePropertyNameChecks = (nameSchema, suffix) => {
392
722
  }
393
723
  if (checks.length === 0)
394
724
  return [];
395
- return [` for (const _name of Object.keys(obj)) {`, ...checks, ` }`];
725
+ return [` for (const _name of Object.keys(${ctx.objVar})) {`, ...checks, ` }`];
726
+ };
727
+ /**
728
+ * Emits `dependentRequired` checks: when a trigger key is present, each of its
729
+ * declared dependencies must be present too. Reads the object and reports at the
730
+ * current node via `ctx`, so it works at the root and inside nested objects.
731
+ */
732
+ const generateDependentRequiredChecks = (schema, ctx) => {
733
+ if (!isSchemaObject(schema) || !hasDependentRequired(schema))
734
+ return [];
735
+ const obj = ctx.objVar;
736
+ const at = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
737
+ const lines = [];
738
+ for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
739
+ if (!Array.isArray(deps))
740
+ continue;
741
+ for (const dep of deps) {
742
+ const msg = JSON.stringify(`must have property '${dep}' when '${trigger}' is present`);
743
+ lines.push(` if (${JSON.stringify(trigger)} in ${obj} && !(${JSON.stringify(dep)} in ${obj})) {`);
744
+ lines.push(` errors.push({ message: ${msg}, path: ${at} })`);
745
+ lines.push(` }`);
746
+ }
747
+ }
748
+ return lines;
396
749
  };
397
750
  /**
398
751
  * Builds the `&&` conditions that prove a single property is valid, or `null`
@@ -414,6 +767,10 @@ const guardPropConditions = (key, propSchema, objAcc) => {
414
767
  hasEnum(propSchema) ||
415
768
  hasConst(propSchema) ||
416
769
  hasOneOf(propSchema) ||
770
+ hasAnyOf(propSchema) ||
771
+ hasAllOf(propSchema) ||
772
+ 'not' in propSchema ||
773
+ 'if' in propSchema ||
417
774
  getMjstInstanceOf(propSchema) !== undefined ||
418
775
  getMjstPrimitive(propSchema) !== undefined ||
419
776
  hasPattern(propSchema) ||
@@ -432,13 +789,14 @@ const guardPropConditions = (key, propSchema, objAcc) => {
432
789
  switch (propSchema.type) {
433
790
  case 'string':
434
791
  return [`typeof ${raw} === 'string'`];
435
- // mjst treats `integer` like `number` (it never enforces integrality), so a
436
- // `typeof === 'number'` guard matches the slow path's verdict exactly.
437
792
  case 'number':
438
- case 'integer':
439
793
  return [`typeof ${raw} === 'number'`];
794
+ case 'integer':
795
+ return [`typeof ${raw} === 'number'`, `Number.isInteger(${raw})`];
440
796
  case 'boolean':
441
797
  return [`typeof ${raw} === 'boolean'`];
798
+ case 'null':
799
+ return [`${raw} === null`];
442
800
  case 'object':
443
801
  // Member access into the nested record is only reached after the shape
444
802
  // check ahead of it in the `&&` chain, so the cast is always safe.
@@ -509,6 +867,12 @@ const guardObjectConditions = (schema, raw, objAcc) => {
509
867
  const required = new Set(hasRequired(schema) ? schema.required : []);
510
868
  const properties = hasProperties(schema) ? schema.properties : {};
511
869
  const keys = Object.keys(properties);
870
+ // A required key with no `properties` entry has no cheap guard condition, so
871
+ // defer to the slow path (which checks its presence).
872
+ for (const key of required) {
873
+ if (!Object.hasOwn(properties, key))
874
+ return null;
875
+ }
512
876
  // The object shape-check only needs `!Array.isArray` when no required field
513
877
  // check would already reject an array (see `arrayRejectedByRequiredProp`).
514
878
  const arrayCheck = arrayRejectedByRequiredProp(keys, required, properties) ? '' : ` && !Array.isArray(${raw})`;
@@ -549,37 +913,23 @@ const generateObjectValidator = (schema, typeName, suffix) => {
549
913
  propertyLines.push(...checks);
550
914
  }
551
915
  }
552
- // additionalProperties with a $ref schema validates all extra keys
553
- if (hasAdditionalProperties(schema) &&
554
- isSchemaObject(schema.additionalProperties) &&
555
- hasRef(schema.additionalProperties)) {
556
- const vRefName = validatorName(refToName(schema.additionalProperties.$ref, suffix));
557
- propertyLines.push(` for (const _key of Object.keys(obj)) {`);
558
- propertyLines.push(` if (${JSON.stringify(Object.keys(properties))}.includes(_key)) continue`);
559
- propertyLines.push(` const _r = ${vRefName}(obj[_key as keyof typeof obj], \`\${_path}/\${_key}\`)`);
560
- propertyLines.push(` if (_r !== true) errors.push(..._r.errors)`);
561
- propertyLines.push(` }`);
562
- }
916
+ // Required keys with no `properties` entry still need a presence check.
917
+ propertyLines.push(...generateMissingRequiredChecks(schema, ctx));
918
+ // patternProperties values and a schema-form additionalProperties are
919
+ // validated here (the `false` form is handled by generateStrictKeyChecks).
920
+ propertyLines.push(...generatePatternAndAdditionalChecks(schema, suffix, ctx));
563
921
  // additionalProperties: false rejects every key not declared in properties
564
922
  propertyLines.push(...generateStrictKeyChecks(schema, ctx));
565
923
  // dependentRequired — when a trigger property is present, its dependencies must be too.
566
- if (hasDependentRequired(schema)) {
567
- for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
568
- if (!Array.isArray(deps))
569
- continue;
570
- for (const dep of deps) {
571
- const msg = JSON.stringify(`must have property '${dep}' when '${trigger}' is present`);
572
- propertyLines.push(` if (${JSON.stringify(trigger)} in obj && !(${JSON.stringify(dep)} in obj)) {`);
573
- propertyLines.push(` errors.push({ message: ${msg}, path: _path })`);
574
- propertyLines.push(` }`);
575
- }
576
- }
577
- }
924
+ propertyLines.push(...generateDependentRequiredChecks(schema, ctx));
578
925
  // propertyNames — every key (always a string) must satisfy the subschema. This
579
926
  // mirrors the interpreter, which runs the full subschema against each key.
580
927
  if (hasPropertyNames(schema) && isSchemaObject(schema.propertyNames)) {
581
- propertyLines.push(...generatePropertyNameChecks(schema.propertyNames, suffix));
928
+ propertyLines.push(...generatePropertyNameChecks(schema.propertyNames, suffix, ctx));
582
929
  }
930
+ // Combinators declared alongside the object's properties (e.g. an object with
931
+ // `allOf` refining it further) are validated against the object value itself.
932
+ propertyLines.push(...generateCombinatorChecks('', 'obj', '`${_path}`', schema, suffix, ctx));
583
933
  // Lazily allocate the errors array so a valid input never builds one — the same
584
934
  // allocation-free happy path the runtime interpreter uses. Each emitted
585
935
  // `errors.push(...)` becomes a create-on-first-use push; nothing is allocated
@@ -654,12 +1004,15 @@ const rightTypeCondition = (accessor, type) => {
654
1004
  case 'string':
655
1005
  return `typeof ${accessor} === 'string'`;
656
1006
  case 'number':
657
- case 'integer':
658
1007
  return `typeof ${accessor} === 'number'`;
1008
+ case 'integer':
1009
+ return `typeof ${accessor} === 'number' && Number.isInteger(${accessor})`;
659
1010
  case 'boolean':
660
1011
  return `typeof ${accessor} === 'boolean'`;
661
1012
  case 'array':
662
1013
  return `Array.isArray(${accessor})`;
1014
+ case 'null':
1015
+ return `${accessor} === null`;
663
1016
  case 'object':
664
1017
  return `typeof ${accessor} === 'object' && ${accessor} !== null && !Array.isArray(${accessor})`;
665
1018
  default:
@@ -702,6 +1055,9 @@ const booleanLeafExpr = (schema, acc) => {
702
1055
  'anyOf' in schema ||
703
1056
  'allOf' in schema ||
704
1057
  'not' in schema ||
1058
+ 'if' in schema ||
1059
+ 'contains' in schema ||
1060
+ 'prefixItems' in schema ||
705
1061
  getMjstInstanceOf(schema) !== undefined ||
706
1062
  getMjstPrimitive(schema) !== undefined) {
707
1063
  return null;
@@ -731,6 +1087,8 @@ const booleanLeafExpr = (schema, acc) => {
731
1087
  case 'number':
732
1088
  case 'integer': {
733
1089
  const parts = [`typeof ${acc} === 'number'`];
1090
+ if (t === 'integer')
1091
+ parts.push(`Number.isInteger(${acc})`);
734
1092
  if (hasMinimum(schema))
735
1093
  parts.push(`!(${acc} ${hasStrictExclusiveMinimum(schema) ? '<=' : '<'} ${schema.minimum})`);
736
1094
  if (hasMaximum(schema))
@@ -745,6 +1103,8 @@ const booleanLeafExpr = (schema, acc) => {
745
1103
  }
746
1104
  case 'boolean':
747
1105
  return `typeof ${acc} === 'boolean'`;
1106
+ case 'null':
1107
+ return `${acc} === null`;
748
1108
  case 'object': {
749
1109
  const parts = booleanObjectParts(schema, acc, `(${acc} as Record<string, unknown>)`);
750
1110
  return parts === null ? null : parts.join(' && ');
@@ -768,7 +1128,17 @@ const booleanLeafExpr = (schema, acc) => {
768
1128
  * on sparse input — the guard must never accept what the slow path would reject.
769
1129
  */
770
1130
  const booleanArrayExpr = (schema, acc) => {
771
- const base = `Array.isArray(${acc})`;
1131
+ const parts = [`Array.isArray(${acc})`];
1132
+ // Length / uniqueness, mirroring the validator's checks exactly so the guard's
1133
+ // verdict matches the slow path's.
1134
+ if (hasMinItems(schema))
1135
+ parts.push(`${acc}.length >= ${schema.minItems}`);
1136
+ if (hasMaxItems(schema))
1137
+ parts.push(`${acc}.length <= ${schema.maxItems}`);
1138
+ if (hasUniqueItems(schema) && schema.uniqueItems === true) {
1139
+ parts.push(`new Set((${acc} as unknown[]).map((_u) => JSON.stringify(_u))).size === ${acc}.length`);
1140
+ }
1141
+ const base = parts.join(' && ');
772
1142
  if (!hasItems(schema))
773
1143
  return base;
774
1144
  const items = schema.items;
@@ -942,20 +1312,18 @@ const generateScalarValidator = (schema, typeName, suffix) => {
942
1312
  ].join('\n');
943
1313
  }
944
1314
  // oneOf — try each branch, return errors from all if none match
945
- if (hasOneOf(schema)) {
946
- const branches = schema.oneOf
947
- .map((branch, i) => {
948
- if (!hasRef(branch))
949
- return null;
950
- const bName = validatorName(refToName(branch.$ref, suffix));
951
- return ` const _r${i} = ${bName}(input, _path)\n if (_r${i} === true) return true`;
952
- })
953
- .filter(Boolean)
954
- .join('\n');
1315
+ // Top-level combinators (`allOf` / `anyOf` / `oneOf` / `not` / `if`), validated
1316
+ // against the input via the shared combinator generator — correct `oneOf`
1317
+ // (exactly one) and inline branches included, not just `$ref` branches.
1318
+ if (hasAllOf(schema) || hasAnyOf(schema) || hasOneOf(schema) || 'not' in schema || 'if' in schema) {
1319
+ const ctx = createRootContext();
1320
+ const checks = generateCombinatorChecks('', 'input', '`${_path}`', schema, suffix, ctx);
1321
+ const body = checks.join('\n').replaceAll('errors.push(', '(errors ??= []).push(');
955
1322
  return [
956
1323
  `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
957
- branches,
958
- ` return { valid: false, errors: [{ message: 'must match one of the expected schemas', path: _path }] }`,
1324
+ ` let errors: ValidationError[] | undefined`,
1325
+ body,
1326
+ ` return errors !== undefined ? { valid: false, errors } : true`,
959
1327
  `}`,
960
1328
  ].join('\n');
961
1329
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-validators",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "description": "Generate TypeScript validation functions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "json-schema-typed": "^8.0.1",
49
- "@amritk/helpers": "0.10.0"
49
+ "@amritk/helpers": "0.10.1"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@ryoppippi/unplugin-typia": "^2.6.5",