@amritk/generate-parsers 0.13.0 → 0.15.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,15 +1,16 @@
1
1
  import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
+ import { quoteJsString } from '@amritk/helpers/quote-js-string';
2
3
  import { refToName } from '@amritk/helpers/ref-to-name';
3
4
  import { resolveRef } from '@amritk/helpers/resolve-ref';
4
5
  import { safeAccessor, safeKey } from '@amritk/helpers/safe-accessor';
5
- import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasItems, hasOneOf, hasProperties, hasRef, hasType, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
6
+ import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasItems, hasOneOf, hasProperties, hasRef, hasRequired, hasType, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
6
7
  import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
7
8
  import { findDiscriminator } from '../helpers/find-discriminator.js';
8
9
  import { getDefaultValue } from '../helpers/get-default-value.js';
9
10
  import { getDiscriminatorValue } from '../helpers/get-discriminator-value.js';
10
11
  import { generateObjectStrictAssertion, generateScalarStrictAssertion } from './generate-strict-assertion.js';
11
- import { canEnforceUnion, generatePropertyTypeCheck, generateUnionCheck, getUnionBranches, shapeValidatorName, } from './generate-type-checks.js';
12
- import { generateValidationExpression, scalarItemTypeCheck } from './generate-validation-expression.js';
12
+ import { canEnforceUnion, generatePropertyTypeCheck, generateUnionCheck, getUnionBranches, isInlineObjectArrayProperty, isInlineObjectProperty, shapeValidatorName, } from './generate-type-checks.js';
13
+ import { generateValidationExpression, isCoercibleItemSchema } from './generate-validation-expression.js';
13
14
  /**
14
15
  * Generates the parser function name from a type name.
15
16
  * Converts "UserObject" to "parseUserObject".
@@ -350,7 +351,7 @@ const generateRefUnionDispatch = (functionName, typeName, branches, strict, ctx)
350
351
  // first branch's parser, preserving the coercing parser's lenient contract while
351
352
  // still running real validation/coercion instead of a blind cast.
352
353
  const fallback = strict
353
- ? `(() => { throw new Error(${JSON.stringify(`[${typeName}] value does not match any union branch`)}); })()`
354
+ ? `(() => { throw new Error(${quoteJsString(`[${typeName}] value does not match any union branch`)}); })()`
354
355
  : `${cases[0]?.parser}(input)`;
355
356
  let expr = fallback;
356
357
  for (let i = cases.length - 1; i >= 0; i--) {
@@ -394,7 +395,7 @@ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
394
395
  if (branches && !unionCtx.stripUnknown && canEnforceUnion(branches, unionCtx.rootSchema)) {
395
396
  const check = generateUnionCheck('input', branches, unionCtx.useRefImports, unionCtx.suffix);
396
397
  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
+ return `export const ${functionName} = (input: unknown): ${typeName} => {\n if (!(${check})) throw new Error(${quoteJsString(`[${typeName}] value does not match any union branch`)});\n return input as ${typeName};\n};`;
398
399
  }
399
400
  }
400
401
  }
@@ -437,6 +438,50 @@ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
437
438
  return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`;
438
439
  }
439
440
  }
441
+ // Root-level arrays with rich item schemas delegate every element to a real
442
+ // parser, so nested enums and $refs inside array items are validated instead
443
+ // of spread through unchecked. $ref items call the imported parser; inline
444
+ // object items get a private item sub-parser in the same file. (Scalar and
445
+ // enum items are covered below: the lax switch coerces them element-wise and
446
+ // generateScalarStrictAssertion enforces them in strict mode.)
447
+ if (isSchemaObject(schema) &&
448
+ hasType(schema) &&
449
+ schema.type === 'array' &&
450
+ hasItems(schema) &&
451
+ !Array.isArray(schema.items)) {
452
+ const items = schema.items;
453
+ const notArrayThrow = `if (!Array.isArray(input)) throw new Error(\`[${typeName}] expected array, got \${input === null ? "null" : typeof input}\`);`;
454
+ // validateArray identity-returns the input array when every element parses
455
+ // to itself; this parser is EXPORTED, and exported parsers never alias the
456
+ // value the caller passed in (matching the scalar root-array path's
457
+ // `[...input]` copy), so materialize a copy exactly when that happens —
458
+ // element references are still shared, like every other fast path.
459
+ const delegatedBody = (itemParserName) => ` const _parsed = validateArray(input, ${itemParserName});\n return (_parsed === input ? [..._parsed] : _parsed) as ${typeName};`;
460
+ const delegated = (itemParserName) => strict
461
+ ? `export const ${functionName} = (input: unknown): ${typeName} => {\n ${notArrayThrow}\n${delegatedBody(itemParserName)}\n};`
462
+ : `export const ${functionName} = (input: unknown): ${typeName} => {\n${delegatedBody(itemParserName)}\n};`;
463
+ if (unionCtx?.useRefImports && isSchemaObject(items) && hasRef(items)) {
464
+ return delegated(generateParserName(refToName(items.$ref, unionCtx.suffix)));
465
+ }
466
+ if (isInlineObjectProperty(items)) {
467
+ const reserved = unionCtx?.reservedNames ?? NO_RESERVED_NAMES;
468
+ // Dedup against imported identifiers: a root `List` whose items carry a
469
+ // $ref to `#/$defs/listItem` imports parseListItem — a bare `ListItem`
470
+ // here would shadow it (TS2440) and self-recurse instead of delegating.
471
+ let itemName = `${typeName}_Item`;
472
+ while (reserved.has(itemName))
473
+ itemName = `${itemName}_`;
474
+ const useRefImports = unionCtx?.useRefImports ?? false;
475
+ const suffix = unionCtx?.suffix ?? '';
476
+ const stripUnknown = unionCtx?.stripUnknown ?? false;
477
+ const preamble = [
478
+ `type ${itemName} = ${typeName}[number];`,
479
+ generateShapeValidator(items, itemName, useRefImports, suffix, false, stripUnknown, reserved),
480
+ generateObjectParser(items, itemName, useRefImports, suffix, unionCtx?.logWarnings ?? false, strict, false, stripUnknown, unionCtx?.rootSchema, reserved),
481
+ ].join('\n\n');
482
+ return `${preamble}\n\n${delegated(generateParserName(itemName))}`;
483
+ }
484
+ }
440
485
  if (!isSchemaObject(schema) || !hasType(schema)) {
441
486
  // Schema without type information cannot be validated beyond a cast
442
487
  return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`;
@@ -463,8 +508,8 @@ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
463
508
  case 'boolean':
464
509
  return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "boolean" ? input as ${typeName} : false as ${typeName};`;
465
510
  case 'array': {
466
- // Coerce each element when the item schema is a single scalar type.
467
- if (hasItems(schema) && !Array.isArray(schema.items) && scalarItemTypeCheck(schema.items, '_it') !== null) {
511
+ // Coerce each element when the item schema is a single scalar type or an enum.
512
+ if (hasItems(schema) && !Array.isArray(schema.items) && isCoercibleItemSchema(schema.items)) {
468
513
  const item = schema.items;
469
514
  const itemExpr = generateValidationExpression('', item, getDefaultValue(item), true, undefined, undefined, '_it', true);
470
515
  return `export const ${functionName} = (input: unknown): ${typeName} => Array.isArray(input) ? (input as unknown[]).map((_it) => ${itemExpr}) as ${typeName} : [] as ${typeName};`;
@@ -495,56 +540,55 @@ const toVarName = (key) => {
495
540
  const safe = key.replace(/[^a-zA-Z0-9_$]/g, '_');
496
541
  return `_${safe}`;
497
542
  };
543
+ /** PascalCases a property key for use in a synthesized sub-type name. */
544
+ const pascalCaseKey = (key) => key
545
+ .replace(/[^a-zA-Z0-9_$]/g, '_')
546
+ .split('_')
547
+ .filter((part) => part.length > 0)
548
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
549
+ .join('');
550
+ /** Shared empty reserved-name set for callers with no import context. */
551
+ const NO_RESERVED_NAMES = new Set();
498
552
  /**
499
- * Matches an inline nested object property — an object schema written directly
500
- * under `properties` with its own `properties`, rather than referenced via
501
- * `$ref`. These get a private sub-parser (and shape predicate) in the same
502
- * generated file so their fields are actually parsed; anything involving
503
- * composition, conditionals, enums, or record semantics keeps the existing
504
- * code paths.
505
- */
506
- const isInlineObjectProperty = (propSchema) => {
507
- if (!isSchemaObject(propSchema))
508
- return false;
509
- if (hasRef(propSchema) || hasEnum(propSchema) || hasConst(propSchema))
510
- return false;
511
- if (hasOneOf(propSchema) || hasAnyOf(propSchema) || hasAllOf(propSchema))
512
- return false;
513
- if ('patternProperties' in propSchema || 'not' in propSchema || 'if' in propSchema)
514
- return false;
515
- // additionalProperties-as-schema records go through the record paths instead
516
- if (hasAdditionalProperties(propSchema) && typeof propSchema.additionalProperties !== 'boolean')
517
- return false;
518
- return isObjectSchema(propSchema) && hasProperties(propSchema);
519
- };
520
- /**
521
- * Builds the property-key → synthesized-type-name map for every inline nested
522
- * object property of a schema, e.g. parent `Order` + key `shipTo` →
523
- * `OrderShipTo`. Both the parser and the shape validator derive the map
524
- * independently, so the naming (including collision suffixes) is a pure
525
- * function of the schema and parent type name.
553
+ * Shared empty result for the common no-inline-sub-types case, so the per-node
554
+ * calls (parser and validator, for every object node, on every generation)
555
+ * allocate nothing. Callers only read from the maps.
526
556
  */
