@amritk/generate-parsers 0.12.2 → 0.13.0

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.
@@ -1,10 +1,14 @@
1
1
  import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
2
  import { refToName } from '@amritk/helpers/ref-to-name';
3
+ import { resolveRef } from '@amritk/helpers/resolve-ref';
3
4
  import { safeAccessor, safeKey } from '@amritk/helpers/safe-accessor';
4
- import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasType, hasUniqueItems, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
5
+ import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasItems, hasOneOf, hasProperties, hasRef, hasType, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
5
6
  import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
7
+ import { findDiscriminator } from '../helpers/find-discriminator.js';
6
8
  import { getDefaultValue } from '../helpers/get-default-value.js';
9
+ import { getDiscriminatorValue } from '../helpers/get-discriminator-value.js';
7
10
  import { generateObjectStrictAssertion, generateScalarStrictAssertion } from './generate-strict-assertion.js';
11
+ import { canEnforceUnion, generatePropertyTypeCheck, generateUnionCheck, getUnionBranches, shapeValidatorName, } from './generate-type-checks.js';
8
12
  import { generateValidationExpression, scalarItemTypeCheck } from './generate-validation-expression.js';
9
13
  /**
10
14
  * Generates the parser function name from a type name.
@@ -41,7 +45,10 @@ const generateRequiredRefCall = (key, ref, suffix) => {
41
45
  const generateOptionalRefCall = (key, ref, suffix) => {
42
46
  const acc = safeAccessor('input', key);
43
47
  const parserName = generateParserName(refToName(ref, suffix));
44
- return `...(${acc} && { ${safeKey(key)}: ${parserName}(${acc}) })`;
48
+ // Gate on presence, not truthiness: a `&&` guard skips parsing when the value
49
+ // is `false`/`0`/`""`/`null`, spreading it raw. `!== undefined` still omits an
50
+ // absent optional property but coerces every present one, matching the main path.
51
+ return `...(${acc} !== undefined && { ${safeKey(key)}: ${parserName}(${acc}) })`;
45
52
  };
46
53
  /**
47
54
  * Generates a validateArray call for required array properties with $ref items.
@@ -58,7 +65,8 @@ const generateRequiredArrayRefCall = (key, ref, suffix) => {
58
65
  const generateOptionalArrayRefCall = (key, ref, suffix) => {
59
66
  const parserName = generateParserName(refToName(ref, suffix));
60
67
  const acc = safeAccessor('input', key);
61
- return `...(${acc} && { ${safeKey(key)}: validateArray(${acc}, ${parserName}) })`;
68
+ // Presence gating (not truthiness) so a falsy-but-present value is still parsed.
69
+ return `...(${acc} !== undefined && { ${safeKey(key)}: validateArray(${acc}, ${parserName}) })`;
62
70
  };
63
71
  /**
64
72
  * Generates a validateRecord call for required object properties with additionalProperties $ref.
@@ -76,7 +84,8 @@ const generateRequiredRecordRefCall = (key, ref, suffix) => {
76
84
  const generateOptionalRecordRefCall = (key, ref, suffix) => {
77
85
  const acc = safeAccessor('input', key);
78
86
  const parserName = generateParserName(refToName(ref, suffix));
79
- return `...(${acc} && { ${safeKey(key)}: validateRecord(${acc}, ${parserName}) })`;
87
+ // Presence gating (not truthiness) so a falsy-but-present value is still parsed.
88
+ return `...(${acc} !== undefined && { ${safeKey(key)}: validateRecord(${acc}, ${parserName}) })`;
80
89
  };
81
90
  /**
82
91
  * Generates a spread entry for optional inline properties.
@@ -296,8 +305,99 @@ const scalarDefaultLiteral = (schema) => {
296
305
  }
297
306
  return '{}';
298
307
  };
299
- const generateNonObjectParser = (typeName, schema, strict) => {
308
+ /**
309
+ * Emits a dispatcher for a top-level `oneOf`/`anyOf` whose branches are `$ref`s,
310
+ * when the branches share a discriminator (a `const`/`enum` tag such as `kind`).
311
+ * Each resolved branch's discriminant selects its imported parser, so a recursive
312
+ * discriminated union (e.g. `Expr = Lit | BinOp`, where `BinOp` has `Expr`
313
+ * children) is actually validated and dispatched instead of blindly cast. Returns
314
+ * `null` when it can't be done (no root schema, no discriminator, a non-ref
315
+ * branch, or ref imports disabled), so the caller keeps its passthrough fallback.
316
+ */
317
+ const generateRefUnionDispatch = (functionName, typeName, branches, strict, ctx) => {
318
+ const rootSchema = ctx.rootSchema;
319
+ if (!ctx.useRefImports || rootSchema === undefined)
320
+ return null;
321
+ if (branches.length === 0 || !branches.every((b) => isSchemaObject(b) && hasRef(b)))
322
+ return null;
323
+ // Resolve each branch ref so we can read its discriminant tag and derive its
324
+ // imported parser name.
325
+ const resolved = branches.map((b) => {
326
+ const ref = b.$ref;
327
+ return { parser: generateParserName(refToName(ref, ctx.suffix)), schema: resolveRef(ref, rootSchema) };
328
+ });
329
+ if (resolved.some((r) => !isSchemaObject(r.schema)))
330
+ return null;
331
+ const discriminator = findDiscriminator(resolved.map((r) => r.schema));
332
+ if (discriminator === null)
333
+ return null;
334
+ const cases = [];
335
+ for (const r of resolved) {
336
+ const value = getDiscriminatorValue(r.schema, discriminator);
337
+ if (value === null)
338
+ return null;
339
+ cases.push({ value, parser: r.parser });
340
+ }
341
+ // Read the discriminant safely: `null`/`undefined` is guarded (a property read
342
+ // would throw), and a non-object primitive yields `undefined` (no branch matches).
343
+ const access = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(discriminator)
344
+ ? `.${discriminator}`
345
+ : `[${JSON.stringify(discriminator)}]`;
346
+ const discExpr = `input == null ? undefined : (input as Record<string, unknown>)${access}`;
347
+ // Fold branches into a nested ternary in declaration order:
348
+ // `_disc === "lit" ? parseLit(input) : _disc === "binop" ? parseBinop(input) : <fallback>`.
349
+ // Unmatched: strict throws (not a member of the union); non-strict coerces via the
350
+ // first branch's parser, preserving the coercing parser's lenient contract while
351
+ // still running real validation/coercion instead of a blind cast.
352
+ const fallback = strict
353
+ ? `(() => { throw new Error(${JSON.stringify(`[${typeName}] value does not match any union branch`)}); })()`
354
+ : `${cases[0]?.parser}(input)`;
355
+ let expr = fallback;
356
+ for (let i = cases.length - 1; i >= 0; i--) {
357
+ const c = cases[i];
358
+ if (c)
359
+ expr = `_disc === ${JSON.stringify(c.value)} ? ${c.parser}(input) : ${expr}`;
360
+ }
361
+ return `export const ${functionName} = (input: unknown): ${typeName} => {\n const _disc = ${discExpr};\n return ${expr};\n};`;
362
+ };
363
+ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
300
364
  const functionName = generateParserName(typeName);
