@amritk/generate-parsers 0.21.0 → 0.22.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.
package/README.md CHANGED
@@ -254,6 +254,73 @@ emitting a parser that quietly accepts what the schema forbids. Coercing
254
254
  (non-strict) parsers are documented to repair rather than reject, so they ignore
255
255
  the rejecting keywords by design.
256
256
 
257
+ ### How a coercing parser repairs a boolean
258
+
259
+ `type: 'boolean'` is repaired from a table of spellings, not from JavaScript
260
+ truthiness. `Boolean("false")` is `true` — as is `Boolean("no")`, `Boolean("0")`
261
+ and `Boolean(2)` — so a truthiness test read every conventional way of writing
262
+ *off* as *on*, which is precisely the input a coercing parser gets from
263
+ environment variables, query strings and hand-written config.
264
+
265
+ The accepted spellings are matched with surrounding whitespace trimmed and case
266
+ folded, so `FALSE` and ` No ` land with `false` and `no`:
267
+
268
+ | Input | Result |
269
+ | --- | --- |
270
+ | `true`, `yes`, `y`, `on`, `1`, the number `1` | `true` |
271
+ | `false`, `no`, `n`, `off`, `0`, `""`, the number `0` | `false` |
272
+ | anything else (`2`, `"maybe"`, an object, `null`) | the schema's `default`, else `false` |
273
+
274
+ The last row is the same rule the other scalars follow: a value that does not
275
+ denote a boolean is not repaired into a guess, it falls back to the default. This
276
+ is wider than Ajv's `coerceTypes`, which takes `"true"` / `"false"`, the numbers
277
+ `1` / `0`, and `null` (as `false`), and rejects the rest — including `"0"`,
278
+ `"1"` and `""`. A coercing parser has no "reject", so its choice is between a
279
+ default and a wrong answer. `null` is the one value both answer for and answer
280
+ differently: Ajv says `false`, a coercing parser says whatever the schema's
281
+ `default` is.
282
+
283
+ ### How a coercing parser repairs a union
284
+
285
+ A coercing parser's contract is that whatever it returns is a valid instance of
286
+ the schema that produced it, and
287
+ `src/generators/coerced-output-validity.differential.test.ts` fuzzes exactly that
288
+ property against Ajv.
289
+
290
+ A union is the hard case, because "repair this value" first has to answer "toward
291
+ which branch?". The generated parser answers it in two steps:
292
+
293
+ 1. **Recognition.** Every branch's shape predicate runs first. A value already in
294
+ a branch's shape takes that branch's parser and comes back unchanged, so valid
295
+ input costs one predicate call and is never rebuilt.
296
+ 2. **Scoring.** A value matching no branch is scored against each branch and
297
+ repaired toward the best fit. A `const` tag is near-decisive — a matching tag
298
+ names the branch, a present-but-wrong one rules it out. Below that, a present
299
+ required property is strong evidence and a present, well-typed declared
300
+ property is weak evidence. Ties keep the earliest branch, which is `anyOf`'s
301
+ own order.
302
+
303
+ Scoring is why `{ name, folder }` is repaired toward the branch that declares
304
+ `folder`, rather than toward whichever branch happens to be written first —
305
+ which would invent a `sidebar` and discard the `folder` the author wrote. Every
306
+ term is decided at build time, so the emitted code is a handful of property
307
+ reads and no schema walking, and none of it runs for input that already matches.
308
+
309
+ Every union position is dispatched: a definition, a `$ref`, an array's `items`
310
+ (including a recursive one, where a branch's parser calls back into the
311
+ dispatcher), and a union written directly as a property value. Measured over
312
+ 4000 mutated documents of the published Scalar configuration schema, **every
313
+ coerced output is a valid instance of its own schema**, and each of the 2514
314
+ documents Ajv rejects is repaired into one Ajv accepts.
315
+
316
+ Two limits are worth stating. A union carrying its *own* keywords alongside its
317
+ branches (`{ anyOf: […], required: […] }`) keeps the general coercion path,
318
+ because the dispatcher expresses the branches and not the siblings. And a union
319
+ of bare scalars in property position is left to that same path, since scoring
320
+ reads keys: with no object branch there is nothing to discriminate on, every
321
+ branch ties, and the first one wins — which is what the general path already
322
+ does.
323
+
257
324
  ### Conformance, measured
258
325
 
259
326
  The coverage above is not a claim — it is checked against the corpus every
@@ -1,5 +1,26 @@
1
1
  /**
2
- * Generates a best-guess default value from a regex pattern.
3
- * Recognizes common patterns and returns appropriate defaults.
2
+ * Builds a default string for a `pattern`-constrained schema — a value a
3
+ * coercing parser can fall back to when the input cannot be repaired.
4
+ *
5
+ * The one invariant: **a returned default always matches the pattern it was
6
+ * built from** (and any length bounds handed in alongside it). Every candidate,
7
+ * synthesized or recognized, is tested against the real `RegExp` before it is
8
+ * returned, and anything that fails is discarded. That check is the whole point.
9
+ * Without it this returned a value the schema rejects — `'^\\+?\\d{3}\\d{4}$'`
10
+ * was answered with `"+1234567890"`, which has three digits too many — so a
11
+ * coercing parser "repaired" input into a document still invalid against its own
12
+ * schema, and the array or object built around it was invalid for the same
13
+ * reason.
14
+ *
15
+ * Two sources of candidates, in order:
16
+ *
17
+ * 1. {@link synthesize}, which reads the pattern and constructs a matching
18
+ * string from it. This covers the ordinary shapes real schemas use — slugs,
19
+ * identifiers, fixed-width codes — that no fixed list can anticipate.
20
+ * 2. A short list of well-known shapes (email, UUID, URL, semver, ISO date),
21
+ * which read better than a synthesized `"aaa"` when they happen to fit.
22
+ *
23
+ * Returns `null` when nothing verifies, which callers already treat as "no
24
+ * usable default" — the safe answer, and far better than a confident wrong one.
4
25
  */