527
- const collectInlineObjectProperties = (schema, typeName) => {
528
- const subTypeNames = new Map();
557
+ const EMPTY_SUB_TYPES = { objects: new Map(), arrayItems: new Map() };
558
+ const collectInlineSubTypes = (schema, typeName, reservedNames = NO_RESERVED_NAMES) => {
529
559
  if (!hasProperties(schema))
530
- return subTypeNames;
531
- const used = new Set();
532
- for (const [key, propSchema] of Object.entries(schema.properties)) {
533
- if (!isInlineObjectProperty(propSchema))
534
- continue;
535
- const pascal = key
536
- .replace(/[^a-zA-Z0-9_$]/g, '_')
537
- .split('_')
538
- .filter((part) => part.length > 0)
539
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
540
- .join('');
541
- let subName = `${typeName}${pascal || 'Value'}`;
542
- while (used.has(subName))
560
+ return EMPTY_SUB_TYPES;
561
+ const props = schema.properties;
562
+ // Single pass with lazy allocation: most object nodes have neither inline
563
+ // object properties nor inline-object array items, and this runs for every
564
+ // node in both the parser and the validator — so nothing is allocated until
565
+ // the first match, and the classification predicates run exactly once per
566
+ // property (an eager pre-scan would re-evaluate them for matching schemas).
567
+ let objects = null;
568
+ let arrayItems = null;
569
+ let used = null;
570
+ const claim = (base) => {
571
+ used ??= new Set();
572
+ let subName = base;
573
+ while (used.has(subName) || reservedNames.has(subName))
543
574
  subName = `${subName}_`;
544
575
  used.add(subName);
545
- subTypeNames.set(key, subName);
576
+ return subName;
577
+ };
578
+ for (const key in props) {
579
+ const propSchema = props[key];
580
+ if (isInlineObjectProperty(propSchema)) {
581
+ objects ??= new Map();
582
+ objects.set(key, claim(`${typeName}_${pascalCaseKey(key) || 'Value'}`));
583
+ }
584
+ else if (isInlineObjectArrayProperty(propSchema)) {
585
+ arrayItems ??= new Map();
586
+ arrayItems.set(key, claim(`${typeName}_${pascalCaseKey(key) || 'Value'}Item`));
587
+ }
546
588
  }
547
- return subTypeNames;
589
+ if (objects === null && arrayItems === null)
590
+ return EMPTY_SUB_TYPES;
591
+ return { objects: objects ?? EMPTY_SUB_TYPES.objects, arrayItems: arrayItems ?? EMPTY_SUB_TYPES.arrayItems };
548
592
  };
549
593
  /**
550
594
  * True when the parser must reject (strict mode) or strip (coerce mode) every
@@ -562,6 +606,44 @@ const hasStrictKeys = (schema) => {
562
606
  return false;
563
607
  return true;
564
608
  };
609
+ /**
610
+ * When every declared property is required (and every required key is
611
+ * declared, with a real schema), the fast-path known-keys test can be an
612
+ * own-key *count* comparison instead of a per-key `for..in` walk: the typed
613
+ * property checks already prove all N declared keys are present, so
614
+ * `Object.keys(input).length === N` proves there are no undeclared extras —
615
+ * measurably cheaper on the hot path. Returns the declared-key count, or null
616
+ * when the cheaper form would be unsound (an optional or non-schema property
617
+ * breaks the presence proof).
618
+ */
619
+ const exactKeyCountOf = (schema) => {
620
+ if (!isSchemaObject(schema) || !hasProperties(schema) || !hasRequired(schema))
621
+ return null;
622
+ const props = schema.properties;
623
+ const required = schema.required;
624
+ // Allocation-free for the common small object: this runs for every object
625
+ // node, in both the parser and the validator, on every generation, and the
626
+ // O(n²) `includes` beats building a Set for a handful of keys. Wide schemas
627
+ // (generated API models) flip to a one-time Set so a 200-property object
628
+ // doesn't pay ~20k string comparisons per node.
629
+ const requiredLookup = required.length > 16 ? new Set(required) : null;
630
+ let declaredCount = 0;
631
+ for (const key in props) {
632
+ declaredCount++;
633
+ // Every declared key must be required, and its check must prove presence —
634
+ // all schema-object checks fail on undefined, but a true/false schema
635
+ // literal emits no check at all.
636
+ const isRequired = requiredLookup !== null ? requiredLookup.has(key) : required.includes(key);
637
+ if (!isRequired || !isSchemaObject(props[key]))
638
+ return null;
639
+ }
640
+ // Every declared key is required (above) and the counts match, so the
641
+ // required list is exactly the declared keys (a duplicated required entry
642
+ // would leave some declared key uncovered and fail the loop).
643
+ if (declaredCount === 0 || required.length !== declaredCount)
644
+ return null;
645
+ return declaredCount;
646
+ };
565
647
  /**
566
648
  * Determines if a property needs a local variable or can be inlined.
567
649
  * Schema-object properties are always cached: the slow-path ternary chain
@@ -582,7 +664,7 @@ const shouldCacheVariable = (propSchema, _canFastPath, _useRefImports) => {
582
664
  * - Fast path that returns input directly when all properties are already valid
583
665
  * - Slow path with optimized expressions
584
666
  */
585
- const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false, rootSchema) => {
667
+ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false, rootSchema, reservedNames = NO_RESERVED_NAMES) => {
586
668
  const functionName = generateParserName(typeName);
587
669
  const exportPrefix = exported ? 'export ' : '';
588
670
  if (!hasProperties(schema)) {
@@ -591,18 +673,35 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
591
673
  }
592
674
  return `${exportPrefix}const ${functionName} = (input: unknown): ${typeName} => isObject(input) ? input as ${typeName} : {} as ${typeName};`;
593
675
  }
594
- const properties = Object.entries(schema.properties);
676
+ const schemaProps = schema.properties;
677
+ // Keys once, values via lookup — Object.entries allocates a tuple per
678
+ // property and this runs for every object node on every generation.
679
+ const propertyKeys = Object.keys(schemaProps);
595
680
  // Inline nested object properties get a private sub-parser (plus shape
596
681
  // predicate and type alias) in the same file, so their fields are parsed for
597
682
  // real instead of only passing an isObject check. The sub-parser recurses
598
- // through this same generator, so nesting works to any depth.
599
- const subTypeNames = collectInlineObjectProperties(schema, typeName);
683
+ // through this same generator, so nesting works to any depth. Array
684
+ // properties with inline object items get the same treatment for their
685
+ // element type, so nested enums and $refs *inside array items* are validated
686
+ // too (each element runs through the item sub-parser via validateArray).
687
+ const { objects: subTypeNames, arrayItems: subItemNames } = collectInlineSubTypes(schema, typeName, reservedNames);
600
688
  const preamble = [];
601
689
  for (const [key, subName] of subTypeNames) {
602
- const propSchema = schema.properties[key];
690
+ const propSchema = schemaProps[key];
603
691
  preamble.push(`type ${subName} = ${typeName}[${JSON.stringify(key)}];`);
604
- preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false, stripUnknown));
605
- preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema));
692
+ preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false, stripUnknown, reservedNames));
693
+ preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames));
694
+ }
695
+ for (const [key, subName] of subItemNames) {
696
+ const propSchema = schemaProps[key];
697
+ const itemSchema = propSchema.items;
698
+ // NonNullable strips the `| undefined` an optional array property carries.
699
+ preamble.push(`type ${subName} = NonNullable<${typeName}[${JSON.stringify(key)}]>[number];`);
700
+ preamble.push(generateShapeValidator(itemSchema, subName, useRefImports, suffix, false, stripUnknown, reservedNames));
701
+ // Hand-rolled loop instead of Array.prototype.every on the guard path — the
702
+ // callback protocol costs a few percent on element-heavy hot paths.
703
+ preamble.push(`const _every${subName} = (arr: readonly unknown[]): boolean => {\n for (let i = 0; i < arr.length; i++) if (!${shapeValidatorName(subName)}(arr[i])) return false;\n return true;\n};`);
704
+ preamble.push(generateObjectParser(itemSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames));
606
705
  }
