@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,7 +1,11 @@
1
1
  import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
2
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
3
+ import { multipleOfFailExpr } from '@amritk/helpers/multiple-of-check';
3
4
  import { safeAccessor } from '@amritk/helpers/safe-accessor';
4
- import { hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasMaximum, hasMaxLength, hasMinimum, hasMinLength, hasMultipleOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, isSchemaObject, } from '@amritk/helpers/schema-guards';
5
+ 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
+ import { generateEnumCheck } from './generate-enum-check.js';
7
+ import { canEnforceUnion, generateUnionCheck, getUnionBranches } from './generate-type-checks.js';
8
+ import { scalarItemTypeCheck } from './generate-validation-expression.js';
5
9
  /**
6
10
  * Returns the inline condition that is true when `accessor` is the wrong type
7
11
  * for the given JSON Schema primitive type.
@@ -11,8 +15,10 @@ const wrongTypeCondition = (accessor, type) => {
11
15
  case 'string':
12
16
  return `typeof ${accessor} !== "string"`;
13
17
  case 'number':
14
- case 'integer':
15
18
  return `typeof ${accessor} !== "number"`;
19
+ case 'integer':
20
+ // `integer` also rejects non-integral numbers; a bare typeof accepts `1.5`.
21
+ return `(typeof ${accessor} !== "number" || !Number.isInteger(${accessor}))`;
16
22
  case 'boolean':
17
23
  return `typeof ${accessor} !== "boolean"`;
18
24
  case 'array':
@@ -28,6 +34,18 @@ const wrongTypeCondition = (accessor, type) => {
28
34
  * `integer` collapses to `number` since both are validated via `typeof === "number"`.
29
35
  */
30
36
  const typeLabel = (type) => (type === 'integer' ? 'number' : type);