5
- export declare const generateDefaultFromPattern: (pattern: string) => string | null;
26
+ export declare const generateDefaultFromPattern: (pattern: string, minLength?: number, maxLength?: number) => string | null;
@@ -1,23 +1,220 @@
1
- const generateDefaultFromPattern = (pattern) => {
2
- if (pattern.includes("@") || pattern.includes("[a-zA-Z0-9]") && pattern.includes("\\.")) {
3
- return '"user@example.com"';
1
+ const RECOGNIZED_DEFAULTS = [
2
+ "user@example.com",
3
+ "00000000-0000-0000-0000-000000000000",
4
+ "https://example.com",
5
+ "1.0.0",
6
+ "2000-01-01",
7
+ "+1234567890"
8
+ ];
9
+ const UNSUPPORTED = /\(\?[=!<]|\\[1-9]|\\b|\\B/;
10
+ const PREFERRED = "abcdefghijklmnopqrstuvwxyz0123456789";
11
+ const escapedChar = (sequence) => {
12
+ switch (sequence) {
13
+ case "d":
14
+ return "0";
15
+ case "w":
16
+ return "a";
17
+ case "s":
18
+ return " ";
19
+ case "D":
20
+ case "S":
21
+ return "a";
22
+ case "W":
23
+ return "-";
24
+ case "n":
25
+ return "\n";
26
+ case "t":
27
+ return " ";
28
+ case "r":
29
+ return "\r";
30
+ default:
31
+ return null;
4
32
  }
5
- if (pattern.includes("[0-9a-f]") && pattern.includes("{8}") || pattern.includes("\\-")) {
6
- if (pattern.match(/[0-9a-f].*\{8\}/i) || pattern.match(/[0-9a-fA-F]{8}/)) {
7
- return '"00000000-0000-0000-0000-000000000000"';
33
+ };
34
+ const ONCE = { min: 1, max: 1 };
35
+ const pickFromClass = (body) => {
36
+ const negated = body.startsWith("^");
37
+ const source = negated ? body.slice(1) : body;
38
+ const members = [];
39
+ for (let i = 0; i < source.length; i++) {
40
+ const char = source[i];
41
+ if (char === "\\") {
42
+ const next = source[++i];
43
+ if (next === void 0)
44
+ return null;
45
+ members.push(escapedChar(next) ?? next);
46
+ continue;
47
+ }
48
+ if (source[i + 1] === "-" && i + 2 < source.length && source[i + 2] !== "]") {
49
+ const low = char;
50
+ const high = source[i + 2];
51
+ const preferred = [...PREFERRED].find((candidate) => candidate >= low && candidate <= high);
52
+ members.push(preferred ?? low);
53
+ i += 2;
54
+ continue;
8
55
  }
56
+ members.push(char);
9
57
  }
10
- if (pattern.includes("https?") || pattern.includes("http")) {
11
- return '"https://example.com"';
58
+ if (!negated) {
59
+ return [...PREFERRED].find((candidate) => members.includes(candidate)) ?? members[0] ?? null;
12
60
  }
13
- if (pattern.match(/\d.*\\\.\.*\d/)) {
14
- return '"1.0.0"';
61
+ return [...PREFERRED].find((candidate) => !members.includes(candidate)) ?? null;
62
+ };
63
+ const readQuantifier = (pattern, start) => {
64
+ const char = pattern[start];
65
+ let quantifier = ONCE;
66
+ let next = start;
67
+ if (char === "*") {
68
+ quantifier = { min: 0, max: Number.POSITIVE_INFINITY };
69
+ next = start + 1;
70
+ } else if (char === "+") {
71
+ quantifier = { min: 1, max: Number.POSITIVE_INFINITY };
72
+ next = start + 1;
73
+ } else if (char === "?") {
74
+ quantifier = { min: 0, max: 1 };
75
+ next = start + 1;
76
+ } else if (char === "{") {
77
+ const close = pattern.indexOf("}", start);
78
+ const body = close === -1 ? null : pattern.slice(start + 1, close);
79
+ const match = body === null ? null : /^(\d+)(,(\d*)?)?$/.exec(body);
80
+ if (match && close !== -1) {
81
+ const min = Number(match[1]);
82
+ const max = match[2] === void 0 ? min : match[3] ? Number(match[3]) : Number.POSITIVE_INFINITY;
83
+ quantifier = { min, max };
84
+ next = close + 1;
85
+ }
15
86
  }
16
- if (pattern.includes("\\d{4}") && pattern.includes("\\d{2}")) {
17
- return '"2000-01-01"';
87
+ if (quantifier !== ONCE && (pattern[next] === "?" || pattern[next] === "+"))
88
+ next += 1;
89
+ return { quantifier, next };
90
+ };
91
+ const parseAlternatives = (pattern, start, stop) => {
92
+ const alternatives = [];
93
+ let current = [];
94
+ let i = start;
95
+ while (i < stop) {
96
+ const char = pattern[i];
97
+ if (char === ")")
98
+ break;
99
+ if (char === "|") {
100
+ alternatives.push(current);
101
+ current = [];
102
+ i += 1;
103
+ continue;
104
+ }
105
+ let node = null;
106
+ if (char === "(") {
107
+ let bodyStart = i + 1;
108
+ if (pattern.startsWith("(?:", i))
109
+ bodyStart = i + 3;
110
+ else if (pattern.startsWith("(?<", i)) {
111
+ const close = pattern.indexOf(">", i);
112
+ if (close === -1)
113
+ return { alternatives: [], next: stop };
114
+ bodyStart = close + 1;
115
+ }
116
+ const inner = parseAlternatives(pattern, bodyStart, stop);
117
+ if (pattern[inner.next] !== ")")
118
+ return { alternatives: [], next: stop };
119
+ const { quantifier, next } = readQuantifier(pattern, inner.next + 1);
120
+ node = { kind: "group", alternatives: inner.alternatives, quantifier };
121
+ i = next;
122
+ } else if (char === "[") {
123
+ let end = i + 1;
124
+ if (pattern[end] === "^")
125
+ end += 1;
126
+ if (pattern[end] === "]")
127
+ end += 1;
128
+ while (end < stop && pattern[end] !== "]")
129
+ end += pattern[end] === "\\" ? 2 : 1;
130
+ if (end >= stop)
131
+ return { alternatives: [], next: stop };
132
+ const picked = pickFromClass(pattern.slice(i + 1, end));
133
+ if (picked === null)
134
+ return { alternatives: [], next: stop };
135
+ const { quantifier, next } = readQuantifier(pattern, end + 1);
136
+ node = { kind: "class", value: picked, quantifier };
137
+ i = next;
138
+ } else if (char === "\\") {
139
+ const sequence = pattern[i + 1];
140
+ if (sequence === void 0)
141
+ return { alternatives: [], next: stop };
142
+ const { quantifier, next } = readQuantifier(pattern, i + 2);
143
+ node = { kind: "literal", value: escapedChar(sequence) ?? sequence, quantifier };
144
+ i = next;
145
+ } else if (char === ".") {
146
+ const { quantifier, next } = readQuantifier(pattern, i + 1);
147
+ node = { kind: "class", value: "a", quantifier };
148
+ i = next;
149
+ } else if (char === "^" || char === "$") {
150
+ i += 1;
151
+ continue;
152
+ } else {
153
+ const { quantifier, next } = readQuantifier(pattern, i + 1);
154
+ node = { kind: "literal", value: char, quantifier };
155
+ i = next;
156
+ }
157
+ current.push(node);
158
+ }
159
+ alternatives.push(current);
160
+ return { alternatives, next: i };
161
+ };
162
+ const render = (nodes, budget) => {
163
+ let out = "";
164
+ for (const node of nodes) {
165
+ const { min, max } = node.quantifier;
166
+ const count = Math.min(min + (max > min ? budget : 0), max);
167
+ const unit = node.kind === "group" ? render(node.alternatives[0] ?? [], budget) : node.value;
168
+ out += unit.repeat(count);
169
+ }
170
+ return out;
171
+ };
172
+ const synthesize = (pattern) => {
173
+ if (UNSUPPORTED.test(pattern))
174
+ return [];
175
+ const { alternatives } = parseAlternatives(pattern, 0, pattern.length);
176
+ const candidates = [];
177
+ for (const alternative of alternatives) {
178
+ for (let budget = 0; budget <= 64; budget++) {
179
+ const rendered = render(alternative, budget);
180
+ candidates.push(rendered);
181
+ if (rendered.length > 512)
182
+ break;
183
+ }
184
+ }
185
+ return candidates;
186
+ };
187
+ const compile = (pattern) => {
188
+ try {
189
+ return new RegExp(pattern, "u");
190
+ } catch {
191
+ try {
192
+ return new RegExp(pattern);
193
+ } catch {
194
+ return null;
195
+ }
196
+ }
197
+ };
198
+ const generateDefaultFromPattern = (pattern, minLength, maxLength) => {
199
+ if (pattern === "")
200
+ return null;
201
+ const regex = compile(pattern);
202
+ if (regex === null)
203
+ return null;
204
+ const low = typeof minLength === "number" && minLength > 0 ? minLength : 0;
205
+ const high = typeof maxLength === "number" && maxLength >= 0 ? maxLength : Number.POSITIVE_INFINITY;
206
+ const fits = (candidate) => {
207
+ if (candidate.length < low || candidate.length > high)
208
+ return false;
209
+ return regex.test(candidate);
210
+ };
211
+ for (const candidate of RECOGNIZED_DEFAULTS) {
212
+ if (fits(candidate))
213
+ return JSON.stringify(candidate);
18
214
  }
19
- if (pattern.includes("\\d{3}") && (pattern.includes("\\d{4}") || pattern.includes("\\d{7}"))) {
20
- return '"+1234567890"';
215
+ for (const candidate of synthesize(pattern)) {
216
+ if (fits(candidate))
217
+ return JSON.stringify(candidate);
21
218
  }
22
219
  return null;
23
220
  };
@@ -1,8 +1,9 @@
1
1
  import { regexLiteral } from "@amritk/helpers/escape-regex-pattern";
2
+ import { generateTypeDefinition } from "@amritk/helpers/generate-type-definition";
2
3
  import { quoteJsString } from "@amritk/helpers/quote-js-string";
3
4
  import { refToName } from "@amritk/helpers/ref-to-name";
4
5
  import { resolveRef } from "@amritk/helpers/resolve-ref";
5
- import { missingCheck, safeAccessor, safeKey } from "@amritk/helpers/safe-accessor";
6
+ import { hasOwnCheck, missingCheck, safeAccessor, safeKey } from "@amritk/helpers/safe-accessor";
6
7
  import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasItems, hasMaxItems, hasMaxProperties, hasMinItems, hasMinProperties, hasOneOf, hasProperties, hasRef, hasRequired, hasType, hasUniqueItems, isObjectSchema, isSchemaObject } from "@amritk/helpers/schema-guards";
7
8
  import { unknownKeyCheck } from "@amritk/helpers/unknown-key-check";
8
9
  import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
@@ -12,7 +13,7 @@ import { getDiscriminatorValue } from "../helpers/get-discriminator-value.js";
12
13
  import { assertNoUnsupportedKeywords } from "./assert-supported-keywords.js";
13
14
  import { generateEnumCaseInsensitiveCoercion } from "./generate-enum-check.js";
14
15
  import { generateBackstopAssertion, generateCompositionChecks, generateContainsCheck, generateKeyedValueChecks, generateObjectKeywordChecks, generateObjectSizeChecks, generateObjectStrictAssertion, generateScalarStrictAssertion, inlineAllOfMembers } from "./generate-strict-assertion.js";
15
- import { canEnforceUnion, generatePropertyTypeCheck, generateUnionCheck, getUnionBranches, hasFastPathBlockingKeyword, isExclusiveUnion, isInlineObjectArrayProperty, isInlineObjectProperty, shapeValidatorName } from "./generate-type-checks.js";
16
+ import { canEnforceUnion, generatePropertyTypeCheck, generateUnionCheck, getUnionBranches, hasFastPathBlockingKeyword, isExclusiveUnion, isInlineObjectArrayProperty, isInlineObjectProperty, isInlineUnionProperty, isUnionArrayProperty, shapeValidatorName } from "./generate-type-checks.js";
16
17
  import { generateUniqueItemsCheck } from "./generate-unique-items-check.js";
17
18
  import { generatePrefixItemsMap, generateValidationExpression, getPrefixItems, isCoercibleItemSchema, prefixItemsCapsLength } from "./generate-validation-expression.js";
18
19
  const generateParserName = (typeName) => {
@@ -209,6 +210,24 @@ const generateFallbackObject = (schema, useRefImports, typeName, suffix, subType
209
210
  result += "\n }";
210
211
  return declaresPrototypeMemberProperty(schema) ? `${result} as unknown as ${typeName}` : result;
211
212
  };
213
+ const SCALAR_ROOT_CONSTRAINTS = [
214
+ "pattern",
215
+ "minLength",
216
+ "maxLength",
217
+ "minimum",
218
+ "maximum",
219
+ "exclusiveMinimum",
220
+ "exclusiveMaximum",
221
+ "multipleOf"
222
+ ];
223
+ const isConstrainedScalarRoot = (schema) => {
224
+ if (!isSchemaObject(schema) || !hasType(schema))
225
+ return false;
226
+ if (schema.type !== "string" && schema.type !== "number" && schema.type !== "integer")
227
+ return false;
228
+ const record = schema;
229
+ return SCALAR_ROOT_CONSTRAINTS.some((keyword) => keyword in record);
230
+ };
212
231
  const scalarDefaultLiteral = (schema) => {
213
232
  const value = getDefaultValue(schema);
214
233
  return value === "undefined" ? "{}" : value;
@@ -252,6 +271,149 @@ ${prelude} const _disc = ${discExpr};
252
271
  return ${expr};
253
272
  };`;
254
273
  };
274
+ const SCORE_TAG_MATCH = 100;
275
+ const SCORE_TAG_MISMATCH = -100;
276
+ const SCORE_REQUIRED_PRESENT = 4;
277
+ const SCORE_REQUIRED_MISSING = -4;
278
+ const SCORE_DECLARED_PRESENT = 1;
279
+ const SCORE_DECLARED_WELL_TYPED = 2;
280
+ const resolveForScoring = (schema, rootSchema) => {
281
+ if (!isSchemaObject(schema) || !hasRef(schema) || rootSchema === void 0)
282
+ return schema;
283
+ const target = resolveRef(schema.$ref, rootSchema);
284
+ return target !== void 0 && isSchemaObject(target) ? target : schema;
285
+ };
286
+ const tagValueOf = (schema) => {
287
+ if (!isSchemaObject(schema))
288
+ return void 0;
289
+ if (hasConst(schema))
290
+ return schema.const;
291
+ if (hasEnum(schema) && schema.enum.length === 1)
292
+ return schema.enum[0];
293
+ return void 0;
294
+ };
295
+ const branchScoreExpression = (branch, ctx) => {
296
+ const resolved = resolveForScoring(branch, ctx.rootSchema);
297
+ if (!isSchemaObject(resolved) || !hasProperties(resolved))
298
+ return "0";
299
+ const properties = resolved.properties;
300
+ const required = new Set(hasRequired(resolved) ? resolved.required : []);
301
+ const terms = [];
302
+ for (const key of Object.keys(properties)) {
303
+ const propertySchema = resolveForScoring(properties[key], ctx.rootSchema);
304
+ const accessor = safeAccessor("input", key);
305
+ const present = hasOwnCheck("input", key);
306
+ const tag = tagValueOf(propertySchema);
307
+ if (tag !== void 0) {
308
+ terms.push(`(${accessor} === ${JSON.stringify(tag)} ? ${SCORE_TAG_MATCH} : ${present} ? ${SCORE_TAG_MISMATCH} : 0)`);
309
+ continue;
310
+ }
311
+ if (required.has(key)) {
312
+ terms.push(`(${present} ? ${SCORE_REQUIRED_PRESENT} : ${SCORE_REQUIRED_MISSING})`);
313
+ } else {
314
+ terms.push(`(${present} ? ${SCORE_DECLARED_PRESENT} : 0)`);
315
+ }
316
+ const typeCheck = generatePropertyTypeCheck(accessor, properties[key], ctx.useRefImports, ctx.suffix);
317
+ if (typeCheck !== null)
318
+ terms.push(`(${typeCheck} ? ${SCORE_DECLARED_WELL_TYPED} : 0)`);
319
+ }
320
+ return terms.length === 0 ? "0" : terms.join(" + ");
321
+ };
322
+ const generateScoredUnionParser = (functionName, typeName, branches, ctx, exported = true) => {
323
+ if (branches.length < 2)
324
+ return null;
325
+ const preamble = [];
326
+ const dispatch = [];
327
+ const reserved = new Set(ctx.reservedNames ?? []);
328
+ const needsUnnarrowedAlias = branches.some((branch) => isSchemaObject(branch) && !(hasRef(branch) && ctx.useRefImports) && !(isObjectSchema(branch) && hasProperties(branch)));
329
+ const valueRef = needsUnnarrowedAlias ? "_u" : "input";
330
+ for (const [index, branch] of branches.entries()) {
331
+ if (!isSchemaObject(branch))
332
+ return null;
333
+ if (hasRef(branch) && ctx.useRefImports) {
334
+ const refName = refToName(branch.$ref, ctx.suffix);
335
+ if (refName === typeName)
336
+ return null;
337
+ dispatch.push({
338
+ predicate: `${shapeValidatorName(refName)}(input)`,
339
+ result: `${generateParserName(refName)}(${valueRef}) as ${typeName}`
340
+ });
341
+ continue;
342
+ }
343
+ if (hasRef(branch))
344
+ return null;
345
+ if (hasOneOf(branch) || hasAnyOf(branch) || hasAllOf(branch) || "not" in branch)
346
+ return null;
347
+ if ("if" in branch || "then" in branch || "else" in branch || "patternProperties" in branch)
348
+ return null;
349
+ if (!isObjectSchema(branch) || !hasProperties(branch)) {
350
+ const predicate = generatePropertyTypeCheck("input", branch, ctx.useRefImports, ctx.suffix);
351
+ if (predicate === null)
352
+ return null;
353
+ const coerced = generateValidationExpression("", branch, scalarDefaultLiteral(branch), true, ctx.rootSchema, void 0, valueRef, false, ctx.caseInsensitive);
354
+ dispatch.push({ predicate, result: `(${coerced}) as ${typeName}` });
355
+ continue;
356
+ }
357
+ let subName = `${typeName}_B${index}`;
358
+ while (reserved.has(subName))
359
+ subName = `${subName}_`;
360
+ reserved.add(subName);
361
+ const shapeValidator = generateShapeValidator(branch, subName, ctx.useRefImports, ctx.suffix, false, ctx.stripUnknown ?? false, reserved, ctx.unknownKeys ?? DEFAULT_UNKNOWN_KEYS);
362
+ if (shapeValidator.includes("(_input: unknown): boolean => false"))
363
+ return null;
364
+ preamble.push(generateTypeDefinition(branch, subName, {
365
+ ...ctx.suffix ? { typeSuffix: ctx.suffix } : {},
366
+ ...ctx.rootSchema ? { rootSchema: ctx.rootSchema } : {}
367
+ }).replace(/^export /, ""));
368
+ preamble.push(shapeValidator);
369
+ preamble.push(generateObjectParser(
370
+ branch,
371
+ subName,
372
+ ctx.useRefImports,
373
+ ctx.suffix,
374
+ ctx.logWarnings,
375
+ // Always the coercing parser: this whole path exists to repair.
376
+ false,
377
+ false,
378
+ ctx.stripUnknown ?? false,
379
+ ctx.rootSchema,
380
+ reserved,
381
+ ctx.caseInsensitive ?? false,
382
+ shapeValidator,
383
+ ctx.unknownKeys ?? DEFAULT_UNKNOWN_KEYS
384
+ ));
385
+ dispatch.push({
386
+ predicate: `${shapeValidatorName(subName)}(input)`,
387
+ result: `${generateParserName(subName)}(${valueRef}) as ${typeName}`
388
+ });
389
+ }
390
+ const first = dispatch[0];
391
+ if (first === void 0)
392
+ return null;
393
+ const lines = [];
394
+ if (needsUnnarrowedAlias)
395
+ lines.push(" const _u: unknown = input;");
396
+ for (const { predicate, result } of dispatch) {
397
+ lines.push(` if (${predicate}) return ${result};`);
398
+ }
399
+ lines.push(` if (!isObject(input)) return ${first.result};`);
400
+ const scores = branches.map((branch, index) => ` const _s${index} = ${branchScoreExpression(branch, ctx)};`);
401
+ lines.push(...scores);
402
+ const last = dispatch[dispatch.length - 1];
403
+ let best = `${last?.result}`;
404
+ for (let index = dispatch.length - 2; index >= 0; index--) {
405
+ const atLeastAsGood = dispatch.slice(index + 1).map((_, offset) => `_s${index} >= _s${index + 1 + offset}`).join(" && ");
406
+ best = `${atLeastAsGood} ? ${dispatch[index]?.result} : ${best}`;
407
+ }
408
+ lines.push(` return ${best};`);
409
+ const head = preamble.length > 0 ? `${preamble.join("\n\n")}
410
+
411
+ ` : "";
412
+ const exportPrefix = exported ? "export " : "";
413
+ return `${head}${exportPrefix}const ${functionName} = (input: unknown): ${typeName} => {
414
+ ${lines.join("\n")}
415
+ };`;
416
+ };
255
417
  const strictContext = (ctx) => ({
256
418
  useRefImports: ctx?.useRefImports ?? false,
257
419
  suffix: ctx?.suffix ?? "",
@@ -314,6 +476,9 @@ ${assertions}
314
476
  }
315
477
  if (hasOneOf(schema) || hasAnyOf(schema)) {
316
478
  const branches = hasOneOf(schema) ? schema.oneOf : hasAnyOf(schema) ? schema.anyOf : [];
479
+ const scored = unionCtx ? generateScoredUnionParser(functionName, typeName, branches, unionCtx) : null;
480
+ if (scored !== null)
481
+ return scored;
317
482
  const hasRefBranch = branches.some((b) => isSchemaObject(b) && hasRef(b));
318
483
  if (branches.length > 0 && !hasRefBranch) {
319
484
  const fallback = scalarDefaultLiteral(branches[0]);
@@ -415,6 +580,10 @@ ${assertion}
415
580
  return ${returnExpr};
416
581
  };`;
417
582
  }
583
+ if (isConstrainedScalarRoot(schema)) {
584
+ const expr = generateValidationExpression("", schema, getDefaultValue(schema), true, unionCtx?.rootSchema, void 0, "input", true, unionCtx?.caseInsensitive);
585
+ return `export const ${functionName} = (input: unknown): ${typeName} => (${expr}) as ${typeName};`;
586
+ }
418
587
  switch (schema.type) {
419
588
  case "string":
420
589
  return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "string" ? input as ${typeName} : "" as ${typeName};`;
@@ -519,7 +688,7 @@ const collectInlineSubTypes = (schema, typeName, reservedNames = NO_RESERVED_NAM
519
688
  if (isInlineObjectProperty(propSchema)) {
520
689
  objects ??= /* @__PURE__ */ new Map();
521
690
  objects.set(key, claim(`${typeName}_${pascalCaseKey(key) || "Value"}`));
522
- } else if (isInlineObjectArrayProperty(propSchema)) {
691
+ } else if (isInlineObjectArrayProperty(propSchema) || isUnionArrayProperty(propSchema)) {
523
692
  arrayItems ??= /* @__PURE__ */ new Map();
524
693
  arrayItems.set(key, claim(`${typeName}_${pascalCaseKey(key) || "Value"}Item`));
525
694
  }
@@ -691,7 +860,51 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
691
860
  for (let i = 0; i < arr.length; i++) if (!${shapeValidatorName(subName)}(arr[i])) return false;
692
861
  return true;
693
862
  };`);
694
- preamble.push(generateObjectParser(itemSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames, caseInsensitive, itemShapeValidator, unknownKeys));
863
+ const unionBranches = strict ? null : getUnionBranches(itemSchema);
864
+ const scoredItemParser = unionBranches === null ? null : generateScoredUnionParser(generateParserName(subName), subName, unionBranches, {
865
+ useRefImports,
866
+ suffix,
867
+ ...rootSchema !== void 0 ? { rootSchema } : {},
868
+ ...logWarnings !== void 0 ? { logWarnings } : {},
869
+ reservedNames,
870
+ stripUnknown,
871
+ unknownKeys,
872
+ caseInsensitive
873
+ }, false);
874
+ preamble.push(scoredItemParser ?? generateObjectParser(itemSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames, caseInsensitive, itemShapeValidator, unknownKeys));
875
+ }
876
+ const unionSubNames = /* @__PURE__ */ new Map();
877
+ if (!strict) {
878
+ const claimedUnionNames = /* @__PURE__ */ new Set([...reservedNames, ...subTypeNames.values(), ...subItemNames.values()]);
879
+ for (const key of Object.keys(schemaProps)) {
880
+ if (subTypeNames.has(key) || subItemNames.has(key))
881
+ continue;
882
+ const propSchema = schemaProps[key];
883
+ if (!isInlineUnionProperty(propSchema))
884
+ continue;
885
+ const branches = getUnionBranches(propSchema);
886
+ if (branches === null)
887
+ continue;
888
+ let unionSubName = `${typeName}_${pascalCaseKey(key) || "Value"}Union`;
889
+ while (claimedUnionNames.has(unionSubName))
890
+ unionSubName = `${unionSubName}_`;
891
+ claimedUnionNames.add(unionSubName);
892
+ const unionParser = generateScoredUnionParser(generateParserName(unionSubName), unionSubName, branches, {
893
+ useRefImports,
894
+ suffix,
895
+ ...rootSchema !== void 0 ? { rootSchema } : {},
896
+ ...logWarnings !== void 0 ? { logWarnings } : {},
897
+ reservedNames: claimedUnionNames,
898
+ stripUnknown,
899
+ unknownKeys,
900
+ caseInsensitive
901
+ }, false);
902
+ if (unionParser === null)
903
+ continue;
904
+ preamble.push(`type ${unionSubName} = NonNullable<${typeName}>[${JSON.stringify(key)}];`);
905
+ preamble.push(unionParser);
906
+ unionSubNames.set(key, unionSubName);
907
+ }
695
908
  }
696
909
  const strictKeys = hasStrictKeys(schema);
697
910
  const stripKeys = strictKeys || stripUnknown;
@@ -860,6 +1073,16 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
860
1073
  for (const { key, varName, isRequired, propSchema } of propInfo) {
861
1074
  const shouldCache = shouldCacheVariable(propSchema);
862
1075
  const accessor = shouldCache ? varName : safeAccessor("input", key);
1076
+ const unionSubName = unionSubNames.get(key);
1077
+ if (unionSubName) {
1078
+ const unionParserName = generateParserName(unionSubName);
1079
+ if (isRequired) {
1080
+ entries.push({ kind: "field", key, value: `${unionParserName}(${accessor})` });
1081
+ } else {
1082
+ entries.push({ kind: "optional", key, read: accessor, value: `${unionParserName}(${accessor})` });
1083
+ }
1084
+ continue;
1085
+ }
863
1086
  const subName = subTypeNames.get(key);
864
1087
  if (subName) {
865
1088
  const subParserName = generateParserName(subName);
@@ -1621,6 +1844,11 @@ const generateShapeValidator = (schema, typeName, useRefImports, suffix = "", ex
1621
1844
  if (check !== null) {
1622
1845
  return `${exportPrefix}const ${fnName} = (input: unknown): boolean => ${check};`;
1623
1846
  }
1847
+ return stub;
1848
+ }
1849
+ const direct = generatePropertyTypeCheck("input", schema, useRefImports, suffix);
1850
+ if (direct !== null) {
1851
+ return `${exportPrefix}const ${fnName} = (input: unknown): boolean => ${direct};`;
1624
1852
  }
1625
1853
  return stub;
1626
1854
  }
@@ -221,9 +221,15 @@ const generateItemCheck = (schema, context) => {
221
221
  if (hasRef(items) && context.useRefImports === true || shapeIsDelegated(items, context))
222
222
  return null;
223
223
  const match = subschemaMatchExpr("_it", items, matchContext(context));
224
- if (match === null || match === "true")
224
+ if (match !== null)
225
+ return match === "true" ? null : { check: match, message: "items do not match the item schema" };
226
+ const branches = getUnionBranches(items);
227
+ if (branches === null || context.stripUnknown === true)
228
+ return null;
229
+ if (!canEnforceUnion(branches, context.rootSchema))
225
230
  return null;
226
- return { check: match, message: "items do not match the item schema" };
231
+ const union = generateUnionCheck("_it", branches, context.useRefImports ?? false, context.suffix ?? "", isExclusiveUnion(items));
232
+ return union === null ? null : { check: union, message: "items do not match the item schema" };
227
233
  };
228
234
  const nullableObjectShapeChecks = (acc, schema, type, label, optional, context) => {
229
235
  if (type !== "object")
@@ -35,6 +35,27 @@ export declare const isInlineObjectProperty: (propSchema: JSONSchema) => propSch
35
35
  * `Array.isArray` check.
36
36
  */
37
37
  export declare const isInlineObjectArrayProperty: (propSchema: JSONSchema) => boolean;
38
+ /**
39
+ * Matches an array property whose `items` is a *union* of object branches — the
40
+ * shape a coercing parser needs a private item sub-parser for, so an element can
41
+ * be dispatched to (and repaired toward) a branch rather than passed through.
42
+ * {@link isInlineObjectArrayProperty} deliberately does not cover this: a union
43
+ * is not one inline object, and the trust walk reads that predicate to decide
44
+ * what a shape validator proves.
45
+ */
46
+ export declare const isUnionArrayProperty: (propSchema: JSONSchema) => boolean;
47
+ /**
48
+ * Matches a union written directly as a property value — `logo: { anyOf: [...] }`
49
+ * rather than a `$ref`, a definition, or an array's `items`. This is the one
50
+ * union position the coercing dispatcher did not reach, so a value matching no
51
+ * branch was passed through unrepaired.
52
+ *
53
+ * The union's *own* keywords are the reason for the exclusions: 2020-12 applies
54
+ * them alongside the branches, and a dispatcher expresses none of them, so a
55
+ * constrained union keeps the general path rather than getting a parser that
56
+ * quietly drops the constraint.
57
+ */
58
+ export declare const isInlineUnionProperty: (propSchema: JSONSchema) => boolean;
38
59
  /**
39
60
  * Extracts the branch list of a `oneOf`/`anyOf` union, or `null` when the
40
61
  * schema is not a union (or mixes in other composition keywords we cannot
@@ -36,6 +36,37 @@ const isInlineObjectArrayProperty = (propSchema) => {
36
36
  return false;
37
37
  return isInlineObjectProperty(propSchema.items);
38
38
  };
39
+ const isUnionArrayProperty = (propSchema) => {
40
+ if (!isSchemaObject(propSchema))
41
+ return false;
42
+ if (!("type" in propSchema) || propSchema.type !== "array")
43
+ return false;
44
+ if (!hasItems(propSchema) || Array.isArray(propSchema.items))
45
+ return false;
46
+ const items = propSchema.items;
47
+ if (!isSchemaObject(items))
48
+ return false;
49
+ const branches = getUnionBranches(items);
50
+ if (branches === null || branches.length < 2)
51
+ return false;
52
+ return branches.every((branch) => isSchemaObject(branch) && (hasRef(branch) || isInlineObjectProperty(branch)));
53
+ };
54
+ const isInlineUnionProperty = (propSchema) => {
55
+ if (!isSchemaObject(propSchema))
56
+ return false;
57
+ if (hasRef(propSchema) || hasAllOf(propSchema) || "not" in propSchema)
58
+ return false;
59
+ if ("if" in propSchema || "then" in propSchema || "else" in propSchema)
60
+ return false;
61
+ if ("patternProperties" in propSchema)
62
+ return false;
63
+ const branches = getUnionBranches(propSchema);
64
+ if (branches === null || branches.length < 2)
65
+ return false;
66
+ if (hasBranchSiblingConstraint(propSchema))
67
+ return false;
68
+ return branches.every((branch) => isSchemaObject(branch) && (hasRef(branch) || isInlineObjectProperty(branch)));
69
+ };
39
70
  const getUnionBranches = (schema) => {
40
71
  if (!isSchemaObject(schema))
41
72
  return null;
@@ -385,8 +416,11 @@ const canTrustReferencedValidator = (ref, rootSchema, visiting) => {
385
416
  }
386
417
  if (!hasProperties(resolved)) {
387
418
  const branches = getUnionBranches(resolved);
388
- if (!branches)
389
- return false;
419
+ if (!branches) {
420
+ if ("patternProperties" in resolved || "if" in resolved)
421
+ return false;
422
+ return canTrustPropertyCheck(resolved, rootSchema, visiting);
423
+ }
390
424
  if ("patternProperties" in resolved || "if" in resolved)
391
425
  return false;
392
426
  return branches.every((branch) => {
@@ -413,6 +447,8 @@ export {
413
447
  isExclusiveUnion,
414
448
  isInlineObjectArrayProperty,
415
449
  isInlineObjectProperty,
450
+ isInlineUnionProperty,
451
+ isUnionArrayProperty,
416
452
  multiTypeCheck,
417
453
  shapeValidatorName
418
454
  };
@@ -141,6 +141,15 @@ const numberBoundChecks = (valueExpr, schema) => {
141
141
  checks.push(multipleOfPassExpr(valueExpr, schema.multipleOf));
142
142
  return checks;
143
143
  };
144
+ const BOOLEAN_TRUE_TOKENS = ["true", "yes", "y", "on", "1"];
145
+ const BOOLEAN_FALSE_TOKENS = ["false", "no", "n", "off", "0", ""];
146
+ const getBooleanCoercion = (accessor, defaultValue) => {
147
+ const side = (tokens, literals) => [...tokens.map((token) => `"${token}"`), ...literals].map((term) => `_b === ${term}`).join(" || ");
148
+ const normalized = `typeof ${accessor} === "string" ? ${accessor}.trim().toLowerCase() : ${accessor}`;
149
+ const truthy = side(BOOLEAN_TRUE_TOKENS, ["1", "true"]);
150
+ const falsy = side(BOOLEAN_FALSE_TOKENS, ["0", "false"]);
151
+ return `((_b) => ${truthy} ? true : ${falsy} ? false : ${defaultValue})(${normalized})`;
152
+ };
144
153
  const getTypeCoercion = (accessor, schema, defaultValue) => {
145
154
  if (!hasType(schema)) {
146
155
  return null;
@@ -163,7 +172,7 @@ const getTypeCoercion = (accessor, schema, defaultValue) => {
163
172
  return `((_n) => ${[`${wellFormed}(_n)`, ...bounds].join(" && ")} ? _n : ${defaultValue})(${n})`;
164
173
  }
165
174
  case "boolean":
166
- return `Boolean(${accessor})`;
175
+ return getBooleanCoercion(accessor, defaultValue);
167
176
  case "array":
168
177
  return defaultValue;
169
178
  case "object":
@@ -46,8 +46,9 @@ const arrayFallback = (schema) => {
46
46
  };
47
47
  const MAX_FALLBACK_LENGTH = 256;
48
48
  const stringFallback = (schema) => {
49
+ const bounds = schema;
49
50
  if (hasPattern(schema)) {
50
- const patternDefault = generateDefaultFromPattern(schema.pattern);
51
+ const patternDefault = generateDefaultFromPattern(schema.pattern, typeof bounds["minLength"] === "number" ? bounds["minLength"] : void 0, typeof bounds["maxLength"] === "number" ? bounds["maxLength"] : void 0);
51
52
  if (patternDefault)
52
53
  return patternDefault;
53
54
  return '""';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-parsers",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "dependencies": {
62
62
  "json-schema-typed": "^8.0.1",
63
- "@amritk/helpers": "^0.19.0"
63
+ "@amritk/helpers": "^0.20.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@amritk/generate-markdown": "^0.6.0",