607
706
  // additionalProperties: false — the per-key "is this undeclared" test inlines
608
707
  // `!==` comparisons for a short key list and hoists a Set only for a long one
@@ -615,13 +714,25 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
615
714
  // `strictKeys` makes an extra a hard error (the throw block further down).
616
715
  const strictKeys = hasStrictKeys(schema);
617
716
  const stripKeys = strictKeys || stripUnknown;
618
- const strictKeyCheck = unknownKeyCheck(properties.map(([key]) => key), `_knownKeys${typeName}`);
619
- if (stripKeys) {
717
+ // When every declared property is required, the fast-path no-extras test is
718
+ // the cheaper own-key count (see exactKeyCountOf) and the `_hasOnlyKnownKeys`
719
+ // predicate is not emitted at all — the shape validator derives the same
720
+ // decision from the schema, so the cross-function contract stays in sync.
721
+ const exactKeyCount = stripKeys ? exactKeyCountOf(schema) : null;
722
+ const strictKeyCheck = unknownKeyCheck(propertyKeys, `_knownKeys${typeName}`);
723
+ if (stripKeys && exactKeyCount === null) {
620
724
  for (const declaration of strictKeyCheck.declarations) {
621
725
  preamble.push(`${declaration};`);
622
726
  }
623
727
  preamble.push(`const _hasOnlyKnownKeys${typeName} = (input: Record<string, unknown>): boolean => {\n for (const _k in input) if (${strictKeyCheck.isUnknown('_k')}) return false;\n return true;\n};`);
624
728
  }
729
+ else if (strict && strictKeys) {
730
+ // The unknown-key throw loop below still needs the hoisted Set (when the
731
+ // key list is long enough to use one).
732
+ for (const declaration of strictKeyCheck.declarations) {
733
+ preamble.push(`${declaration};`);
734
+ }
735
+ }
625
736
  const fallbackObject = generateFallbackObject(schema, useRefImports, typeName, suffix, subTypeNames);
626
737
  // First pass: gather per-property info and decide whether a fast path is
627
738
  // possible. A fast path returns the input (or a direct strip-build) for
@@ -641,7 +752,8 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
641
752
  // declarations (TS2451). Dedupe by suffixing `_` until unique — the same
642
753
  // approach `collectInlineObjectProperties` uses — so each key gets its own var.
643
754
  const usedVarNames = new Set();
644
- for (const [key, propSchema] of properties) {
755
+ for (const key of propertyKeys) {
756
+ const propSchema = schemaProps[key];
645
757
  const isRequired = isPropertyRequired(key, schema);
646
758
  let varName = toVarName(key);
647
759
  while (usedVarNames.has(varName))
@@ -658,10 +770,15 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
658
770
  }
659
771
  const subName = subTypeNames.get(key);
660
772
  // Inline nested objects fast-path through their private shape predicate, the
661
- // same way $ref properties use the imported one.
662
- const check = subName
773
+ // same way $ref properties use the imported one. Arrays of inline objects
774
+ // additionally prove every element via the private item predicate.
775
+ let check = subName
663
776
  ? `${shapeValidatorName(subName)}(${varName})`
664
777
  : generatePropertyTypeCheck(varName, propSchema, useRefImports, suffix);
778
+ const itemSubName = subItemNames.get(key);
779
+ if (check !== null && itemSubName) {
780
+ check = `${check} && _every${itemSubName}(${varName})`;
781
+ }
665
782
  if (check === null) {
666
783
  canFastPath = false;
667
784
  }
@@ -676,9 +793,16 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
676
793
  // fire on inputs that carry no undeclared key. For strict + additionalProperties:
677
794
  // false the cold path rejects extras instead; folding the known-keys term into
678
795
  // the guard lets the guard run *before* that rejection, so a valid clean input
679
- // never pays for the per-property assertions.
796
+ // never pays for the per-property assertions. With every declared property
797
+ // required, the own-key count is equivalent and cheaper than the per-key walk —
798
+ // but only for plain objects: a crafted prototype could satisfy the typed
799
+ // checks through inherited properties while the own-key count still matches,
800
+ // so non-plain inputs route to the slow path, where the for..in walk keeps
801
+ // the historical inherited-key rejection.
680
802
  if (stripKeys) {
681
- fastPathChecks.push(`_hasOnlyKnownKeys${typeName}(input)`);
803
+ fastPathChecks.push(exactKeyCount !== null
804
+ ? `Object.getPrototypeOf(input) === Object.prototype && Object.keys(input).length === ${exactKeyCount}`
805
+ : `_hasOnlyKnownKeys${typeName}(input)`);
682
806
  }
683
807
  // The deep guard proves the whole shape (so `{ ...input }` can be returned),
684
808
  // using the nested shape predicates and the known-keys term above.
@@ -784,6 +908,21 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
784
908
  }
785
909
  continue;
786
910
  }