37
+ /**
38
+ * 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`).
44
+ */
45
+ const throwError = (message, suffixExpr) => {
46
+ const literal = JSON.stringify(message);
47
+ return suffixExpr ? `throw new Error(${literal} + (${suffixExpr}))` : `throw new Error(${literal})`;
48
+ };
31
49
  /**
32
50
  * Generates strict-mode constraint checks for a typed property
33
51
  * (pattern, length, min/max, multipleOf).
@@ -37,69 +55,125 @@ const generateConstraintChecks = (acc, propSchema, typeName, key) => {
37
55
  return [];
38
56
  const t = propSchema.type;
39
57
  const lines = [];
58
+ const field = `[${typeName}] field '${key}'`;
40
59
  if (t === 'string') {
41
60
  if (hasPattern(propSchema)) {
42
61
  const pattern = escapeRegexPattern(propSchema.pattern);
43
- lines.push(` if (typeof ${acc} === "string" && !/${pattern}/.test(${acc})) throw new Error('[${typeName}] field "${key}" must match pattern ${propSchema.pattern}');`);
62
+ lines.push(` if (typeof ${acc} === "string" && !/${pattern}/.test(${acc})) ${throwError(`${field} must match pattern ${propSchema.pattern}`)};`);
44
63
  }
45
64
  if (hasMinLength(propSchema)) {
46
- lines.push(` if (typeof ${acc} === "string" && ${acc}.length < ${propSchema.minLength}) throw new Error('[${typeName}] field "${key}" must have at least ${propSchema.minLength} characters');`);
65
+ lines.push(` if (typeof ${acc} === "string" && ${acc}.length < ${propSchema.minLength}) ${throwError(`${field} must have at least ${propSchema.minLength} characters`)};`);
47
66
  }
48
67
  if (hasMaxLength(propSchema)) {
49
- lines.push(` if (typeof ${acc} === "string" && ${acc}.length > ${propSchema.maxLength}) throw new Error('[${typeName}] field "${key}" must have at most ${propSchema.maxLength} characters');`);
68
+ lines.push(` if (typeof ${acc} === "string" && ${acc}.length > ${propSchema.maxLength}) ${throwError(`${field} must have at most ${propSchema.maxLength} characters`)};`);
50
69
  }
51
70
  }
52
71
  if (t === 'number' || t === 'integer') {
53
72
  if (hasMinimum(propSchema)) {
54
- lines.push(` if (typeof ${acc} === "number" && ${acc} < ${propSchema.minimum}) throw new Error('[${typeName}] field "${key}" must be >= ${propSchema.minimum}');`);
73
+ lines.push(` if (typeof ${acc} === "number" && ${acc} < ${propSchema.minimum}) ${throwError(`${field} must be >= ${propSchema.minimum}`)};`);
55
74
  }
56
75
  if (hasMaximum(propSchema)) {
57
- lines.push(` if (typeof ${acc} === "number" && ${acc} > ${propSchema.maximum}) throw new Error('[${typeName}] field "${key}" must be <= ${propSchema.maximum}');`);
76
+ lines.push(` if (typeof ${acc} === "number" && ${acc} > ${propSchema.maximum}) ${throwError(`${field} must be <= ${propSchema.maximum}`)};`);
58
77
  }
59
78
  if (hasExclusiveMinimum(propSchema)) {
60
- lines.push(` if (typeof ${acc} === "number" && ${acc} <= ${propSchema.exclusiveMinimum}) throw new Error('[${typeName}] field "${key}" must be > ${propSchema.exclusiveMinimum}');`);
79
+ lines.push(` if (typeof ${acc} === "number" && ${acc} <= ${propSchema.exclusiveMinimum}) ${throwError(`${field} must be > ${propSchema.exclusiveMinimum}`)};`);
61
80
  }
62
81
  if (hasExclusiveMaximum(propSchema)) {
63
- lines.push(` if (typeof ${acc} === "number" && ${acc} >= ${propSchema.exclusiveMaximum}) throw new Error('[${typeName}] field "${key}" must be < ${propSchema.exclusiveMaximum}');`);
82
+ lines.push(` if (typeof ${acc} === "number" && ${acc} >= ${propSchema.exclusiveMaximum}) ${throwError(`${field} must be < ${propSchema.exclusiveMaximum}`)};`);
64
83
  }
65
84
  if (hasMultipleOf(propSchema)) {
66
- lines.push(` if (typeof ${acc} === "number" && ${acc} % ${propSchema.multipleOf} !== 0) throw new Error('[${typeName}] field "${key}" must be a multiple of ${propSchema.multipleOf}');`);
85
+ lines.push(` if (typeof ${acc} === "number" && ${multipleOfFailExpr(acc, propSchema.multipleOf)}) ${throwError(`${field} must be a multiple of ${propSchema.multipleOf}`)};`);
86
+ }
87
+ }
88
+ if (t === 'array') {
89
+ if (hasMinItems(propSchema)) {
90
+ lines.push(` if (Array.isArray(${acc}) && ${acc}.length < ${propSchema.minItems}) ${throwError(`${field} must have at least ${propSchema.minItems} items`)};`);
91
+ }
92
+ if (hasMaxItems(propSchema)) {
93
+ lines.push(` if (Array.isArray(${acc}) && ${acc}.length > ${propSchema.maxItems}) ${throwError(`${field} must have at most ${propSchema.maxItems} items`)};`);
94
+ }
95
+ if (hasUniqueItems(propSchema) && propSchema.uniqueItems === true) {
96
+ // Deep-equality dedupe via JSON keys, matching the interpreter's semantics —
97
+ // a plain `new Set` would only catch primitive duplicates, not equal objects.
98
+ lines.push(` if (Array.isArray(${acc}) && new Set(${acc}.map((_u) => JSON.stringify(_u))).size !== ${acc}.length) ${throwError(`${field} must NOT have duplicate items`)};`);
99
+ }
100
+ // Item types: the fast path proves them via `.every`, but this slow path
101
+ // used to check only length/uniqueness, letting e.g. a number slip into a
102
+ // declared `string[]`. Enforce scalar and enum item schemas here; richer
103
+ // item schemas ($refs, objects) are validated by their own parsers.
104
+ const itemCheck = generateItemCheck(propSchema);
105
+ if (itemCheck) {
106
+ lines.push(` if (Array.isArray(${acc}) && !${acc}.every((_it) => ${itemCheck.check})) ${throwError(`${field} ${itemCheck.message}`)};`);
67
107
  }
68
108
  }
69
109
  return lines;
70
110
  };
111
+ /**
112
+ * Boolean per-item check (bound to `_it`) for an array schema's `items`, with
113
+ * the error-message fragment describing what was expected. Only scalar types
114
+ * and enums are checked — returns null for anything richer.
115
+ */
116
+ const generateItemCheck = (schema) => {
117
+ if (!isSchemaObject(schema) || !hasItems(schema) || Array.isArray(schema.items))
118
+ return null;
119
+ const items = schema.items;
120
+ if (isSchemaObject(items) && hasEnum(items) && items.enum.length > 0) {
121
+ const label = items.enum.map((v) => JSON.stringify(v)).join(', ');
122
+ return { check: generateEnumCheck('_it', items.enum), message: `items must be one of: ${label}` };
123
+ }
124
+ const scalarCheck = scalarItemTypeCheck(items, '_it');
125
+ if (scalarCheck === null || !isSchemaObject(items) || !hasType(items))
126
+ return null;
127
+ return { check: scalarCheck, message: `items expected ${typeLabel(items.type)}` };
128
+ };
71
129
  /**
72
130
  * Generates strict-mode lines for a single property of an object schema.
73
131
  * Properties with a `$ref` are skipped here — the nested parser handles its own
74
132
  * strict check when called from the parent's slow path.
75
133
  */
