@amritk/generate-parsers 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,9 +16,14 @@ import { generateSchemaChecks } from './generate-schema-checks.js';
16
16
  * epoch number); other classes have no safe generic coercion, so we return null
17
17
  * and the caller falls back to the default.
18
18
  */
19
- const getInstanceCoercion = (accessor, instanceOf) => {
19
+ const getInstanceCoercion = (accessor, instanceOf, defaultValue) => {
20
+ // A bare `new Date(bad)` yields an `Invalid Date` — an object that is
21
+ // `instanceof Date` yet whose every operation is `NaN`, silently poisoning
22
+ // downstream code. Construct once, then fall back to the default when the
23
+ // result is invalid. This sits on the coercion (failure) branch only, so the
24
+ // hot path never pays for it.
20
25
  if (instanceOf === 'Date')
21
- return `new Date(${accessor} as string | number | Date)`;
26
+ return `((_d) => Number.isNaN(_d.getTime()) ? ${defaultValue} : _d)(new Date(${accessor} as string | number | Date))`;
22
27
  return null;
23
28
  };
24
29
  /**
@@ -57,6 +62,29 @@ export const isCoercibleItemSchema = (itemSchema) => {
57
62
  return true;
58
63
  return isSchemaObject(itemSchema) && hasEnum(itemSchema) && itemSchema.enum.length > 0;
59
64
  };
65
+ /**
66
+ * The tuple `prefixItems` array when the schema declares one, else `null`.
67
+ * `prefixItems` (JSON Schema 2020-12) positions each element against its own
68
+ * subschema; the draft array-form `items` is handled by the existing item
69
+ * paths, not here.
70
+ */
71
+ export const getPrefixItems = (schema) => {
72
+ if (!isSchemaObject(schema))
73
+ return null;
74
+ const prefix = schema['prefixItems'];
75
+ return Array.isArray(prefix) ? prefix : null;
76
+ };
77
+ /**
78
+ * True when a sibling `items: false` (2020-12) or `additionalItems: false`
79
+ * (draft) caps a tuple's length — any element past the declared prefix is
80
+ * disallowed, so the coercer drops it and strict mode rejects it.
81
+ */
82
+ export const prefixItemsCapsLength = (schema) => {
83
+ if (!isSchemaObject(schema))
84
+ return false;
85
+ const s = schema;
86
+ return s['items'] === false || s['additionalItems'] === false;
87
+ };
60
88
  /**
61
89
  * A boolean type check for a single JSON Schema primitive type name. Unlike
62
90
  * {@link scalarItemTypeCheck} this also covers `array`/`object` (as inline shape
@@ -140,12 +168,15 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
140
168
  const instanceOf = getMjstInstanceOf(schema);
141
169
  if (instanceOf) {
142
170
  const valid = `${accessor} instanceof ${instanceOf}`;
143
- const coercion = getInstanceCoercion(accessor, instanceOf);
171
+ // The coercer's own invalid-value fallback matches the branch it sits on:
172
+ // the default for a required property, `undefined` for an optional one.
144
173
  if (isRequired) {
174
+ const coercion = getInstanceCoercion(accessor, instanceOf, defaultValue);
145
175
  return coercion
146
176
  ? `${valid} ? ${accessor} : (${accessor} !== undefined ? ${coercion} : ${defaultValue})`
147
177
  : `${valid} ? ${accessor} : ${defaultValue}`;
148
178
  }
179
+ const coercion = getInstanceCoercion(accessor, instanceOf, 'undefined');
149
180
  return coercion
150
181
  ? `${valid} ? ${accessor} : (${accessor} !== undefined ? ${coercion} : undefined)`
151
182
  : `${valid} ? ${accessor} : undefined`;
@@ -239,6 +270,17 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
239
270
  if (!hasType(schema) && !hasEnum(schema)) {
240
271
  return `${accessor} ?? ${defaultValue}`;
241
272
  }
273
+ // Tuple `prefixItems`: coerce each declared position through its own subschema
274
+ // and, with a sibling `items: false`/`additionalItems: false`, drop any element
275
+ // past the tuple length. A shorter input keeps its absent trailing positions.
276
+ // The fast path only takes a tuple whose present positions are already
277
+ // well-typed, so a mistyped position reaches here.
278
+ const prefixItems = getPrefixItems(schema);
279
+ if (hasType(schema) && schema.type === 'array' && prefixItems) {
280
+ const mapped = generatePrefixItemsMap(accessor, prefixItems, prefixItemsCapsLength(schema), rootSchema, visitedRefs, caseInsensitive);
281
+ const built = `(Array.isArray(${accessor}) ? ${mapped} : ${defaultValue})`;
282
+ return isRequired ? built : `(${accessor} !== undefined ? ${built} : undefined)`;
283
+ }
242
284
  // An array of scalar or enum items: coerce each element so e.g. `number[]`
243
285
  // actually contains numbers and an enum member set holds. The fast path only
244
286
  // takes a well-typed array, so a mistyped element reaches here. ($ref and
@@ -393,3 +435,30 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
393
435
  return `${combinedCheck} ? ${accessor} : ${enumFallback('undefined')}`;
394
436
  }
395
437
  };
438
+ /**
439
+ * Builds the coercion transform for a tuple `prefixItems` array: an index-matched
440
+ * `.map` where each declared position runs through its own subschema's coercion
441
+ * (present positions only — a shorter input keeps its absent trailing positions),
442
+ * and every element past the tuple passes through unchanged. When `capLength` is
443
+ * set (a sibling `items: false`), a trailing `.slice(0, N)` drops those extras.
444
+ * `accessor` must already be known to be an array (the caller guards with
445
+ * `Array.isArray`). `$ref` positions resolve against `rootSchema` when provided.
446
+ */
447
+ export const generatePrefixItemsMap = (accessor, prefix, capLength, rootSchema, visitedRefs, caseInsensitive) => {
448
+ const branches = prefix.map((rawPos, i) => {
449
+ // Resolve a position `$ref` to its target here: generateValidationExpression's
450
+ // own ref path rebuilds the accessor from the property key, which would
451
+ // discard the `_el` element accessor an override needs — so hand it the
452
+ // concrete schema directly instead.
453
+ let pos = rawPos;
454
+ if (isSchemaObject(pos) && hasRef(pos) && rootSchema) {
455
+ const resolved = resolveRef(pos.$ref, rootSchema);
456
+ if (resolved)
457
+ pos = resolved;
458
+ }
459
+ const posExpr = generateValidationExpression('', pos, getDefaultValue(pos), true, rootSchema, visitedRefs, '_el', true, caseInsensitive);
460
+ return `_i === ${i} ? ${posExpr}`;
461
+ });
462
+ const mapped = `(${accessor} as unknown[]).map((_el, _i) => ${[...branches, '_el'].join(' : ')})`;
463
+ return capLength ? `${mapped}.slice(0, ${prefix.length})` : mapped;
464
+ };
@@ -0,0 +1,29 @@
1
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
+ /**
3
+ * Builds a boolean expression that is `true` *exactly* when `accessor` matches
4
+ * `schema` — sound in **both** directions (true ⇒ valid, false ⇒ invalid) — or
5
+ * `null` when the schema uses a form this generator cannot prove inline.
6
+ *
7
+ * This is deliberately self-contained: it never emits a `$ref` shape-validator
8
+ * call (those can be conservative `=> false` stubs, which would break false-
9
+ * soundness) and never touches combinators. That keeps the expression usable
10
+ * both for *counting* matches (`contains`) and for *rejecting* non-matches
11
+ * (`propertyNames`, `dependentSchemas`) in strict mode, where a wrong verdict
12
+ * would silently accept or reject a document. Anything richer returns `null`,
13
+ * and `assertNoUnsupportedKeywords` turns that into a generation-time error.
14
+ *
15
+ * Constraint keywords apply per JSON Schema semantics: a `pattern`/`maxLength`
16
+ * with no `type` still constrains string instances (and is a no-op for other
17
+ * types), so a type-less schema guards each family by its runtime type rather
18
+ * than ignoring it — the subtlety `propertyNames: { maxLength: 3 }` depends on.
19
+ *
20
+ * `depth` seeds the `.every` element variable so nested array matches never
21
+ * shadow one another.
22
+ */
23
+ export declare const subschemaMatchExpr: (accessor: string, schema: JSONSchema, depth?: number) => string | null;
24
+ /**
25
+ * Whether {@link subschemaMatchExpr} can build an exact matcher for `schema`.
26
+ * The generation-time guard uses this to decide whether a `contains`,
27
+ * `propertyNames`, or `dependentSchemas` subschema is enforceable.
28
+ */
29
+ export declare const canMatchSubschema: (schema: JSONSchema) => boolean;
@@ -0,0 +1,286 @@
1
+ import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
+ import { multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
3
+ import { safeAccessor } from '@amritk/helpers/safe-accessor';
4
+ import { hasAdditionalProperties, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasPattern, hasProperties, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
5
+ import { generateEnumCheck } from './generate-enum-check.js';
6
+ /**
7
+ * Keywords `subschemaMatchExpr` reads and enforces. A subschema carrying any
8
+ * keyword outside this set (and the annotation set below) cannot be matched
9
+ * *exactly*, so the matcher bails to `null` and the generation-time guard turns
10
+ * that into a hard error rather than a permissive parser.
11
+ */
12
+ const HANDLED_KEYWORDS = new Set([
13
+ 'type',
14
+ 'enum',
15
+ 'const',
16
+ 'pattern',
17
+ 'minLength',
18
+ 'maxLength',
19
+ 'minimum',
20
+ 'maximum',
21
+ 'exclusiveMinimum',
22
+ 'exclusiveMaximum',
23
+ 'multipleOf',
24
+ 'minItems',
25
+ 'maxItems',
26
+ 'uniqueItems',
27
+ 'items',
28
+ 'required',
29
+ 'properties',
30
+ 'minProperties',
31
+ 'maxProperties',
32
+ 'additionalProperties',
33
+ ]);
34
+ /**
35
+ * Keywords that only annotate (Ajv, our differential oracle, does not assert on
36
+ * them under `strict: false`), so their presence never blocks an exact match.
37
+ * `format` is annotation-only in 2020-12 by default, matching the oracle.
38
+ */
39
+ const ANNOTATION_KEYWORDS = new Set([
40
+ 'title',
41
+ 'description',
42
+ '$comment',
43
+ '$id',
44
+ '$schema',
45
+ '$anchor',
46
+ '$vocabulary',
47
+ '$defs',
48
+ 'definitions',
49
+ 'examples',
50
+ 'default',
51
+ 'deprecated',
52
+ 'readOnly',
53
+ 'writeOnly',
54
+ 'format',
55
+ 'contentMediaType',
56
+ 'contentEncoding',
57
+ 'contentSchema',
58
+ ]);
59
+ const isObjectExpr = (acc) => `typeof ${acc} === "object" && ${acc} !== null && !Array.isArray(${acc})`;
60
+ /** True when `additionalProperties` is a constraining (non-boolean) schema. */
61
+ const hasSchemaAdditionalProperties = (schema) => hasAdditionalProperties(schema) &&
62
+ typeof schema.additionalProperties !== 'boolean';
63
+ /**
64
+ * String-family constraint checks (no type assertion). Every JSON Schema string
65
+ * keyword only asserts on string instances, so these are combined with a
66
+ * `typeof === "string"` — either as the assertion (a `type: 'string'` schema) or
67
+ * as a guard (`typeof !== "string" || …` for a type-less schema).
68
+ */
69
+ const stringConstraints = (acc, schema) => {
70
+ const c = [];
71
+ if (hasPattern(schema))
72
+ c.push(`/${escapeRegexPattern(schema.pattern)}/.test(${acc})`);
73
+ if (hasMinLength(schema))
74
+ c.push(`${acc}.length >= ${schema.minLength}`);
75
+ if (hasMaxLength(schema))
76
+ c.push(`${acc}.length <= ${schema.maxLength}`);
77
+ return c;
78
+ };
79
+ /** Number-family constraint checks (no type assertion). `integer` requires an explicit type. */
80
+ const numberConstraints = (acc, schema, integer) => {
81
+ const c = [];
82
+ if (integer)
83
+ c.push(`Number.isInteger(${acc})`);
84
+ if (hasMinimum(schema))
85
+ c.push(`${acc} >= ${schema.minimum}`);
86
+ if (hasMaximum(schema))
87
+ c.push(`${acc} <= ${schema.maximum}`);
88
+ if (hasExclusiveMinimum(schema))
89
+ c.push(`${acc} > ${schema.exclusiveMinimum}`);
90
+ if (hasExclusiveMaximum(schema))
91
+ c.push(`${acc} < ${schema.exclusiveMaximum}`);
92
+ if (hasMultipleOf(schema))
93
+ c.push(multipleOfPassExpr(acc, schema.multipleOf));
94
+ return c;
95
+ };
96
+ /** Array-family constraint checks (no `Array.isArray` assertion). `null` when an item schema is unprovable. */
97
+ const arrayConstraints = (acc, schema, depth) => {
98
+ const c = [];
99
+ if (hasMinItems(schema))
100
+ c.push(`${acc}.length >= ${schema.minItems}`);
101
+ if (hasMaxItems(schema))
102
+ c.push(`${acc}.length <= ${schema.maxItems}`);
103
+ if (hasUniqueItems(schema) && schema.uniqueItems === true) {
104
+ c.push(`new Set((${acc} as unknown[]).map((_u) => JSON.stringify(_u))).size === ${acc}.length`);
105
+ }
106
+ if (hasItems(schema)) {
107
+ if (Array.isArray(schema.items))
108
+ return null;
109
+ const el = `_i${depth}`;
110
+ const itemMatch = subschemaMatchExpr(el, schema.items, depth + 1);
111
+ if (itemMatch === null)
112
+ return null;
113
+ if (itemMatch !== 'true')
114
+ c.push(`(${acc} as unknown[]).every((${el}) => ${itemMatch})`);
115
+ }
116
+ return c;
117
+ };
118
+ /** Object-family constraint checks (no object-type assertion). `null` when a property schema is unprovable. */
119
+ const objectConstraints = (acc, schema, depth) => {
120
+ // A schema-valued additionalProperties record would leave record values unproven.
121
+ if (hasSchemaAdditionalProperties(schema))
122
+ return null;
123
+ const c = [];
124
+ const required = new Set(hasRequired(schema) ? schema.required : []);
125
+ if (hasProperties(schema)) {
126
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
127
+ const propAcc = safeAccessor(acc, key);
128
+ const propMatch = subschemaMatchExpr(propAcc, propSchema, depth + 1);
129
+ if (propMatch === null)
130
+ return null;
131
+ if (required.has(key)) {
132
+ c.push(propMatch === 'true'
133
+ ? `${JSON.stringify(key)} in ${acc}`
134
+ : `(${JSON.stringify(key)} in ${acc} && ${propMatch})`);
135
+ }
136
+ else if (propMatch !== 'true') {
137
+ c.push(`(${propAcc} === undefined || ${propMatch})`);
138
+ }
139
+ required.delete(key);
140
+ }
141
+ }
142
+ for (const key of required)
143
+ c.push(`${JSON.stringify(key)} in ${acc}`);
144
+ if (hasMinProperties(schema))
145
+ c.push(`Object.keys(${acc}).length >= ${schema.minProperties}`);
146
+ if (hasMaxProperties(schema))
147
+ c.push(`Object.keys(${acc}).length <= ${schema.maxProperties}`);
148
+ if (hasAdditionalProperties(schema) && schema.additionalProperties === false) {
149
+ const allowed = JSON.stringify(hasProperties(schema) ? Object.keys(schema.properties) : []);
150
+ c.push(`Object.keys(${acc}).every((_k) => ${allowed}.includes(_k))`);
151
+ }
152
+ return c;
153
+ };
154
+ /**
155
+ * Builds a boolean expression that is `true` *exactly* when `accessor` matches
156
+ * `schema` — sound in **both** directions (true ⇒ valid, false ⇒ invalid) — or
157
+ * `null` when the schema uses a form this generator cannot prove inline.
158
+ *
159
+ * This is deliberately self-contained: it never emits a `$ref` shape-validator
160
+ * call (those can be conservative `=> false` stubs, which would break false-
161
+ * soundness) and never touches combinators. That keeps the expression usable
162
+ * both for *counting* matches (`contains`) and for *rejecting* non-matches
163
+ * (`propertyNames`, `dependentSchemas`) in strict mode, where a wrong verdict
164
+ * would silently accept or reject a document. Anything richer returns `null`,
165
+ * and `assertNoUnsupportedKeywords` turns that into a generation-time error.
166
+ *
167
+ * Constraint keywords apply per JSON Schema semantics: a `pattern`/`maxLength`
168
+ * with no `type` still constrains string instances (and is a no-op for other
169
+ * types), so a type-less schema guards each family by its runtime type rather
170
+ * than ignoring it — the subtlety `propertyNames: { maxLength: 3 }` depends on.
171
+ *
172
+ * `depth` seeds the `.every` element variable so nested array matches never
173
+ * shadow one another.
174
+ */
175
+ export const subschemaMatchExpr = (accessor, schema, depth = 0) => {
176
+ // Boolean schemas: `true` matches everything, `false` matches nothing.
177
+ if (schema === true)
178
+ return 'true';
179
+ if (schema === false)
180
+ return 'false';
181
+ if (!isSchemaObject(schema))
182
+ return null;
183
+ // Any keyword we neither handle nor know to be a pure annotation could narrow
184
+ // the value set; matching would then be unsound, so bail.
185
+ for (const key of Object.keys(schema)) {
186
+ if (!HANDLED_KEYWORDS.has(key) && !ANNOTATION_KEYWORDS.has(key))
187
+ return null;
188
+ }
189
+ // An array-form `type` (multi-type / nullable) is a disjunction we do not
190
+ // prove inline here; `hasType` is false for it, so without this it would fall
191
+ // through to a permissive result.
192
+ if ('type' in schema && typeof schema.type !== 'string')
193
+ return null;
194
+ const checks = [];
195
+ // `const` — a single exact value. Primitives compare with `===`; a structural
196
+ // const would need deep equality, which this flat matcher does not attempt.
197
+ if (hasConst(schema)) {
198
+ const c = schema.const;
199
+ if (c !== null && typeof c === 'object')
200
+ return null;
201
+ checks.push(`${accessor} === ${JSON.stringify(c)}`);
202
+ }
203
+ // `enum` — membership in a fixed set (generateEnumCheck is exact).
204
+ if (hasEnum(schema)) {
205
+ if (schema.enum.length === 0)
206
+ return null;
207
+ checks.push(generateEnumCheck(accessor, schema.enum));
208
+ }
209
+ if (hasType(schema)) {
210
+ const typeChecks = buildTypedChecks(accessor, schema, depth);
211
+ if (typeChecks === null)
212
+ return null;
213
+ checks.push(...typeChecks);
214
+ }
215
+ else {
216
+ const typeless = buildTypelessChecks(accessor, schema, depth);
217
+ if (typeless === null)
218
+ return null;
219
+ checks.push(...typeless);
220
+ }
221
+ // A schema with no proven constraint matches every value.
222
+ if (checks.length === 0)
223
+ return 'true';
224
+ return checks.length === 1 ? checks[0] : `(${checks.join(' && ')})`;
225
+ };
226
+ /** Type assertion plus that type's constraints, for a single-`type` schema. */
227
+ const buildTypedChecks = (accessor, schema, depth) => {
228
+ switch (schema.type) {
229
+ case 'string':
230
+ return [`typeof ${accessor} === "string"`, ...stringConstraints(accessor, schema)];
231
+ case 'number':
232
+ return [`typeof ${accessor} === "number"`, ...numberConstraints(accessor, schema, false)];
233
+ case 'integer':
234
+ return [`typeof ${accessor} === "number"`, ...numberConstraints(accessor, schema, true)];
235
+ case 'boolean':
236
+ return [`typeof ${accessor} === "boolean"`];
237
+ case 'null':
238
+ return [`${accessor} === null`];
239
+ case 'array': {
240
+ const constraints = arrayConstraints(accessor, schema, depth);
241
+ if (constraints === null)
242
+ return null;
243
+ return [`Array.isArray(${accessor})`, ...constraints];
244
+ }
245
+ case 'object': {
246
+ const constraints = objectConstraints(accessor, schema, depth);
247
+ if (constraints === null)
248
+ return null;
249
+ return [isObjectExpr(accessor), ...constraints];
250
+ }
251
+ default:
252
+ return null;
253
+ }
254
+ };
255
+ /**
256
+ * Constraints on a schema with no `type`: each family only asserts on instances
257
+ * of its type, so guard each present family by its runtime type. A value of a
258
+ * different type satisfies the family vacuously.
259
+ */
260
+ const buildTypelessChecks = (accessor, schema, depth) => {
261
+ const checks = [];
262
+ const strChecks = stringConstraints(accessor, schema);
263
+ if (strChecks.length > 0)
264
+ checks.push(`(typeof ${accessor} !== "string" || ${joinAnd(strChecks)})`);
265
+ const numChecks = numberConstraints(accessor, schema, false);
266
+ if (numChecks.length > 0)
267
+ checks.push(`(typeof ${accessor} !== "number" || ${joinAnd(numChecks)})`);
268
+ const arrChecks = arrayConstraints(accessor, schema, depth);
269
+ if (arrChecks === null)
270
+ return null;
271
+ if (arrChecks.length > 0)
272
+ checks.push(`(!Array.isArray(${accessor}) || ${joinAnd(arrChecks)})`);
273
+ const objChecks = objectConstraints(accessor, schema, depth);
274
+ if (objChecks === null)
275
+ return null;
276
+ if (objChecks.length > 0)
277
+ checks.push(`(!(${isObjectExpr(accessor)}) || ${joinAnd(objChecks)})`);
278
+ return checks;
279
+ };
280
+ const joinAnd = (checks) => (checks.length === 1 ? checks[0] : `(${checks.join(' && ')})`);
281
+ /**
282
+ * Whether {@link subschemaMatchExpr} can build an exact matcher for `schema`.
283
+ * The generation-time guard uses this to decide whether a `contains`,
284
+ * `propertyNames`, or `dependentSchemas` subschema is enforceable.
285
+ */
286
+ export const canMatchSubschema = (schema) => subschemaMatchExpr('_x', schema) !== null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-parsers",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "json-schema-typed": "^8.0.1",
51
- "@amritk/helpers": "0.12.0",
51
+ "@amritk/helpers": "0.13.0",
52
52
  "@amritk/generate-markdown": "0.4.1"
53
53
  },
54
54
  "devDependencies": {