@amritk/generate-parsers 0.16.0 → 0.16.2

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,8 +1,158 @@
1
1
  import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
2
2
  import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
3
3
  import { multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
4
- import { hasAdditionalProperties, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasPattern, hasProperties, hasRequired, hasType, hasUniqueItems, } from '@amritk/helpers/schema-guards';
4
+ import { hasAdditionalProperties, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasPattern, hasProperties, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
5
5
  import { generateEnumCheck } from './generate-enum-check.js';
6
+ // Keywords that only make sense on a given type. A branch that carries any of
7
+ // them but no explicit `type` is treated as that type for discrimination.
8
+ const OBJECT_KEYWORDS = [
9
+ 'properties',
10
+ 'required',
11
+ 'minProperties',
12
+ 'maxProperties',
13
+ 'additionalProperties',
14
+ 'patternProperties',
15
+ 'dependentRequired',
16
+ 'propertyNames',
17
+ ];
18
+ const ARRAY_KEYWORDS = ['items', 'prefixItems', 'minItems', 'maxItems', 'uniqueItems', 'contains'];
19
+ const STRING_KEYWORDS = ['minLength', 'maxLength', 'pattern', 'format'];
20
+ const NUMBER_KEYWORDS = ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf'];
21
+ const countKeywords = (schema, keywords) => keywords.reduce((total, keyword) => (keyword in schema ? total + 1 : total), 0);
22
+ /** Resolves the JSON Schema type of a single literal (a `const` value). */
23
+ const typeOfLiteral = (value) => {
24
+ if (value === null)
25
+ return 'null';
26
+ if (Array.isArray(value))
27
+ return 'array';
28
+ const type = typeof value;
29
+ if (type === 'boolean')
30
+ return 'boolean';
31
+ if (type === 'number' || type === 'bigint')
32
+ return 'number';
33
+ if (type === 'string')
34
+ return 'string';
35
+ return 'object';
36
+ };
37
+ /**
38
+ * Infers the type of a schema branch that omits an explicit `type`.
39
+ *
40
+ * A `const` pins the value to one literal, so its type is definitive. An enum
41
+ * of only `null`s can only be the null type. Otherwise each candidate type is
42
+ * scored by how many of its characteristic keywords the schema carries and the
43
+ * highest wins, with ties resolved in `object > array > string > number` order
44
+ * (so `{ minProperties, minItems }` is an object and `{ minItems, maxItems,
45
+ * minLength }` is an array). Returns undefined when nothing points at a type —
46
+ * e.g. a bare `{ description }` — so the caller emits no checks.
47
+ */
48
+ const inferSchemaType = (schema) => {
49
+ if (!isSchemaObject(schema))
50
+ return undefined;
51
+ if (hasConst(schema)) {
52
+ return typeOfLiteral(schema.const);
53
+ }
54
+ if (hasEnum(schema) && schema.enum.length > 0 && schema.enum.every((value) => value === null)) {
55
+ return 'null';
56
+ }
57
+ const scores = {
58
+ object: countKeywords(schema, OBJECT_KEYWORDS),
59
+ array: countKeywords(schema, ARRAY_KEYWORDS),
60
+ string: countKeywords(schema, STRING_KEYWORDS),
61
+ number: countKeywords(schema, NUMBER_KEYWORDS),
62
+ };
63
+ // Iterating in priority order and only replacing on a strictly-greater score
64
+ // means the earliest (highest-priority) type wins any tie.
65
+ let best;
66
+ for (const type of ['object', 'array', 'string', 'number']) {
67
+ if (scores[type] > 0 && (best === undefined || scores[type] > scores[best])) {
68
+ best = type;
69
+ }
70
+ }
71
+ return best;
72
+ };
73
+ /**
74
+ * Emits the checks for a branch whose type was inferred (not explicit). It
75
+ * mirrors the explicit-type switch below, with two deliberate differences: the
76
+ * enum membership check uses the `.includes` form (appended by the caller) and
77
+ * `multipleOf` uses the plain `% === 0` form, matching the inference spec.
78
+ */
79
+ const generateInferredChecks = (accessor, schema, type) => {
80
+ const checks = [];
81
+ switch (type) {
82
+ case 'object':
83
+ checks.push(`typeof ${accessor} === "object" && ${accessor} !== null && !Array.isArray(${accessor})`);
84
+ if (hasRequired(schema) && schema.required.length > 0) {
85
+ for (const requiredKey of schema.required) {
86
+ checks.push(`${JSON.stringify(requiredKey)} in ${accessor}`);
87
+ }
88
+ }
89
+ if (hasMinProperties(schema)) {
90
+ checks.push(`Object.keys(${accessor}).length >= ${schema.minProperties}`);
91
+ }
92
+ if (hasMaxProperties(schema)) {
93
+ checks.push(`Object.keys(${accessor}).length <= ${schema.maxProperties}`);
94
+ }
95
+ if (hasAdditionalProperties(schema) && schema.additionalProperties === false && hasProperties(schema)) {
96
+ const allowedKeysStr = JSON.stringify(Object.keys(schema.properties));
97
+ checks.push(`Object.keys(${accessor}).every(k => ${allowedKeysStr}.includes(k))`);
98
+ }
99
+ break;
100
+ case 'array':
101
+ checks.push(`Array.isArray(${accessor})`);
102
+ if (hasMinItems(schema)) {
103
+ checks.push(`${accessor}.length >= ${schema.minItems}`);
104
+ }
105
+ if (hasMaxItems(schema)) {
106
+ checks.push(`${accessor}.length <= ${schema.maxItems}`);
107
+ }
108
+ if (hasUniqueItems(schema) && schema.uniqueItems === true) {
109
+ checks.push(`new Set(${accessor}).size === ${accessor}.length`);
110
+ }
111
+ break;
112
+ case 'string':
113
+ checks.push(`typeof ${accessor} === "string"`);
114
+ if (hasPattern(schema)) {
115
+ checks.push(`/${escapeRegexPattern(schema.pattern)}/.test(${accessor})`);
116
+ }
117
+ if (hasMinLength(schema)) {
118
+ checks.push(`${accessor}.length >= ${schema.minLength}`);
119
+ }
120
+ if (hasMaxLength(schema)) {
121
+ checks.push(`${accessor}.length <= ${schema.maxLength}`);
122
+ }
123
+ break;
124
+ case 'number':
125
+ checks.push(`typeof ${accessor} === "number"`);
126
+ if (hasMinimum(schema)) {
127
+ checks.push(`${accessor} >= ${schema.minimum}`);
128
+ }
129
+ if (hasMaximum(schema)) {
130
+ checks.push(`${accessor} <= ${schema.maximum}`);
131
+ }
132
+ if (hasExclusiveMinimum(schema)) {
133
+ checks.push(`${accessor} > ${schema.exclusiveMinimum}`);
134
+ }
135
+ if (hasExclusiveMaximum(schema)) {
136
+ checks.push(`${accessor} < ${schema.exclusiveMaximum}`);
137
+ }
138
+ if (hasMultipleOf(schema)) {
139
+ checks.push(`${accessor} % ${schema.multipleOf} === 0`);
140
+ }
141
+ break;
142
+ case 'boolean':
143
+ checks.push(`typeof ${accessor} === "boolean"`);
144
+ break;
145
+ case 'null':
146
+ // A `null` inferred from an all-null enum is covered by the membership
147
+ // check the caller appends; only a `const: null` needs its own identity
148
+ // check here.
149
+ if (hasConst(schema)) {
150
+ checks.push(`${accessor} === null`);
151
+ }
152
+ break;
153
+ }
154
+ return checks;
155
+ };
6
156
  /**
7
157
  * Generates validation checks for a single schema (used in union validation).
8
158
  */
@@ -18,7 +168,17 @@ export const generateSchemaChecks = (accessor, schema) => {
18
168
  if (primitive) {
19
169
  return [`typeof ${accessor} === "${primitive}"`];
20
170
  }
171
+ // Without an explicit `type`, a branch's keywords still imply one. Inferring
172
+ // it lets union discrimination reject the branch instead of matching anything.
21
173
  if (!hasType(schema)) {
174
+ const inferred = inferSchemaType(schema);
175
+ if (!inferred) {
176
+ return checks;
177
+ }
178
+ checks.push(...generateInferredChecks(accessor, schema, inferred));
179
+ if (hasEnum(schema) && schema.enum.length > 0) {
180
+ checks.push(`${JSON.stringify(schema.enum)}.includes(${accessor} as never)`);
181
+ }
22
182
  return checks;
23
183
  }
24
184
  switch (schema.type) {
@@ -14,6 +14,27 @@ export type StrictAssertionContext = {
14
14
  readonly rootSchema?: Record<string, unknown>;
15
15
  readonly stripUnknown?: boolean;
16
16
  };
17
+ /**
18
+ * Strict-mode `contains` / `minContains` / `maxContains`: at least `minContains`
19
+ * (default 1) and at most `maxContains` (default ∞) items of the array must
20
+ * match the `contains` subschema. Runtime-gated on `Array.isArray` so a
21
+ * non-array value is left to the type check, and wrapped in a block so the
22
+ * per-array count variable never collides with a sibling check. `minContains: 0`
23
+ * makes the lower bound trivially satisfied. Emits nothing when there is no
24
+ * `contains` or its subschema is not matchable inline (the generation-time guard
25
+ * rejects the latter, so a strict parser never silently drops the constraint).
26
+ */
27
+ export declare const generateContainsCheck: (acc: string, schema: JSONSchema, label: string) => string[];
28
+ /**
29
+ * Emits the object-level keyword checks (`dependentRequired`, `dependentSchemas`,
30
+ * `propertyNames`) for the object reached by `obj`. When `guard` is true the
31
+ * whole block is wrapped in a runtime `isObject` check — used for object-typed
32
+ * *properties*, whose value may be absent or a non-object (the type check
33
+ * reports that separately). The root object and inline sub-parsers pass
34
+ * `guard = false` because the enclosing parser has already proven the value is
35
+ * an object.
36
+ */
37
+ export declare const generateObjectKeywordChecks: (obj: string, schema: JSONSchema, label: string, guard: boolean) => string[];
17
38
  /**
18
39
  * Generates strict-mode assertions for the body of an object parser.
19
40
  * Throws on:
@@ -30,4 +51,4 @@ export declare const generateObjectStrictAssertion: (schema: JSONSchema, typeNam
30
51
  * Generates a single strict-mode assertion line for a non-object scalar parser.
31
52
  * Returns null when the schema has no type information to assert on.
32
53
  */
33
- export declare const generateScalarStrictAssertion: (schema: JSONSchema, typeName: string) => string | null;
54
+ export declare const generateScalarStrictAssertion: (schema: JSONSchema, typeName: string, rootSchema?: Record<string, unknown>) => string | null;
@@ -2,11 +2,13 @@ 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
4
  import { quoteJsString } from '@amritk/helpers/quote-js-string';
5
+ import { resolveRef } from '@amritk/helpers/resolve-ref';
5
6
  import { safeAccessor } from '@amritk/helpers/safe-accessor';
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';
7
+ import { hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
7
8
  import { generateEnumCheck } from './generate-enum-check.js';
8
- import { canEnforceUnion, generateUnionCheck, getUnionBranches } from './generate-type-checks.js';
9
- import { scalarItemTypeCheck } from './generate-validation-expression.js';
9
+ import { canEnforceUnion, generateUnionCheck, getUnionBranches, isInlineObjectProperty } from './generate-type-checks.js';
10
+ import { getPrefixItems, prefixItemsCapsLength, scalarItemTypeCheck } from './generate-validation-expression.js';
11
+ import { subschemaMatchExpr } from './subschema-match.js';
10
12
  /**
11
13
  * Returns the inline condition that is true when `accessor` is the wrong type
12
14
  * for the given JSON Schema primitive type.
@@ -55,12 +57,11 @@ const throwError = (message, suffixExpr) => {
55
57
  * Generates strict-mode constraint checks for a typed property
56
58
  * (pattern, length, min/max, multipleOf).
57
59
  */
58
- const generateConstraintChecks = (acc, propSchema, typeName, key) => {
60
+ const generateConstraintChecks = (acc, propSchema, field, context = {}) => {
59
61
  if (!isSchemaObject(propSchema) || !hasType(propSchema))
60
62
  return [];
61
63
  const t = propSchema.type;
62
64
  const lines = [];
63
- const field = `[${typeName}] field '${key}'`;
64
65
  if (t === 'string') {
65
66
  if (hasPattern(propSchema)) {
66
67
  const pattern = escapeRegexPattern(propSchema.pattern);
@@ -110,6 +111,8 @@ const generateConstraintChecks = (acc, propSchema, typeName, key) => {
110
111
  if (itemCheck) {
111
112
  lines.push(` if (Array.isArray(${acc}) && !${acc}.every((_it) => ${itemCheck.check})) ${throwError(`${field} ${itemCheck.message}`)};`);
112
113
  }
114
+ // Tuple `prefixItems`: assert each position and cap length under items:false.
115
+ lines.push(...generatePrefixItemsAssertion(acc, field, propSchema, context.rootSchema));
113
116
  }
114
117
  return lines;
115
118
  };
@@ -131,6 +134,172 @@ const generateItemCheck = (schema) => {
131
134
  return null;
132
135
  return { check: scalarCheck, message: `items expected ${typeLabel(items.type)}` };
133
136
  };
137
+ /**
138
+ * Strict-mode `contains` / `minContains` / `maxContains`: at least `minContains`
139
+ * (default 1) and at most `maxContains` (default ∞) items of the array must
140
+ * match the `contains` subschema. Runtime-gated on `Array.isArray` so a
141
+ * non-array value is left to the type check, and wrapped in a block so the
142
+ * per-array count variable never collides with a sibling check. `minContains: 0`
143
+ * makes the lower bound trivially satisfied. Emits nothing when there is no
144
+ * `contains` or its subschema is not matchable inline (the generation-time guard
145
+ * rejects the latter, so a strict parser never silently drops the constraint).
146
+ */
147
+ export const generateContainsCheck = (acc, schema, label) => {
148
+ if (!isSchemaObject(schema))
149
+ return [];
150
+ const sp = schema;
151
+ if (!('contains' in sp))
152
+ return [];
153
+ const match = subschemaMatchExpr('_c', sp['contains']);
154
+ if (match === null)
155
+ return [];
156
+ const min = typeof sp['minContains'] === 'number' ? sp['minContains'] : 1;
157
+ const max = typeof sp['maxContains'] === 'number' ? sp['maxContains'] : undefined;
158
+ const bound = max !== undefined ? `_cn < ${min} || _cn > ${max}` : `_cn < ${min}`;
159
+ return [
160
+ ` if (Array.isArray(${acc})) {`,
161
+ ` const _cn = (${acc} as unknown[]).filter((_c) => ${match}).length;`,
162
+ ` if (${bound}) ${throwError(`${label} array does not contain the required matching items`)};`,
163
+ ` }`,
164
+ ];
165
+ };
166
+ /**
167
+ * Strict-mode `dependentRequired`: when a trigger key is present, every declared
168
+ * dependency key must be present too.
169
+ */
170
+ const generateDependentRequiredChecks = (obj, schema, label) => {
171
+ if (!hasDependentRequired(schema))
172
+ return [];
173
+ const lines = [];
174
+ for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
175
+ if (!Array.isArray(deps))
176
+ continue;
177
+ for (const dep of deps) {
178
+ lines.push(` if (${JSON.stringify(trigger)} in ${obj} && !(${JSON.stringify(dep)} in ${obj})) ${throwError(`${label} must have property '${dep}' when '${trigger}' is present`)};`);
179
+ }
180
+ }
181
+ return lines;
182
+ };
183
+ /**
184
+ * Strict-mode `dependentSchemas` (2020-12): when a trigger property is present,
185
+ * the *whole object* must also match the associated subschema. A `true`
186
+ * subschema is a no-op; a `false` subschema makes the trigger's presence always
187
+ * invalid. Object subschemas are enforced via {@link subschemaMatchExpr}; a
188
+ * subschema it cannot match inline is rejected at generation time by the guard.
189
+ */
190
+ const generateDependentSchemasChecks = (obj, schema, label) => {
191
+ const dep = schema['dependentSchemas'];
192
+ if (typeof dep !== 'object' || dep === null || Array.isArray(dep))
193
+ return [];
194
+ const lines = [];
195
+ for (const [trigger, sub] of Object.entries(dep)) {
196
+ if (sub === true)
197
+ continue;
198
+ if (sub === false) {
199
+ lines.push(` if (${JSON.stringify(trigger)} in ${obj}) ${throwError(`${label} must NOT have property '${trigger}'`)};`);
200
+ continue;
201
+ }
202
+ const match = subschemaMatchExpr(obj, sub);
203
+ if (match === null || match === 'true')
204
+ continue;
205
+ lines.push(` if (${JSON.stringify(trigger)} in ${obj} && !(${match})) ${throwError(`${label} does not satisfy the schema required when '${trigger}' is present`)};`);
206
+ }
207
+ return lines;
208
+ };
209
+ /**
210
+ * Resolves a tuple position's schema through a single `$ref` (via `rootSchema`)
211
+ * so the assertion can read its `type`/`enum`. A `$ref` with no resolvable target
212
+ * (or no root document) is returned as-is, leaving the position unasserted.
213
+ */
214
+ const resolvePositionSchema = (pos, rootSchema) => {
215
+ if (isSchemaObject(pos) && hasRef(pos) && rootSchema) {
216
+ const resolved = resolveRef(pos.$ref, rootSchema);
217
+ if (resolved)
218
+ return resolved;
219
+ }
220
+ return pos;
221
+ };
222
+ /**
223
+ * Strict-mode assertion lines for a tuple `prefixItems`: each present position is
224
+ * asserted against its subschema (a scalar type, an enum, or a `$ref`/inline
225
+ * schema resolved to one via `rootSchema`), and a sibling `items: false` /
226
+ * `additionalItems: false` rejects any extra element. `acc` is the array
227
+ * accessor and `field` the error-message prefix (`[Type] field 'k'` or
228
+ * `[Type]`). Mirrors the validators' tuple pass, but throws on the first
229
+ * violation instead of collecting errors. Positions whose schema is richer than
230
+ * a scalar/enum are left to their own downstream handling (like array `items`).
231
+ */
232
+ const generatePrefixItemsAssertion = (acc, field, schema, rootSchema) => {
233
+ const prefix = getPrefixItems(schema);
234
+ if (!prefix)
235
+ return [];
236
+ const lines = [];
237
+ for (let i = 0; i < prefix.length; i++) {
238
+ const pos = resolvePositionSchema(prefix[i], rootSchema);
239
+ const el = `${acc}[${i}]`;
240
+ // A shorter input simply has no element at this position — `prefixItems`
241
+ // does not require presence (that is `minItems`' job), so guard on length.
242
+ const present = `Array.isArray(${acc}) && ${acc}.length > ${i}`;
243
+ if (isSchemaObject(pos) && hasEnum(pos) && pos.enum.length > 0) {
244
+ const allowed = JSON.stringify(pos.enum);
245
+ const label = pos.enum.map((v) => JSON.stringify(v)).join(', ');
246
+ lines.push(` if (${present} && !(${allowed} as readonly unknown[]).includes(${el})) ${throwError(`${field}[${i}] must be one of: ${label}`)};`);
247
+ continue;
248
+ }
249
+ if (isSchemaObject(pos) && hasType(pos)) {
250
+ const pt = pos.type;
251
+ const wrong = wrongTypeCondition(el, pt);
252
+ if (wrong) {
253
+ lines.push(` if (${present} && (${wrong})) ${throwError(`${field}[${i}] expected ${typeLabel(pt)}, got `, `typeof ${el}`)};`);
254
+ }
255
+ }
256
+ }
257
+ if (prefixItemsCapsLength(schema)) {
258
+ lines.push(` if (Array.isArray(${acc}) && ${acc}.length > ${prefix.length}) ${throwError(`${field} must NOT have more than ${prefix.length} items`)};`);
259
+ }
260
+ return lines;
261
+ };
262
+ /**
263
+ * Strict-mode `propertyNames`: every own key of the object (keys are always
264
+ * strings) must satisfy the name subschema. A trivially-true matcher imposes no
265
+ * constraint, so no loop is emitted; an unmatchable subschema is rejected at
266
+ * generation time by the guard.
267
+ */
268
+ const generatePropertyNameChecks = (obj, schema, label) => {
269
+ if (!hasPropertyNames(schema) || !isSchemaObject(schema.propertyNames))
270
+ return [];
271
+ const match = subschemaMatchExpr('_name', schema.propertyNames);
272
+ if (match === null || match === 'true')
273
+ return [];
274
+ return [
275
+ ` for (const _name of Object.keys(${obj})) {`,
276
+ ` if (!(${match})) ${throwError(`${label} invalid property name: `, '_name')};`,
277
+ ` }`,
278
+ ];
279
+ };
280
+ /**
281
+ * Emits the object-level keyword checks (`dependentRequired`, `dependentSchemas`,
282
+ * `propertyNames`) for the object reached by `obj`. When `guard` is true the
283
+ * whole block is wrapped in a runtime `isObject` check — used for object-typed
284
+ * *properties*, whose value may be absent or a non-object (the type check
285
+ * reports that separately). The root object and inline sub-parsers pass
286
+ * `guard = false` because the enclosing parser has already proven the value is
287
+ * an object.
288
+ */
289
+ export const generateObjectKeywordChecks = (obj, schema, label, guard) => {
290
+ if (!isSchemaObject(schema))
291
+ return [];
292
+ const inner = [
293
+ ...generateDependentRequiredChecks(obj, schema, label),
294
+ ...generateDependentSchemasChecks(obj, schema, label),
295
+ ...generatePropertyNameChecks(obj, schema, label),
296
+ ];
297
+ if (inner.length === 0)
298
+ return [];
299
+ if (!guard)
300
+ return inner;
301
+ return [` if (isObject(${obj})) {`, ...inner.map((line) => ` ${line}`), ` }`];
302
+ };
134
303
  /**
135
304
  * Generates strict-mode lines for a single property of an object schema.
136
305
  * Properties with a `$ref` are skipped here — the nested parser handles its own
@@ -205,7 +374,24 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName, contex
205
374
  lines.push(` if (${acc} !== undefined && (${wrongType})) ${expected};`);
206
375
  }
207
376
  }
208
- lines.push(...generateConstraintChecks(acc, propSchema, typeName, key));
377
+ lines.push(...generateConstraintChecks(acc, propSchema, field, context));
378
+ }
379
+ // Type-independent constraints, each runtime-gated on the value's shape:
380
+ // - `contains` asserts on arrays (no-op otherwise),
381
+ // - the object-level keywords assert on objects.
382
+ // Gated on a single `isSchemaObject` + a few `in` checks so a plain scalar
383
+ // property — the common case — skips the costlier `isInlineObjectProperty`
384
+ // probe and the check builders entirely. Inline object properties are
385
+ // deep-validated by their own sub-parser (which runs these same object-level
386
+ // checks), so emitting them here too would be premature and redundant.
387
+ if (isSchemaObject(propSchema)) {
388
+ const r = propSchema;
389
+ if ('contains' in r)
390
+ lines.push(...generateContainsCheck(acc, propSchema, field));
391
+ if (('dependentRequired' in r || 'dependentSchemas' in r || 'propertyNames' in r) &&
392
+ !isInlineObjectProperty(propSchema)) {
393
+ lines.push(...generateObjectKeywordChecks(acc, propSchema, field, true));
394
+ }
209
395
  }
210
396
  return lines;
211
397
  };
@@ -223,12 +409,23 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName, contex
223
409
  export const generateObjectStrictAssertion = (schema, typeName, context = {}) => {
224
410
  const lines = [];
225
411
  lines.push(` if (!isObject(input)) ${throwError(`[${typeName}] expected object, got `, 'input === null ? "null" : typeof input')};`);
226
- if (!hasProperties(schema) || !isSchemaObject(schema))
412
+ if (!isSchemaObject(schema))
227
413
  return lines;
228
- const required = new Set(hasRequired(schema) ? schema.required : []);
229
- const props = schema.properties;
230
- for (const key in props) {
231
- lines.push(...generatePropertyAssertion(key, props[key], required.has(key), typeName, context));
414
+ if (hasProperties(schema)) {
415
+ const required = new Set(hasRequired(schema) ? schema.required : []);
416
+ const props = schema.properties;
417
+ for (const key in props) {
418
+ lines.push(...generatePropertyAssertion(key, props[key], required.has(key), typeName, context));
419
+ }
420
+ }
421
+ // Object-level keywords for the object itself. `input` is already proven an
422
+ // object above, so no runtime guard is needed. Emitted even when the schema
423
+ // has no `properties` (e.g. a constrained-key map: `{ type: 'object',
424
+ // propertyNames: {...} }`). Gated on a cheap presence check so a keyword-free
425
+ // object skips the three check builders.
426
+ const sr = schema;
427
+ if ('dependentRequired' in sr || 'dependentSchemas' in sr || 'propertyNames' in sr) {
428
+ lines.push(...generateObjectKeywordChecks('input', schema, `[${typeName}]`, false));
232
429
  }
233
430
  return lines;
234
431
  };
@@ -236,7 +433,7 @@ export const generateObjectStrictAssertion = (schema, typeName, context = {}) =>
236
433
  * Generates a single strict-mode assertion line for a non-object scalar parser.
237
434
  * Returns null when the schema has no type information to assert on.
238
435
  */
239
- export const generateScalarStrictAssertion = (schema, typeName) => {
436
+ export const generateScalarStrictAssertion = (schema, typeName, rootSchema) => {
240
437
  const got = 'input === null ? "null" : typeof input';
241
438
  const instanceOf = getMjstInstanceOf(schema);
242
439
  if (instanceOf) {
@@ -246,20 +443,45 @@ export const generateScalarStrictAssertion = (schema, typeName) => {
246
443
  if (primitive) {
247
444
  return ` if (typeof input !== "${primitive}") ${throwError(`[${typeName}] expected ${primitive}, got `, got)};`;
248
445
  }
249
- if (!isSchemaObject(schema) || !hasType(schema))
446
+ if (!isSchemaObject(schema))
250
447
  return null;
251
- const t = schema.type;
252
- const wrongType = wrongTypeCondition('input', t);
253
- if (!wrongType)
254
- return null;
255
- const lines = [` if (${wrongType}) ${throwError(`[${typeName}] expected ${typeLabel(t)}, got `, got)};`];
256
- // Root-level arrays enforce scalar/enum item types too — the same gap the
257
- // property path closes in generateConstraintChecks.
258
- if (t === 'array') {
259
- const itemCheck = generateItemCheck(schema);
260
- if (itemCheck) {
261
- lines.push(` if (!(input as readonly unknown[]).every((_it) => ${itemCheck.check})) ${throwError(`[${typeName}] ${itemCheck.message}`)};`);
448
+ const label = `[${typeName}]`;
449
+ const lines = [];
450
+ // `const` / `enum` apply to a root value regardless of a declared `type` — a
451
+ // root `{ enum: [...] }` or `{ const: ... }` must reject a non-member, not
452
+ // silently coerce it. `const` is compared through `JSON.stringify` so it works
453
+ // for any JSON value; the value must match exactly.
454
+ if (hasConst(schema)) {
455
+ const wanted = JSON.stringify(JSON.stringify(schema.const));
456
+ lines.push(` if (JSON.stringify(input) !== ${wanted}) ${throwError(`${label} must be ${JSON.stringify(schema.const)}`)};`);
457
+ }
458
+ else if (hasEnum(schema)) {
459
+ const allowed = JSON.stringify(schema.enum);
460
+ const enumLabel = schema.enum.map((v) => JSON.stringify(v)).join(', ');
461
+ lines.push(` if (!(${allowed} as readonly unknown[]).includes(input)) ${throwError(`${label} must be one of: ${enumLabel}`)};`);
462
+ }
463
+ if (hasType(schema)) {
464
+ const t = schema.type;
465
+ const wrongType = wrongTypeCondition('input', t);
466
+ if (wrongType) {
467
+ lines.push(` if (${wrongType}) ${throwError(`${label} expected ${typeLabel(t)}, got `, got)};`);
468
+ }
469
+ // Root-level arrays enforce scalar/enum item types too — the same gap the
470
+ // property path closes in generateConstraintChecks — plus `contains`.
471
+ if (t === 'array') {
472
+ const itemCheck = generateItemCheck(schema);
473
+ if (itemCheck) {
474
+ lines.push(` if (!(input as readonly unknown[]).every((_it) => ${itemCheck.check})) ${throwError(`${label} ${itemCheck.message}`)};`);
475
+ }
476
+ lines.push(...generateContainsCheck('input', schema, label));
477
+ // Tuple `prefixItems`: assert each position and cap length under items:false.
478
+ lines.push(...generatePrefixItemsAssertion('input', label, schema, rootSchema));
262
479
  }
480
+ // String (pattern, min/maxLength), number/integer (bounds, multipleOf) and
481
+ // array (length, uniqueItems) constraints — previously enforced only for
482
+ // named properties, so a root scalar accepted e.g. `{type:'string',minLength:5}`
483
+ // violations.
484
+ lines.push(...generateConstraintChecks('input', schema, label, rootSchema ? { rootSchema } : {}));
263
485
  }
264
- return lines.join('\n');
486
+ return lines.length > 0 ? lines.join('\n') : null;
265
487
  };
@@ -41,6 +41,15 @@ export declare const isInlineObjectArrayProperty: (propSchema: JSONSchema) => bo
41
41
  * turn into a membership check).
42
42
  */
43
43
  export declare const getUnionBranches: (schema: JSONSchema) => readonly JSONSchema[] | null;
44
+ /**
45
+ * True when a schema carries a keyword the strict slow path enforces but which
46
+ * no flat type check can mirror: `contains` (array item counting),
47
+ * `dependentRequired` / `dependentSchemas` (cross-property presence), or
48
+ * `propertyNames` (per-key constraints). A fast path or shape validator that
49
+ * accepted such a value on a bare type check would skip the enforcement, so
50
+ * both must bail when this is true — the slow path then runs the real checks.
51
+ */
52
+ export declare const hasFastPathBlockingKeyword: (schema: JSONSchema) => boolean;
44
53
  /**
45
54
  * Generates a fast-path type check expression for a property.
46
55
  * Returns null if the schema is too complex for a simple type check.
@@ -5,7 +5,7 @@ import { resolveRef } from '@amritk/helpers/resolve-ref';
5
5
  import { safeAccessor } from '@amritk/helpers/safe-accessor';
6
6
  import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasType, hasUniqueItems, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
7
7
  import { generateEnumCheck } from './generate-enum-check.js';
8
- import { scalarItemTypeCheck } from './generate-validation-expression.js';
8
+ import { getPrefixItems, prefixItemsCapsLength, scalarItemTypeCheck } from './generate-validation-expression.js';
9
9
  /**
10
10
  * Boolean type-check expression builders shared by the shape validators, the
11
11
  * parsers' fast paths, and the strict-mode assertions. They live in their own
@@ -86,6 +86,23 @@ export const getUnionBranches = (schema) => {
86
86
  return schema.anyOf;
87
87
  return null;
88
88
  };
89
+ /**
90
+ * True when a schema carries a keyword the strict slow path enforces but which
91
+ * no flat type check can mirror: `contains` (array item counting),
92
+ * `dependentRequired` / `dependentSchemas` (cross-property presence), or
93
+ * `propertyNames` (per-key constraints). A fast path or shape validator that
94
+ * accepted such a value on a bare type check would skip the enforcement, so
95
+ * both must bail when this is true — the slow path then runs the real checks.
96
+ */
97
+ export const hasFastPathBlockingKeyword = (schema) => {
98
+ if (!isSchemaObject(schema))
99
+ return false;
100
+ const record = schema;
101
+ // Plain `in` checks (not the typed `hasDependentRequired`): this runs several
102
+ // times per property during codegen, and a present-but-malformed keyword still
103
+ // belongs on the slow path, where the typed guards handle it.
104
+ return ('contains' in record || 'dependentSchemas' in record || 'propertyNames' in record || 'dependentRequired' in record);
105
+ };
89
106
  /**
90
107
  * Generates a fast-path type check expression for a property.
91
108
  * Returns null if the schema is too complex for a simple type check.
@@ -97,6 +114,19 @@ export const getUnionBranches = (schema) => {
97
114
  export const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix) => {
98
115
  if (!isSchemaObject(schema))
99
116
  return null;
117
+ // Keywords the strict slow path enforces but no flat check can prove: bail so
118
+ // neither the fast path nor the shape validator accepts a value they would
119
+ // otherwise wave through untested. Inlined here (rather than calling
120
+ // hasFastPathBlockingKeyword) to reuse the isSchemaObject narrowing above —
121
+ // this is a codegen hot path hit several times per property. Keep in sync with
122
+ // hasFastPathBlockingKeyword.
123
+ const record = schema;
124
+ if ('contains' in record ||
125
+ 'dependentSchemas' in record ||
126
+ 'propertyNames' in record ||
127
+ 'dependentRequired' in record) {
128
+ return null;
129
+ }
100
130
  // $ref via shape predicate (deep fast-path through nested types).
101
131
  if (useRefImports && hasRef(schema)) {
102
132
  const refName = refToName(schema.$ref, suffix);
@@ -195,6 +225,24 @@ export const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix
195
225
  if (itemCheck)
196
226
  checks.push(`${varName}.every((_it) => ${itemCheck})`);
197
227
  }
228
+ // Tuple `prefixItems`: every present position must already match its
229
+ // subschema, and a sibling `items: false` bars extra elements. Positions
230
+ // are checked with ref imports disabled, so no imported shape-validator
231
+ // call is introduced here — a position needing one returns null and simply
232
+ // disables the fast path, keeping the union trust-walk sound without a
233
+ // mirrored prefixItems traversal. A mistyped tuple then routes to the slow
234
+ // path (coerced) or the strict assertion (thrown).
235
+ const prefix = getPrefixItems(schema);
236
+ if (prefix) {
237
+ for (let i = 0; i < prefix.length; i++) {
238
+ const posCheck = generatePropertyTypeCheck(`${varName}[${i}]`, prefix[i], false, suffix);
239
+ if (posCheck === null)
240
+ return null;
241
+ checks.push(`(${varName}.length <= ${i} || (${posCheck}))`);
242
+ }
243
+ if (prefixItemsCapsLength(schema))
244
+ checks.push(`${varName}.length <= ${prefix.length}`);
245
+ }
198
246
  break;
199
247
  }
200
248
  case 'object':