76
- const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
134
+ const generatePropertyAssertion = (key, propSchema, isRequired, typeName, context = {}) => {
77
135
  const acc = safeAccessor('input', key);
136
+ const field = `[${typeName}] field '${key}'`;
78
137
  const lines = [];
79
138
  if (isRequired) {
80
- lines.push(` if (!(${JSON.stringify(key)} in input)) throw new Error('[${typeName}] missing required property "${key}"');`);
139
+ lines.push(` if (!(${JSON.stringify(key)} in input)) ${throwError(`[${typeName}] missing required property '${key}'`)};`);
81
140
  }
82
141
  if (!isSchemaObject(propSchema))
83
142
  return lines;
84
143
  if (hasRef(propSchema))
85
144
  return lines;
145
+ // Union properties: enforce membership when every branch check is
146
+ // false-sound (canEnforceUnion), so a value matching no variant throws
147
+ // instead of passing through untyped. Left unenforced (pass-through, the
148
+ // historical behavior) when any branch is too complex to check safely.
149
+ const unionBranches = getUnionBranches(propSchema);
150
+ if (unionBranches) {
151
+ if (!context.stripUnknown && canEnforceUnion(unionBranches, context.rootSchema)) {
152
+ const check = generateUnionCheck(acc, unionBranches, context.useRefImports ?? false, context.suffix ?? '');
153
+ if (check !== null) {
154
+ const failure = throwError(`${field} does not match any allowed variant`);
155
+ lines.push(isRequired ? ` if (!(${check})) ${failure};` : ` if (${acc} !== undefined && !(${check})) ${failure};`);
156
+ }
157
+ }
158
+ return lines;
159
+ }
86
160
  const instanceOf = getMjstInstanceOf(propSchema);
87
161
  if (instanceOf) {
88
162
  if (isRequired) {
89
- lines.push(` if (!(${acc} instanceof ${instanceOf})) throw new Error('[${typeName}] field "${key}" must be ${instanceOf}');`);
163
+ lines.push(` if (!(${acc} instanceof ${instanceOf})) ${throwError(`${field} must be ${instanceOf}`)};`);
90
164
  }
91
165
  else {
92
- lines.push(` if (${acc} !== undefined && !(${acc} instanceof ${instanceOf})) throw new Error('[${typeName}] field "${key}" must be ${instanceOf}');`);
166
+ lines.push(` if (${acc} !== undefined && !(${acc} instanceof ${instanceOf})) ${throwError(`${field} must be ${instanceOf}`)};`);
93
167
  }
94
168
  return lines;
95
169
  }
96
170
  const primitive = getMjstPrimitive(propSchema);
97
171
  if (primitive) {
98
172
  if (isRequired) {
99
- lines.push(` if (typeof ${acc} !== "${primitive}") throw new Error('[${typeName}] field "${key}" must be ${primitive}');`);
173
+ lines.push(` if (typeof ${acc} !== "${primitive}") ${throwError(`${field} must be ${primitive}`)};`);
100
174
  }
101
175
  else {
102
- lines.push(` if (${acc} !== undefined && typeof ${acc} !== "${primitive}") throw new Error('[${typeName}] field "${key}" must be ${primitive}');`);
176
+ lines.push(` if (${acc} !== undefined && typeof ${acc} !== "${primitive}") ${throwError(`${field} must be ${primitive}`)};`);
103
177
  }
104
178
  return lines;
105
179
  }
@@ -107,10 +181,10 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
107
181
  const allowed = JSON.stringify(propSchema.enum);
108
182
  const label = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
109
183
  if (isRequired) {
110
- lines.push(` if (!(${allowed} as readonly unknown[]).includes(${acc})) throw new Error('[${typeName}] field "${key}" must be one of: ${label}');`);
184
+ lines.push(` if (!(${allowed} as readonly unknown[]).includes(${acc})) ${throwError(`${field} must be one of: ${label}`)};`);
111
185
  }
112
186
  else {
113
- lines.push(` if (${acc} !== undefined && !(${allowed} as readonly unknown[]).includes(${acc})) throw new Error('[${typeName}] field "${key}" must be one of: ${label}');`);
187
+ lines.push(` if (${acc} !== undefined && !(${allowed} as readonly unknown[]).includes(${acc})) ${throwError(`${field} must be one of: ${label}`)};`);
114
188
  }
115
189
  return lines;
116
190
  }