365
+ // A ref-branch discriminated union dispatches to its branch parsers in *both*
366
+ // modes (strict throws on an unmatched discriminant; non-strict coerces via the
367
+ // first branch). This runs before the `!strict` coercion block below so a strict
368
+ // recursive union is validated instead of blindly cast.
369
+ if (unionCtx && isSchemaObject(schema) && (hasOneOf(schema) || hasAnyOf(schema))) {
370
+ const branches = hasOneOf(schema) ? schema.oneOf : hasAnyOf(schema) ? schema.anyOf : [];
371
+ if (branches.length > 0 && branches.every((b) => isSchemaObject(b) && hasRef(b))) {
372
+ const dispatch = generateRefUnionDispatch(functionName, typeName, branches, strict, unionCtx);
373
+ if (dispatch !== null)
374
+ return dispatch;
375
+ }
376
+ }
377
+ // An alias definition (a bare `$ref` with no shape of its own, e.g. a root
378
+ // schema that is just `$ref: '#/$defs/expr'`) delegates to the referenced
379
+ // parser instead of blindly casting, in both modes. Guarded against
380
+ // self-reference: delegating to ourselves would recurse forever.
381
+ if (unionCtx?.useRefImports && isSchemaObject(schema) && hasRef(schema) && !hasProperties(schema)) {
382
+ const refName = refToName(schema.$ref, unionCtx.suffix);
383
+ if (refName !== typeName) {
384
+ return `export const ${functionName} = (input: unknown): ${typeName} => ${generateParserName(refName)}(input) as ${typeName};`;
385
+ }
386
+ }
387
+ // Strict union enforcement for inline (or mixed) branches: membership is the
388
+ // disjunction of per-branch checks, and a non-member throws. Only emitted
389
+ // when every branch check is false-sound (see canEnforceUnion) — otherwise a
390
+ // conservative stub validator somewhere in the graph could reject valid
391
+ // input — and never when stripping unknown keys (see UnionParserContext).
392
+ if (strict && unionCtx && isSchemaObject(schema)) {
393
+ const branches = getUnionBranches(schema);
394
+ if (branches && !unionCtx.stripUnknown && canEnforceUnion(branches, unionCtx.rootSchema)) {
395
+ const check = generateUnionCheck('input', branches, unionCtx.useRefImports, unionCtx.suffix);
396
+ if (check !== null) {
397
+ return `export const ${functionName} = (input: unknown): ${typeName} => {\n if (!(${check})) throw new Error(${JSON.stringify(`[${typeName}] value does not match any union branch`)});\n return input as ${typeName};\n};`;
398
+ }
399
+ }
400
+ }
301
401
  // `const`/`enum` define the value space directly (the generated type is a