911
+ // Arrays of inline objects run every element through the private item
912
+ // sub-parser, the same way arrays of $refs delegate via validateArray.
913
+ // In strict mode the item parser throws on a bad element; in coerce mode
914
+ // it repairs the element to a valid instance.
915
+ const itemSubName = subItemNames.get(key);
916
+ if (itemSubName) {
917
+ const itemParserName = generateParserName(itemSubName);
918
+ if (isRequired) {
919
+ objectLines.push(` ${safeKey(key)}: validateArray(${accessor}, ${itemParserName}),`);
920
+ }
921
+ else {
922
+ objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: validateArray(${accessor}, ${itemParserName}) }),`);
923
+ }
924
+ continue;
925
+ }
787
926
  // Handle direct $ref properties via imported parsers
788
927
  if (shouldUseRefImport(propSchema, useRefImports)) {
789
928
  const ref = propSchema.$ref;
@@ -872,6 +1011,18 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
872
1011
  lines.push(` if (${deepGuard}) return { ...input } as ${typeName};`);
873
1012
  return;
874
1013
  }
1014
+ // A *private* (nested-object / array-item) parser whose deep guard proved
1015
+ // the input is exactly the declared shape — typed checks plus the
1016
+ // no-undeclared-keys term, recursively via sub-predicates — can hand the
1017
+ // input back by reference instead of allocating a literal. That is the
1018
+ // same sharing the parent's own fast-path literal performs (`items:
1019
+ // _items`), and it is what keeps clean array elements allocation-free.
1020
+ // Exported root parsers keep returning a fresh object so callers never
1021
+ // alias the value they passed in.
1022
+ if (!exported) {
1023
+ lines.push(` if (${deepGuard}) return input as ${typeName};`);
1024
+ return;
1025
+ }
875
1026
  const fields = [];
