@amritk/generate-parsers 0.8.0 → 0.9.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.
@@ -2,6 +2,7 @@ import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
2
  import { refToName } from '@amritk/helpers/ref-to-name';
3
3
  import { safeAccessor, safeKey } from '@amritk/helpers/safe-accessor';
4
4
  import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasDefault, hasEnum, hasExamples, 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 { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
5
6
  import { getDefaultValue } from '../helpers/get-default-value.js';
6
7
  import { generateObjectStrictAssertion, generateScalarStrictAssertion } from './generate-strict-assertion.js';
7
8
  import { generateValidationExpression } from './generate-validation-expression.js';
@@ -551,13 +552,17 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
551
552
  preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false));
552
553
  preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false));
553
554
  }
554
- // additionalProperties: false — hoist the declared-keys Set so the per-call
555
- // sweep is one Set lookup per key. The predicate form is shared by the fast
556
- // path and the shape validator; strict mode throws with the offending key.
555
+ // additionalProperties: false — the per-key "is this undeclared" test inlines
556
+ // `!==` comparisons for a short key list and hoists a Set only for a long one
557
+ // (see unknownKeyCheck). The predicate form is shared by the fast path and the
558
+ // shape validator; strict mode throws with the offending key.
557
559
  const strictKeys = hasStrictKeys(schema);
560
+ const strictKeyCheck = unknownKeyCheck(properties.map(([key]) => key), `_knownKeys${typeName}`);
558
561
  if (strictKeys) {
559
- preamble.push(`const _knownKeys${typeName} = new Set(${JSON.stringify(properties.map(([key]) => key))});`);
560
- preamble.push(`const _hasOnlyKnownKeys${typeName} = (input: Record<string, unknown>): boolean => {\n for (const _k in input) if (!_knownKeys${typeName}.has(_k)) return false;\n return true;\n};`);
562
+ for (const declaration of strictKeyCheck.declarations) {
563
+ preamble.push(`${declaration};`);
564
+ }
565
+ 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};`);
561
566
  }
562
567
  const fallbackObject = generateFallbackObject(schema, useRefImports, typeName, suffix, subTypeNames);
563
568
  const lines = [];
@@ -568,7 +573,7 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
568
573
  }
569
574
  if (strictKeys) {
570
575
  lines.push(` for (const _k in input) {`);
571
- lines.push(` if (!_knownKeys${typeName}.has(_k)) throw new Error(\`[${typeName}] unknown property "\${_k}"\`);`);
576
+ lines.push(` if (${strictKeyCheck.isUnknown('_k')}) throw new Error(\`[${typeName}] unknown property "\${_k}"\`);`);
572
577
  lines.push(` }`);
573
578
  }
574
579
  }
@@ -631,10 +636,12 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
631
636
  }
632
637
  // Emit a warning for any input key not declared in the schema's properties
633
638
  if (logWarnings && propInfo.length > 0) {
634
- const keysList = propInfo.map(({ key }) => JSON.stringify(key)).join(', ');
635
- lines.push(` const _knownKeys = new Set([${keysList}]);`);
639
+ const warnKeyCheck = unknownKeyCheck(propInfo.map(({ key }) => key), '_knownKeys');
640
+ for (const declaration of warnKeyCheck.declarations) {
641
+ lines.push(` ${declaration};`);
642
+ }
636
643
  lines.push(` for (const _k in input) {`);
637
- lines.push(` if (!_knownKeys.has(_k)) {`);
644
+ lines.push(` if (${warnKeyCheck.isUnknown('_k')}) {`);
638
645
  lines.push(` console.warn(\`[${typeName}] Unknown property "\${_k}"\`);`);
639
646
  lines.push(` }`);
640
647
  lines.push(` }`);
@@ -747,6 +754,59 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
747
754
  return fn;
748
755
  return `${preamble.join('\n\n')}\n\n${fn}`;
749
756
  };