@@ -118,11 +192,12 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
118
192
  const t = propSchema.type;
119
193
  const wrongType = wrongTypeCondition(acc, t);
120
194
  if (wrongType) {
195
+ const expected = throwError(`${field} expected ${typeLabel(t)}, got `, `typeof ${acc}`);
121
196
  if (isRequired) {
122
- lines.push(` if (${wrongType}) throw new Error(\`[${typeName}] field "${key}" expected ${typeLabel(t)}, got \${typeof ${acc}}\`);`);
197
+ lines.push(` if (${wrongType}) ${expected};`);
123
198
  }
124
199
  else {
125
- lines.push(` if (${acc} !== undefined && (${wrongType})) throw new Error(\`[${typeName}] field "${key}" expected ${typeLabel(t)}, got \${typeof ${acc}}\`);`);
200
+ lines.push(` if (${acc} !== undefined && (${wrongType})) ${expected};`);
126
201
  }
127
202
  }
128
203
  lines.push(...generateConstraintChecks(acc, propSchema, typeName, key));
@@ -140,14 +215,14 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
140
215
  * Properties with a `$ref` are validated by the nested parser's own strict
141
216
  * check when that parser is invoked downstream.
142
217
  */
143
- export const generateObjectStrictAssertion = (schema, typeName) => {
218
+ export const generateObjectStrictAssertion = (schema, typeName, context = {}) => {
144
219
  const lines = [];
145
- lines.push(` if (!isObject(input)) throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);`);
220
+ lines.push(` if (!isObject(input)) ${throwError(`[${typeName}] expected object, got `, 'input === null ? "null" : typeof input')};`);
146
221
  if (!hasProperties(schema) || !isSchemaObject(schema))
147
222
  return lines;
148
223
  const required = new Set(hasRequired(schema) ? schema.required : []);
149
224
  for (const [key, propSchema] of Object.entries(schema.properties)) {
150
- lines.push(...generatePropertyAssertion(key, propSchema, required.has(key), typeName));
225
+ lines.push(...generatePropertyAssertion(key, propSchema, required.has(key), typeName, context));
151
226
  }
152
227
  return lines;
153
228
  };
@@ -156,13 +231,14 @@ export const generateObjectStrictAssertion = (schema, typeName) => {
156
231
  * Returns null when the schema has no type information to assert on.
157
232
  */
158
233
  export const generateScalarStrictAssertion = (schema, typeName) => {
234
+ const got = 'input === null ? "null" : typeof input';
159
235
  const instanceOf = getMjstInstanceOf(schema);
160
236
  if (instanceOf) {
161
- return ` if (!(input instanceof ${instanceOf})) throw new Error(\`[${typeName}] expected ${instanceOf}, got \${input === null ? "null" : typeof input}\`);`;
237
+ return ` if (!(input instanceof ${instanceOf})) ${throwError(`[${typeName}] expected ${instanceOf}, got `, got)};`;
162
238
  }
163
239
  const primitive = getMjstPrimitive(schema);
164
240
  if (primitive) {
165
- return ` if (typeof input !== "${primitive}") throw new Error(\`[${typeName}] expected ${primitive}, got \${input === null ? "null" : typeof input}\`);`;
241
+ return ` if (typeof input !== "${primitive}") ${throwError(`[${typeName}] expected ${primitive}, got `, got)};`;
166
242
  }
167
243
  if (!isSchemaObject(schema) || !hasType(schema))
168
244
  return null;
@@ -170,5 +246,14 @@ export const generateScalarStrictAssertion = (schema, typeName) => {
170
246
  const wrongType = wrongTypeCondition('input', t);
171
247
  if (!wrongType)
172
248
  return null;
173
- return ` if (${wrongType}) throw new Error(\`[${typeName}] expected ${typeLabel(t)}, got \${input === null ? "null" : typeof input}\`);`;
249
+ const lines = [` if (${wrongType}) ${throwError(`[${typeName}] expected ${typeLabel(t)}, got `, got)};`];
250
+ // Root-level arrays enforce scalar/enum item types too — the same gap the
251
+ // property path closes in generateConstraintChecks.
252
+ if (t === 'array') {
253
+ const itemCheck = generateItemCheck(schema);
254
+ if (itemCheck) {
255
+ lines.push(` if (!(input as readonly unknown[]).every((_it) => ${itemCheck.check})) ${throwError(`[${typeName}] ${itemCheck.message}`)};`);
256
+ }
257
+ }
258
+ return lines.join('\n');
174
259
  };
@@ -0,0 +1,65 @@
1
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
+ /**
3
+ * Boolean type-check expression builders shared by the shape validators, the
4
+ * parsers' fast paths, and the strict-mode assertions. They live in their own
5
+ * module (rather than generate-parser-function) so generate-strict-assertion
6
+ * can use them without an import cycle.
7
+ *
8
+ * Every expression built here is *true-sound*: when it evaluates to true the
9
+ * value definitely matches the schema fragment. That is all a fast path needs.
10
+ * The reverse direction — false definitely means invalid — only holds when
11
+ * {@link canEnforceUnion} approves the schema, because a check may call an
12
+ * imported `validate{X}Shape` that was generated as a conservative `=> false`
13
+ * stub. Strict-mode code that *throws* on a false check must gate on it.
14
+ */
15
+ /**
16
+ * Returns the shape-predicate function name for a given type name.
17
+ * The predicate is generated alongside each parser and tells callers
18
+ * whether `input` is already in the shape produced by the parser's fast path.
19
+ */
20
+ export declare const shapeValidatorName: (typeName: string) => string;
21
+ /**
22
+ * Extracts the branch list of a `oneOf`/`anyOf` union, or `null` when the
23
+ * schema is not a union (or mixes in other composition keywords we cannot
24
+ * turn into a membership check).
25
+ */
26
+ export declare const getUnionBranches: (schema: JSONSchema) => readonly JSONSchema[] | null;
27
+ /**
28
+ * Generates a fast-path type check expression for a property.
29
+ * Returns null if the schema is too complex for a simple type check.
30
+ *
31
+ * When `useRefImports` is true, $ref properties (and arrays of $refs) are
32
+ * checked by calling the imported shape predicate, allowing parent parsers
33
+ * to fast-path through nested ref types without recursing into their parsers.
34
+ */
35
+ export declare const generatePropertyTypeCheck: (varName: string, schema: JSONSchema, useRefImports: boolean, suffix: string) => string | null;
36
+ /**
37
+ * Generates a boolean check that `varName` matches an inline object schema —
38
+ * `isObject` plus a per-property check for every declared property (and a
39
+ * presence check for `required` keys without a declared schema). Used for
40
+ * union branches written inline, where no named shape validator exists to
41
+ * call. Returns null when any property is too complex to check.
42
+ */
43
+ export declare const generateInlineObjectCheck: (varName: string, schema: JSONSchema, useRefImports: boolean, suffix: string) => string | null;
44
+ /**
45
+ * Generates a union membership check: true when `varName` matches at least one
46
+ * branch of a `oneOf`/`anyOf`. Inline object branches get a per-property check;
47
+ * everything else goes through {@link generatePropertyTypeCheck} ($refs call
48
+ * the imported shape validator, consts/enums/scalars check directly). Returns
49
+ * null when any branch cannot be checked.
50
+ */
51
+ export declare const generateUnionCheck: (varName: string, branches: readonly JSONSchema[], useRefImports: boolean, suffix: string) => string | null;
52
+ /**
53
+ * True when the union membership check emitted by {@link generateUnionCheck}
54
+ * is also *false-sound* — a false result definitely means the value is invalid
55
+ * — so strict mode may throw on it.
56
+ *
57
+ * The risk is `$ref`s: a branch (or a property inside one) whose check calls
58
+ * an imported `validate{X}Shape` is only trustworthy when that validator was
59
+ * generated as a real predicate. This walks every reachable ref (mirroring
60
+ * generateShapeValidator's stub conditions) and rejects the union when any of
61
+ * them would have produced the conservative `=> false` stub, or is itself
62
+ * built on one. Cycles (e.g. a recursive `expr` union) are assumed sound —
63
+ * the recursion bottoms out on the data, not the schema.
64
+ */
65
+ export declare const canEnforceUnion: (branches: readonly JSONSchema[], rootSchema: Record<string, unknown> | undefined) => boolean;