876
1027
  for (const { key, varName, isRequired, propSchema } of propInfo) {
877
1028
  if (!isSchemaObject(propSchema)) {
@@ -907,13 +1058,45 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
907
1058
  ...(rootSchema !== undefined ? { rootSchema } : {}),
908
1059
  }).slice(1);
909
1060
  if (shallowGuard) {
910
- // stripUnknown: a well-typed input skips the assertions and goes straight
911
- // to the strip build (which removes extras and recurses into sub-parsers).
912
- lines.push(` if (!(${shallowGuard})) {`);
913
- for (const assertionLine of assertionLines) {
914
- lines.push(` ${assertionLine}`);
1061
+ // A private sub-parser hands input that is already exactly the declared
1062
+ // shape (no extras at any level) back by reference, so a clean array
1063
+ // element or nested object costs no allocation. The deep guard is
1064
+ // evaluated as the shallow guard plus only its *residual* terms (deeper
1065
+ // per-property checks and the no-extras term), so a carries-extras input
1066
+ // never runs the same typed checks twice before taking the strip build.
1067
+ if (!exported && deepGuard) {
1068
+ const residual = [];
1069
+ for (let i = 0; i < fastPathChecks.length; i++) {
1070
+ const deep = fastPathChecks[i];
1071
+ const shallow = shallowChecks[i];
1072
+ if (deep === shallow)
1073
+ continue;
1074
+ // When the deep check extends the shallow one by conjunction (e.g.
1075
+ // `Array.isArray(_x) && _everyItem(_x)`), the shallow guard already
1076
+ // proved the prefix — re-evaluating it per clean parse was pure
1077
+ // waste on the hot path, so only the extension joins the residual.
1078
+ residual.push(shallow !== undefined && deep.startsWith(`${shallow} && `) ? deep.slice(shallow.length + 4) : deep);
1079
+ }
1080
+ lines.push(` if (${shallowGuard}) {`);
1081
+ lines.push(residual.length > 0
1082
+ ? ` if (${residual.join(' && ')}) return input as ${typeName};`
1083
+ : ` return input as ${typeName};`);
1084
+ lines.push(` } else {`);
1085
+ for (const assertionLine of assertionLines) {
1086
+ lines.push(` ${assertionLine}`);
1087
+ }
1088
+ lines.push(` }`);
1089
+ }
1090
+ else {
1091
+ // stripUnknown: a well-typed input skips the assertions and goes
1092
+ // straight to the strip build (which removes extras and recurses into
1093
+ // sub-parsers).
1094
+ lines.push(` if (!(${shallowGuard})) {`);
1095
+ for (const assertionLine of assertionLines) {
1096
+ lines.push(` ${assertionLine}`);
1097
+ }
1098
+ lines.push(` }`);
915
1099
  }
916
- lines.push(` }`);
917
1100
  emitReturn(lines, buildObjectLines(true));
918
1101
  }
919
1102
  else {
@@ -924,6 +1107,13 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
924
1107
  lines.push(assertionLine);
925
1108
  }
926
1109
  if (strictKeys) {
1110
+ // for..in (not Object.keys) deliberately: this loop is cold — the fast
1111
+ // path already proved the key set — but swapping in the keys-array
1112
+ // iterator here once regressed the *hot* path several percent on CI:
1113
+ // the extra dead-path bytecode changed the engine's inlining of the
1114
+ // whole parser. The fast-path no-extras test uses own-key semantics;
1115
+ // an inherited-key mismatch merely lands here and keeps the historical
1116
+ // for..in rejection.
927
1117
  lines.push(` for (const _k in input) {`);
928
1118
  lines.push(` if (${strictKeyCheck.isUnknown('_k')}) throw new Error(\`[${typeName}] unknown property "\${_k}"\`);`);
929
1119
  lines.push(` }`);
@@ -1335,8 +1525,12 @@ const selectParserStrategy = (schema, typeName, options) => {
1335
1525
  const stripUnknown = options?.stripUnknown ?? false;
1336
1526
  const suffix = options?.typeSuffix ?? '';
1337
1527
  // Special case for the self-referential JSON Schema meta-schema type (e.g.
1338
- // `Schema` / `SchemaObject`) - it can be any JSON Schema.
1339
- if (typeName === `Schema${suffix}`) {
1528
+ // `Schema` / `SchemaObject`) - it can be any JSON Schema. This is an OpenAPI
1529
+ // heuristic for a `$defs`/`components.schemas` entry named `schema`; it must
1530
+ // never fire for the root document, whose name is user-derived (a `schema.json`
1531
+ // file naturally yields the root type `Schema`) and would otherwise collapse
1532
+ // to a validation-free pass-through parser.
1533
+ if (!options?.isRoot && typeName === `Schema${suffix}`) {
1340
1534
  return generateSchemaObjectParser(typeName);
1341
1535
  }
1342
1536
  const isObjectLikeSchema = isObjectSchema(schema) ||
@@ -1347,7 +1541,9 @@ const selectParserStrategy = (schema, typeName, options) => {
1347
1541
  useRefImports,
1348
1542
  suffix,
1349
1543
  stripUnknown,
1544
+ logWarnings,
1350
1545
  ...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
1546
+ ...(options?.reservedNames !== undefined ? { reservedNames: options.reservedNames } : {}),
1351
1547
  });
1352
1548
  }
1353
1549
  // Handle schemas with both properties AND patternProperties.
@@ -1361,7 +1557,7 @@ const selectParserStrategy = (schema, typeName, options) => {
1361
1557
  // properties AND conditional keywords use all declared properties rather than
1362
1558
  // only the if/then fragment.
1363
1559
  if (hasProperties(schema)) {
1364
- return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, options?.rootSchema);
1560
+ return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, options?.rootSchema, options?.reservedNames);
1365
1561
  }
1366
1562
  // Handle conditional schemas (if/then/else) for schemas without explicit properties.
1367
1563
  if (isSchemaObject(schema) && 'if' in schema && 'then' in schema && 'else' in schema) {
@@ -1379,7 +1575,9 @@ const selectParserStrategy = (schema, typeName, options) => {
1379
1575
  useRefImports,
1380
1576
  suffix,
1381
1577
  stripUnknown,
1578
+ logWarnings,
1382
1579
  ...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
1580
+ ...(options?.reservedNames !== undefined ? { reservedNames: options.reservedNames } : {}),
1383
1581
  });
1384
1582
  }