757
+ /**
758
+ * Combined-parser variant for `properties` + `patternProperties` schemas that
759
+ * set `additionalProperties: false`. Declared properties are coerced as usual,
760
+ * keys matching any pattern are kept (the first `$ref` pattern is coerced via
761
+ * its imported parser when ref imports are on), and every other key is rejected
762
+ * in strict mode or stripped in coerce mode — matching the interpreter.
763
+ */
764
+ const generateStrictCombinedParser = (schema, typeName, functionName, propertyLines, patterns, useRefImports, suffix, strict) => {
765
+ const declaredKeys = hasProperties(schema) ? Object.keys(schema.properties) : [];
766
+ // Below the inline threshold a key === "a" || key === "b" chain skips declared
767
+ // keys without the per-call Set allocation; a wider list hoists the Set.
768
+ const knownKeyCheck = unknownKeyCheck(declaredKeys, '_knownKeys');
769
+ // The first $ref pattern (when ref imports are on) coerces its matching values
770
+ // through the imported parser; the remaining patterns keep the raw value.
771
+ const refPattern = useRefImports ? patterns.find(([, ps]) => isSchemaObject(ps) && hasRef(ps)) : undefined;
772
+ const loopLines = [` if (${knownKeyCheck.isKnown('key')}) continue;`];
773
+ if (refPattern) {
774
+ const parserName = generateParserName(refToName(refPattern[1].$ref, suffix));
775
+ loopLines.push(` if (/${escapeRegexPattern(refPattern[0])}/.test(key)) {`);
776
+ loopLines.push(` (result as Record<string, unknown>)[key] = ${parserName}(input[key]);`);
777
+ loopLines.push(` continue;`);
778
+ loopLines.push(` }`);
779
+ }
780
+ const keepConditions = patterns
781
+ .filter(([p]) => !(refPattern && p === refPattern[0]))
782
+ .map(([p]) => `/${escapeRegexPattern(p)}/.test(key)`);
783
+ if (keepConditions.length > 0) {
784
+ loopLines.push(` if (${keepConditions.join(' || ')}) {`);
785
+ loopLines.push(` (result as Record<string, unknown>)[key] = input[key];`);
786
+ loopLines.push(` continue;`);
787
+ loopLines.push(` }`);
788
+ }
789
+ // Unknown key: throw in strict mode, otherwise let it fall through (dropped,
790
+ // since it was never copied into `result`).
791
+ if (strict)
792
+ loopLines.push(` throw new Error(\`[${typeName}] unknown property "\${key}"\`);`);
793
+ const notObjectBranch = strict
794
+ ? ` throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);`
795
+ : ` return {} as unknown as ${typeName};`;
796
+ const resultBody = propertyLines.length > 0 ? `{\n${propertyLines.join('\n')}\n }` : '{}';
797
+ // Only the Set form needs a declaration; the inline === chain is stateless.
798
+ const knownKeysDeclaration = knownKeyCheck.declarations.map((decl) => ` ${decl};\n`).join('');
799
+ return `export const ${functionName} = (input: unknown): ${typeName} => {
800
+ if (!isObject(input)) {
801
+ ${notObjectBranch}
802
+ }
803
+ ${knownKeysDeclaration} const result = ${resultBody} as unknown as ${typeName};
804
+ for (const key in input) {
805
+ ${loopLines.join('\n')}
806
+ }
807
+ return result;
808
+ };`;
809
+ };
750
810
  /**
751
811
  * Generates a parser for schemas that have both properties AND patternProperties.
752
812
  * Parses the known properties first, then iterates remaining keys to match patterns.
@@ -768,6 +828,14 @@ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, l
768
828
  const patternProps = schema.patternProperties;
769
829
  const patterns = Object.entries(patternProps);
770
830
  const refPattern = patterns.find(([, ps]) => isSchemaObject(ps) && hasRef(ps));
831
+ const strictKeys = hasAdditionalProperties(schema) && schema.additionalProperties === false;
832
+ // With `additionalProperties: false`, keys matching neither a declared
833
+ // property nor any pattern must be rejected (strict) or stripped (coerce).
834
+ // The blanket `...input` spread used below cannot do that, so build a
835
+ // selective copy that mirrors the interpreter's undeclared-key handling.
836
+ if (strictKeys) {
837
+ return generateStrictCombinedParser(schema, typeName, functionName, propertyLines, patterns, useRefImports, suffix, strict);
838
+ }
771
839
  if (!refPattern || !useRefImports) {
772
840
  return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
773
841
  }
@@ -894,6 +962,38 @@ const generatePatternPropertiesParser = (schema, typeName, useRefImports, suffix
894
962
  const notObjectBranch = strict
895
963
  ? ` throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);`
896
964
  : ` return {} as unknown as ${typeName};`;
965
+ // With `additionalProperties: false`, only keys matching a pattern survive:
966
+ // others are rejected (strict) or stripped (coerce). Start from an empty
967
+ // object rather than spreading every input key.
968
+ const strictKeys = hasAdditionalProperties(schema) && schema.additionalProperties === false;
969
+ if (strictKeys) {
970
+ const loopLines = [` if (/${escapedPattern}/.test(key)) {`];
971
+ if (patternAssignment) {
972
+ loopLines.push(` const value = input[key];`);
973
+ loopLines.push(` ${patternAssignment}`);
974
+ }
975
+ loopLines.push(` continue;`);
976
+ loopLines.push(` }`);
977
+ const keepConditions = patterns.slice(1).map(([p]) => `/${escapeRegexPattern(p)}/.test(key)`);
978
+ if (keepConditions.length > 0) {
979
+ loopLines.push(` if (${keepConditions.join(' || ')}) {`);
980
+ loopLines.push(` (result as Record<string, unknown>)[key] = input[key];`);
981
+ loopLines.push(` continue;`);
982
+ loopLines.push(` }`);
983
+ }
984
+ if (strict)
985
+ loopLines.push(` throw new Error(\`[${typeName}] unknown property "\${key}"\`);`);
986
+ return `export const ${functionName} = (input: unknown): ${typeName} => {
987
+ if (!isObject(input)) {
988
+ ${notObjectBranch}
989
+ }
990
+ const result = {} as unknown as ${typeName};
991
+ for (const key in input) {
992
+ ${loopLines.join('\n')}
993
+ }
994
+ return result;
995
+ };`;
996
+ }
897
997
  // Generate a parser that handles both pattern matching and x- extensions
898
998
  return `export const ${functionName} = (input: unknown): ${typeName} => {
899
999
  if (!isObject(input)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-parsers",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "json-schema-typed": "^8.0.1",
50
- "@amritk/helpers": "0.9.0",
50
+ "@amritk/helpers": "0.10.0",
51
51
  "@amritk/generate-markdown": "0.2.4"
52
52
  }
53
53
  }