302
402
  // literal / literal-union), so a non-member must coerce to a valid member —
303
403
  // otherwise the returned value would not be of the declared type. This runs
@@ -322,10 +422,7 @@ const generateNonObjectParser = (typeName, schema, strict) => {
322
422
  }
323
423
  // A top-level union must validate membership: an unmatched value is not of
324
424
  // the declared union type, so coerce it to a member-shaped default. Reuse the
325
- // same union validation the property path uses. A `$ref` branch can't be
326
- // validated inline here (the membership test would silently drop it and
327
- // wrongly coerce a valid ref-shaped value), so fall through to a passthrough
328
- // cast rather than discarding valid input.
425
+ // same union validation the property path uses.
329
426
  if (hasOneOf(schema) || hasAnyOf(schema)) {
330
427
  const branches = hasOneOf(schema) ? schema.oneOf : hasAnyOf(schema) ? schema.anyOf : [];
331
428
  const hasRefBranch = branches.some((b) => isSchemaObject(b) && hasRef(b));
@@ -334,6 +431,9 @@ const generateNonObjectParser = (typeName, schema, strict) => {
334
431
  const expr = generateValidationExpression('', schema, fallback, true, undefined, undefined, 'input', true);
335
432
  return `export const ${functionName} = (input: unknown): ${typeName} => (${expr}) as ${typeName};`;
336
433
  }
434
+ // Ref-branch union: a *discriminated* one was already dispatched above (both
435
+ // modes). Anything reaching here is non-discriminated, which can't be validated
436
+ // inline without risking dropping valid input — fall back to a passthrough cast.
337
437
  return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`;
338
438
  }
339
439
  }
@@ -356,8 +456,10 @@ const generateNonObjectParser = (typeName, schema, strict) => {
356
456
  case 'string':
357
457
  return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "string" ? input as ${typeName} : "" as ${typeName};`;
358
458
  case 'number':
359
- case 'integer':
360
459
  return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "number" ? input as ${typeName} : 0 as ${typeName};`;
460
+ case 'integer':
461
+ // `integer` rejects non-integral numbers; a bare typeof would accept `1.5`.
462
+ return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "number" && Number.isInteger(input) ? input as ${typeName} : 0 as ${typeName};`;
361
463
  case 'boolean':
362
464
  return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "boolean" ? input as ${typeName} : false as ${typeName};`;
363
465
  case 'array': {
@@ -393,12 +495,6 @@ const toVarName = (key) => {
393
495
  const safe = key.replace(/[^a-zA-Z0-9_$]/g, '_');
394
496
  return `_${safe}`;
395
497
  };
396
- /**
397
- * Returns the shape-predicate function name for a given type name.
398
- * The predicate is generated alongside each parser and tells callers
399
- * whether `input` is already in the shape produced by the parser's fast path.
400
- */
401
- const shapeValidatorName = (typeName) => `validate${typeName}Shape`;
402
498
  /**
403
499
  * Matches an inline nested object property — an object schema written directly
404
500
  * under `properties` with its own `properties`, rather than referenced via
@@ -466,107 +562,6 @@ const hasStrictKeys = (schema) => {
466
562
  return false;
467
563
  return true;
468
564
  };
469
- /**
470
- * Generates a fast-path type check expression for a property.
471
- * Returns null if the schema is too complex for a simple type check
472
- * (e.g. has unions, enums that require more elaborate validation).
473
- *
474
- * When `useRefImports` is true, $ref properties (and arrays of $refs) are
475
- * checked by calling the imported shape predicate, allowing parent parsers
476
- * to fast-path through nested ref types without recursing into their parsers.
477
- */
478
- const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix) => {
479
- if (!isSchemaObject(schema))
480
- return null;
481
- // $ref via shape predicate (deep fast-path through nested types).
482
- if (useRefImports && hasRef(schema)) {
483
- const refName = refToName(schema.$ref, suffix);
484
- return `${shapeValidatorName(refName)}(${varName})`;
485
- }
486
- // Skip fast path for complex schemas that need more elaborate validation
487
- if (hasEnum(schema) ||
488
- hasOneOf(schema) ||
489
- hasAnyOf(schema) ||
490
- hasAllOf(schema) ||
491
- hasRef(schema) ||
492
- 'not' in schema) {
493
- return null;
494
- }
495
- if (!hasType(schema))
496
- return null;
497
- // Array of $refs: each item must satisfy the imported shape predicate.
498
- if (useRefImports &&
499
- schema.type === 'array' &&
500
- hasItems(schema) &&
501
- isSchemaObject(schema.items) &&
502
- hasRef(schema.items)) {
503
- const refName = refToName(schema.items.$ref, suffix);
504
- return `Array.isArray(${varName}) && ${varName}.every(${shapeValidatorName(refName)})`;
505
- }
506
- const checks = [];
507
- switch (schema.type) {
508
- case 'string': {
509
- checks.push(`typeof ${varName} === "string"`);
510
- if (hasPattern(schema))
511
- checks.push(`/${escapeRegexPattern(schema.pattern)}/.test(${varName})`);
512
- if (hasMinLength(schema))
513
- checks.push(`${varName}.length >= ${schema.minLength}`);
514
- if (hasMaxLength(schema))
515
- checks.push(`${varName}.length <= ${schema.maxLength}`);
516
- break;
517
- }
518
- case 'number':
519
- case 'integer': {
520
- checks.push(`typeof ${varName} === "number"`);
521
- if (hasMinimum(schema))
522
- checks.push(`${varName} >= ${schema.minimum}`);
523
- if (hasMaximum(schema))
524
- checks.push(`${varName} <= ${schema.maximum}`);
525
- if (hasExclusiveMinimum(schema))
526
- checks.push(`${varName} > ${schema.exclusiveMinimum}`);
527
- if (hasExclusiveMaximum(schema))
528
- checks.push(`${varName} < ${schema.exclusiveMaximum}`);
529
- if (hasMultipleOf(schema))
530
- checks.push(`${varName} % ${schema.multipleOf} === 0`);
531
- break;
532
- }
533
- case 'boolean':
534
- checks.push(`typeof ${varName} === "boolean"`);
535
- break;
536
- case 'array': {
537
- checks.push(`Array.isArray(${varName})`);
538
- if (hasMinItems(schema))
539
- checks.push(`${varName}.length >= ${schema.minItems}`);
540
- if (hasMaxItems(schema))
541
- checks.push(`${varName}.length <= ${schema.maxItems}`);
542
- if (hasUniqueItems(schema) && schema.uniqueItems === true) {
543
- checks.push(`new Set(${varName}).size === ${varName}.length`);
544
- }
545
- // For a scalar item type, every element must already be well-typed to take
546
- // the fast path; a mistyped element routes the array to the slow path where
547
- // each element is coerced.
548
- if (hasItems(schema) && !Array.isArray(schema.items)) {
549
- const itemCheck = scalarItemTypeCheck(schema.items, '_it');
550
- if (itemCheck)
551
- checks.push(`${varName}.every((_it) => ${itemCheck})`);
552
- }
553
- break;
554
- }
555
- case 'object':
556
- checks.push(`isObject(${varName})`);
557
- break;
558
- default:
559
- return null;
560
- }
561
- if (checks.length === 0)
562
- return null;
563
- // checks[0] is safe: we guard with checks.length === 0 above
564
- let result = checks[0];
565
- for (let i = 1; i < checks.length; i++) {
566
- result += ' && ' + checks[i];
567
- }
568
- return result;
569
- };
570
565
  /**
571
566
  * Determines if a property needs a local variable or can be inlined.
572
567
  * Schema-object properties are always cached: the slow-path ternary chain
@@ -587,7 +582,7 @@ const shouldCacheVariable = (propSchema, _canFastPath, _useRefImports) => {
587
582
  * - Fast path that returns input directly when all properties are already valid
588
583
  * - Slow path with optimized expressions
589
584
  */
590
- const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false) => {
585
+ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false, rootSchema) => {
591
586
  const functionName = generateParserName(typeName);
592
587
  const exportPrefix = exported ? 'export ' : '';
593
588
  if (!hasProperties(schema)) {
@@ -607,7 +602,7 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
607
602
  const propSchema = schema.properties[key];
608
603
  preamble.push(`type ${subName} = ${typeName}[${JSON.stringify(key)}];`);
609
604
  preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false, stripUnknown));
610
- preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown));
605
+ preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema));
611
606
  }
612
607
  // additionalProperties: false — the per-key "is this undeclared" test inlines
613
608
  // `!==` comparisons for a short key list and hoists a Set only for a long one
@@ -641,9 +636,17 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
641
636
  schema.allOf.some((entry) => isSchemaObject(entry) && hasRef(entry));
642
637
  let canFastPath = !hasAllOfRefParsers;
643
638
  const fastPathChecks = [];
639
+ // `toVarName` collapses distinct keys that differ only in non-identifier chars
640
+ // (e.g. `a-b` and `a.b` both → `_a_b`), which would emit two `const _a_b`
641
+ // declarations (TS2451). Dedupe by suffixing `_` until unique — the same
642
+ // approach `collectInlineObjectProperties` uses — so each key gets its own var.
643
+ const usedVarNames = new Set();
644
644
  for (const [key, propSchema] of properties) {
645
645
  const isRequired = isPropertyRequired(key, schema);
646
- const varName = toVarName(key);
646
+ let varName = toVarName(key);
647
+ while (usedVarNames.has(varName))
648
+ varName = `${varName}_`;
649
+ usedVarNames.add(varName);
647
650
  propInfo.push({ key, varName, isRequired, propSchema });
648
651
  if (!canFastPath)
649
652
  continue;
@@ -897,7 +900,12 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
897
900
  lines.push(...varDeclLines);
898
901
  lines.push(...warnLines);
899
902
  // The first assertion line is the `isObject` check, already done above.
900
- const assertionLines = generateObjectStrictAssertion(schema, typeName).slice(1);
903
+ const assertionLines = generateObjectStrictAssertion(schema, typeName, {
904
+ useRefImports,
905
+ suffix,
906
+ stripUnknown,
907
+ ...(rootSchema !== undefined ? { rootSchema } : {}),
908
+ }).slice(1);
901
909
  if (shallowGuard) {
902
910
  // stripUnknown: a well-typed input skips the assertions and goes straight
903
911
  // to the strip build (which removes extras and recurses into sub-parsers).
@@ -1335,7 +1343,12 @@ const selectParserStrategy = (schema, typeName, options) => {
1335
1343
  (isSchemaObject(schema) && ('patternProperties' in schema || 'additionalProperties' in schema));
1336
1344
  // Handle non-object schemas with type-appropriate validation
1337
1345
  if (!isObjectLikeSchema && !isSchemaObject(schema)) {
1338
- return generateNonObjectParser(typeName, schema, strict);
1346
+ return generateNonObjectParser(typeName, schema, strict, {
1347
+ useRefImports,
1348
+ suffix,
1349
+ stripUnknown,
1350
+ ...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
1351
+ });
1339
1352
  }
1340
1353
  // Handle schemas with both properties AND patternProperties.
1341
1354
  // This generates a parser that handles known properties and also iterates
@@ -1348,7 +1361,7 @@ const selectParserStrategy = (schema, typeName, options) => {
1348
1361
  // properties AND conditional keywords use all declared properties rather than
1349
1362
  // only the if/then fragment.
1350
1363
  if (hasProperties(schema)) {
1351
- return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
1364
+ return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, options?.rootSchema);
1352
1365
  }
1353
1366
  // Handle conditional schemas (if/then/else) for schemas without explicit properties.
1354
1367
  if (isSchemaObject(schema) && 'if' in schema && 'then' in schema && 'else' in schema) {
@@ -1362,7 +1375,12 @@ const selectParserStrategy = (schema, typeName, options) => {
1362
1375
  }
1363
1376
  // Handle non-object schemas with type-appropriate validation (no properties, no conditionals)
1364
1377
  if (!isObjectLikeSchema) {
1365
- return generateNonObjectParser(typeName, schema, strict);
1378
+ return generateNonObjectParser(typeName, schema, strict, {
1379
+ useRefImports,
1380
+ suffix,
1381
+ stripUnknown,
1382
+ ...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
1383
+ });
1366
1384
  }
1367
1385
  // Handle schemas with patternProperties (but no properties)
1368
1386
  if ('patternProperties' in schema) {
@@ -1421,6 +1439,31 @@ export const generateShapeValidator = (schema, typeName, useRefImports, suffix =
1421
1439
  const stub = `${exportPrefix}const ${fnName} = (_input: unknown): boolean => false;`;
1422
1440
  if (!isSchemaObject(schema))
1423
1441
  return stub;
1442
+ // An alias definition (a bare `$ref` with no shape of its own) delegates to
1443
+ // the referenced type's validator. Guarded against self-reference.
1444
+ if (useRefImports && hasRef(schema) && !hasProperties(schema)) {
1445
+ const refName = refToName(schema.$ref, suffix);
1446
+ if (refName !== typeName) {
1447
+ return `${exportPrefix}const ${fnName} = (input: unknown): boolean => ${shapeValidatorName(refName)}(input);`;
1448
+ }
1449
+ return stub;
1450
+ }
1451
+ // A pure union definition (file-level oneOf/anyOf without properties) gets a
1452
+ // real membership predicate when every branch is checkable. A recursive
1453
+ // union (e.g. `expr` whose branches $ref `expr` itself) works because the
1454
+ // branch checks call this very validator by name at runtime. Skipped under
1455
+ // stripUnknown: the branch checks carry no known-keys terms, so a `true`
1456
+ // could not guarantee the parser's strip build would be a no-op.
1457
+ if (!hasProperties(schema) && !('patternProperties' in schema) && !('if' in schema) && !stripUnknown) {
1458
+ const branches = getUnionBranches(schema);
1459
+ if (branches) {
1460
+ const check = generateUnionCheck('input', branches, useRefImports, suffix);
1461
+ if (check !== null) {
1462
+ return `${exportPrefix}const ${fnName} = (input: unknown): boolean => ${check};`;
1463
+ }
1464
+ }
1465
+ return stub;
1466
+ }
1424
1467
  if (!hasProperties(schema))
1425
1468
  return stub;
1426
1469
  // Composition / conditional schemas can match in many shapes — bail.
@@ -1,5 +1,6 @@
1
1
  import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
2
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
3
+ import { multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
3
4
  import { hasAdditionalProperties, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasPattern, hasProperties, hasRequired, hasType, hasUniqueItems, } from '@amritk/helpers/schema-guards';
4
5
  import { generateEnumCheck } from './generate-enum-check.js';
5
6
  /**
@@ -37,6 +38,10 @@ export const generateSchemaChecks = (accessor, schema) => {
37
38
  case 'number':
38
39
  case 'integer':
39
40
  checks.push(`typeof ${accessor} === "number"`);
41
+ // `integer` must reject non-integral numbers (a bare typeof accepts `1.5`).
42
+ if (schema.type === 'integer') {
43
+ checks.push(`Number.isInteger(${accessor})`);
44
+ }
40
45
  if (hasMinimum(schema)) {
41
46
  checks.push(`${accessor} >= ${schema.minimum}`);
42
47
  }
@@ -50,7 +55,7 @@ export const generateSchemaChecks = (accessor, schema) => {
50
55
  checks.push(`${accessor} < ${schema.exclusiveMaximum}`);
51
56
  }
52
57
  if (hasMultipleOf(schema)) {
53
- checks.push(`${accessor} % ${schema.multipleOf} === 0`);
58
+ checks.push(multipleOfPassExpr(accessor, schema.multipleOf));
54
59
  }
55
60
  break;
56
61
  case 'boolean':
@@ -73,7 +78,7 @@ export const generateSchemaChecks = (accessor, schema) => {
73
78
  // Check required properties exist
74
79
  if (hasRequired(schema) && schema.required.length > 0) {
75
80
  for (const requiredKey of schema.required) {
76
- checks.push(`"${requiredKey}" in ${accessor}`);
81
+ checks.push(`${JSON.stringify(requiredKey)} in ${accessor}`);
77
82
  }
78
83
  }
79
84
  // Check minProperties
@@ -1,4 +1,19 @@
1
1
  import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
+ /**
3
+ * Context for assertions that reach beyond the property's own schema: union
4
+ * membership checks may call imported `validate{X}Shape` predicates ($ref
5
+ * branches), which requires knowing the ref-import mode, the type-name suffix,
6
+ * and the root document (to prove those validators are real — see
7
+ * canEnforceUnion). `stripUnknown` disables union enforcement entirely, since
8
+ * shape validators then treat undeclared keys as a mismatch while the
9
+ * stripUnknown contract is to drop them.
10
+ */
11
+ export type StrictAssertionContext = {
12
+ readonly useRefImports?: boolean;
13
+ readonly suffix?: string;
14
+ readonly rootSchema?: Record<string, unknown>;
15
+ readonly stripUnknown?: boolean;
16
+ };
2
17
  /**
3
18
  * Generates strict-mode assertions for the body of an object parser.
4
19
  * Throws on:
@@ -10,7 +25,7 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
10
25
  * Properties with a `$ref` are validated by the nested parser's own strict
11
26
  * check when that parser is invoked downstream.
12
27
  */
13
- export declare const generateObjectStrictAssertion: (schema: JSONSchema, typeName: string) => string[];
28
+ export declare const generateObjectStrictAssertion: (schema: JSONSchema, typeName: string, context?: StrictAssertionContext) => string[];
14
29
  /**
15
30
  * Generates a single strict-mode assertion line for a non-object scalar parser.
16
31
  * Returns null when the schema has no type information to assert on.