1385
1583
  // Handle schemas with patternProperties (but no properties)
@@ -1433,7 +1631,7 @@ export const shapeValidatorFunctionName = (typeName) => shapeValidatorName(typeN
1433
1631
  * in additionalProperties). The stub is safe: parents calling it will fall
1434
1632
  * through to the slow path, matching the pre-deep-fast-path behavior.
1435
1633
  */
1436
- export const generateShapeValidator = (schema, typeName, useRefImports, suffix = '', exported = true, stripUnknown = false) => {
1634
+ export const generateShapeValidator = (schema, typeName, useRefImports, suffix = '', exported = true, stripUnknown = false, reservedNames = NO_RESERVED_NAMES) => {
1437
1635
  const fnName = shapeValidatorName(typeName);
1438
1636
  const exportPrefix = exported ? 'export ' : '';
1439
1637
  const stub = `${exportPrefix}const ${fnName} = (_input: unknown): boolean => false;`;
@@ -1477,18 +1675,26 @@ export const generateShapeValidator = (schema, typeName, useRefImports, suffix =
1477
1675
  'else' in schema) {
1478
1676
  return stub;
1479
1677
  }
1480
- const properties = Object.entries(schema.properties);
1678
+ const schemaProps = schema.properties;
1679
+ const propertyKeys = Object.keys(schemaProps);
1481
1680
  // Same deterministic naming as the parser's sub-parser generation, so the
1482
1681
  // referenced private shape predicates exist in the same file.
1483
- const subTypeNames = collectInlineObjectProperties(schema, typeName);
1682
+ const { objects: subTypeNames, arrayItems: subItemNames } = collectInlineSubTypes(schema, typeName, reservedNames);
1484
1683
  // When the parser strips extras (additionalProperties: false or stripUnknown),
1485
1684
  // an input carrying an undeclared key is *not* fast-path eligible — the parser
1486
1685
  // would strip it rather than return `{ ...input }` — so the shape only matches
1487
- // when every key is declared. (The `_hasOnlyKnownKeys` predicate this calls is
1488
- // emitted by the parser under the same `stripKeys` condition.)
1489
- const strictKeysGuard = hasStrictKeys(schema) || stripUnknown ? `\n if (!_hasOnlyKnownKeys${typeName}(input)) return false;` : '';
1686
+ // when every key is declared. With every declared property required, the
1687
+ // no-extras test is the cheaper own-key count appended after the typed checks
1688
+ // (which prove all N keys present); otherwise it is the `_hasOnlyKnownKeys`
1689
+ // walk the parser emits under the same `stripKeys` condition.
1690
+ const validatorStripKeys = hasStrictKeys(schema) || stripUnknown;
1691
+ const validatorKeyCount = validatorStripKeys ? exactKeyCountOf(schema) : null;
1692
+ const strictKeysGuard = validatorStripKeys && validatorKeyCount === null
1693
+ ? `\n if (!_hasOnlyKnownKeys${typeName}(input)) return false;`
1694
+ : '';
1490
1695
  const checks = [];
1491
- for (const [key, propSchema] of properties) {
1696
+ for (const key of propertyKeys) {
1697
+ const propSchema = schemaProps[key];
1492
1698
  // Records of refs require iterating every value — too expensive for the fast path.
1493
1699
  if (shouldUseRecordRefImport(propSchema, useRefImports)) {
1494
1700
  return stub;
@@ -1496,9 +1702,16 @@ export const generateShapeValidator = (schema, typeName, useRefImports, suffix =
1496
1702
  const accessor = safeAccessor('input', key);
1497
1703
  const isRequired = isPropertyRequired(key, schema);
1498
1704
  const subName = subTypeNames.get(key);
1499
- const check = subName
1705
+ let check = subName
1500
1706
  ? `${shapeValidatorName(subName)}(${accessor})`
1501
1707
  : generatePropertyTypeCheck(accessor, propSchema, useRefImports, suffix);
1708
+ // Arrays of inline objects prove every element via the private item loop
1709
+ // helper (emitted alongside the item sub-parser), matching the parser's
1710
+ // fast-path guard.
1711
+ const itemSubName = subItemNames.get(key);
1712
+ if (check !== null && itemSubName) {
1713
+ check = `${check} && _every${itemSubName}(${accessor})`;
1714
+ }
1502
1715
  if (check === null) {
1503
1716
  return stub;
1504
1717
  }
@@ -1509,6 +1722,14 @@ export const generateShapeValidator = (schema, typeName, useRefImports, suffix =
1509
1722
  checks.push(`(${accessor} === undefined || ${check})`);
1510
1723
  }
1511
1724
  }
1725
+ // The count form is sound only in conjunction with the typed checks above
1726
+ // (they prove every declared key present) and only for plain objects (a
1727
+ // crafted prototype could satisfy those checks through inherited
1728
+ // properties), so it joins the chain last, prototype-guarded like the
1729
+ // parser's own fast path.
1730
+ if (validatorKeyCount !== null) {
1731
+ checks.push(`Object.getPrototypeOf(input) === Object.prototype && Object.keys(input).length === ${validatorKeyCount}`);
1732
+ }
1512
1733
  if (checks.length === 0) {
1513
1734
  if (strictKeysGuard) {
1514
1735
  return `${exportPrefix}const ${fnName} = (input: unknown): boolean => {
@@ -1,6 +1,7 @@
1
1
  import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
2
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
3
3
  import { multipleOfFailExpr } from '@amritk/helpers/multiple-of-check';
4
+ import { quoteJsString } from '@amritk/helpers/quote-js-string';
4
5
  import { safeAccessor } from '@amritk/helpers/safe-accessor';
5
6
  import { hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
6
7
  import { generateEnumCheck } from './generate-enum-check.js';
@@ -21,6 +22,11 @@ const wrongTypeCondition = (accessor, type) => {
21
22
  return `(typeof ${accessor} !== "number" || !Number.isInteger(${accessor}))`;
22
23
  case 'boolean':
23
24
  return `typeof ${accessor} !== "boolean"`;
25
+ case 'null':
26
+ // Missing from this switch historically, which meant a null-typed
27
+ // property was never enforced on the assertion path — the strict-mode
28
+ // differential fuzzer caught non-null values sailing through.
29
+ return `${accessor} !== null`;
24
30
  case 'array':
25
31
  return `!Array.isArray(${accessor})`;
26
32
  case 'object':
@@ -36,14 +42,13 @@ const wrongTypeCondition = (accessor, type) => {
36
42
  const typeLabel = (type) => (type === 'integer' ? 'number' : type);
37
43
  /**
38
44
  * Emits `throw new Error(<message>[ + <suffixExpr>])`. The static message may
39
- * contain schema-controlled text (property names, patterns, enum values), so it
40
- * is always serialized with `JSON.stringify` — a key like `it's` or a pattern
41
- * containing a quote or newline can no longer break out of the string literal or
42
- * inject code. `suffixExpr`, when given, is a runtime expression appended to the
43
- * message (e.g. `typeof input`).
45
+ * contain schema-controlled text (property names, patterns, enum values), so
46
+ * it goes through the shared {@link quoteJsString} escape-or-quote decision.
47
+ * `suffixExpr`, when given, is a runtime expression appended to the message
48
+ * (e.g. `typeof input`).
44
49
  */
45
50
  const throwError = (message, suffixExpr) => {
46
- const literal = JSON.stringify(message);
51
+ const literal = quoteJsString(message);
47
52
  return suffixExpr ? `throw new Error(${literal} + (${suffixExpr}))` : `throw new Error(${literal})`;
48
53
  };
49
54
  /**
@@ -221,8 +226,9 @@ export const generateObjectStrictAssertion = (schema, typeName, context = {}) =>
221
226
  if (!hasProperties(schema) || !isSchemaObject(schema))
222
227
  return lines;
223
228
  const required = new Set(hasRequired(schema) ? schema.required : []);
224
- for (const [key, propSchema] of Object.entries(schema.properties)) {
225
- lines.push(...generatePropertyAssertion(key, propSchema, required.has(key), typeName, context));
229
+ const props = schema.properties;
230
+ for (const key in props) {
231
+ lines.push(...generatePropertyAssertion(key, props[key], required.has(key), typeName, context));
226
232
  }
227
233
  return lines;
228
234
  };