@amritk/validation 0.3.2 → 0.4.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.
@@ -2,7 +2,19 @@ import { regexFlagsFor } from "@amritk/helpers/escape-regex-pattern";
2
2
  import { declaresKey, readKey } from "@amritk/helpers/read-key";
3
3
  import { refToName } from "@amritk/helpers/ref-to-name";
4
4
  import { isSchemaObject } from "@amritk/helpers/schema-guards";
5
+ import { NO_FORMATS } from "./enforced-keywords.js";
6
+ import { createSubschemaMatcher } from "./generate-validator-function.js";
5
7
  const COERCIBLE_TYPES = /* @__PURE__ */ new Set(["string", "number", "integer", "boolean", "null"]);
8
+ const ANNOTATION_KEYWORDS = /* @__PURE__ */ new Set([
9
+ "title",
10
+ "description",
11
+ "$comment",
12
+ "examples",
13
+ "default",
14
+ "deprecated",
15
+ "readOnly",
16
+ "writeOnly"
17
+ ]);
6
18
  const coercerNameFor = (ref, typeSuffix) => `coerce${refToName(ref, typeSuffix)}Value`;
7
19
  const declaresObjectKeys = (schema) => declaresKey(schema, "properties") || declaresKey(schema, "patternProperties") || declaresKey(schema, "additionalProperties");
8
20
  const declaresArrayItems = (schema) => declaresKey(schema, "items") || declaresKey(schema, "prefixItems");
@@ -14,34 +26,121 @@ const soleScalarType = (schema) => {
14
26
  const type = readKey(schema, "type");
15
27
  return typeof type === "string" && COERCIBLE_TYPES.has(type) ? type : null;
16
28
  };
17
- const unionScalarTypes = (schema) => {
29
+ const typeArrayScalars = (schema) => {
18
30
  const declared = readKey(schema, "type");
19
- if (Array.isArray(declared)) {
20
- const types2 = declared.filter((entry) => typeof entry === "string");
21
- return types2.length > 0 && types2.every((type) => COERCIBLE_TYPES.has(type)) ? types2 : null;
22
- }
23
- if (declared !== void 0)
31
+ if (!Array.isArray(declared))
24
32
  return null;
25
- const branches = readKey(schema, "anyOf") ?? readKey(schema, "oneOf");
33
+ const types = declared.filter((entry) => typeof entry === "string");
34
+ return types.length > 0 && types.every((type) => COERCIBLE_TYPES.has(type)) ? types : null;
35
+ };
36
+ const bareScalarBranches = (branches) => {
26
37
  if (!Array.isArray(branches) || branches.length === 0)
27
38
  return null;
28
39
  const types = [];
29
40
  for (const branch of branches) {
30
41
  if (!isSchemaObject(branch))
31
42
  return null;
32
- const type = soleScalarType(branch);
33
- if (type !== null)
34
- types.push(type);
35
- else if (declaresObjectKeys(branch) || declaresArrayItems(branch))
36
- continue;
37
- else if (readKey(branch, "type") === "object")
38
- continue;
39
- else if (readKey(branch, "type") === "array")
40
- continue;
41
- else
43
+ const node = branch;
44
+ const type = soleScalarType(node);
45
+ if (type === null)
46
+ return null;
47
+ if (Object.keys(node).some((key) => key !== "type" && !ANNOTATION_KEYWORDS.has(key)))
42
48
  return null;
49
+ types.push(type);
50
+ }
51
+ return types;
52
+ };
53
+ const emitMatcher = (body, ctx) => {
54
+ const name = `_matches${ctx.next()}`;
55
+ const parameter = /\binput\b/.test(body) ? "input" : "_input";
56
+ const path = /\b_path\b/.test(body) ? [` const _path = ''`] : [];
57
+ ctx.helpers.push([`const ${name} = (${parameter}: unknown): boolean => {`, ...path, body, `}`].join("\n"));
58
+ return name;
59
+ };
60
+ const branchTest = (branch, ctx) => {
61
+ const named = ctx.matcher.named(branch);
62
+ if (named !== null)
63
+ return named;
64
+ const body = ctx.matcher.test(branch);
65
+ return typeof body === "string" ? emitMatcher(body, ctx) : body;
66
+ };
67
+ const emitBranchCoercer = (schema, keyword, ctx) => {
68
+ const branches = readKey(schema, keyword);
69
+ if (!Array.isArray(branches) || branches.length === 0)
70
+ return null;
71
+ const tests = [];
72
+ for (const branch of branches) {
73
+ const test = branchTest(branch, ctx);
74
+ if (test === true)
75
+ return null;
76
+ tests.push(test);
77
+ }
78
+ const live = branches.map((branch, index) => ({ name: tests[index], coerce: coercerFor(branch, ctx) })).filter((entry) => typeof entry.name === "string");
79
+ if (live.every(({ coerce }) => coerce === null))
80
+ return null;
81
+ const names = [];
82
+ const attempts = [];
83
+ for (const { name: name2, coerce } of live) {
84
+ names.push(name2);
85
+ if (coerce !== null)
86
+ attempts.push({ coerce, test: name2 });
87
+ }
88
+ const id = ctx.next();
89
+ const name = `_coerceBranches${id}`;
90
+ const lines = [
91
+ `const ${name} = (input: unknown): unknown => {`,
92
+ ` if (${names.map((name2) => `${name2}(input)`).join(" || ")}) return input`
93
+ ];
94
+ if (attempts.length === 1) {
95
+ const [only] = attempts;
96
+ lines.push(` const next = ${only.coerce("input")}`, ` return next !== input && ${only.test}(next) ? next : input`);
97
+ } else {
98
+ lines.push(` let out: unknown = input`);
99
+ attempts.forEach(({ coerce, test }, index) => {
100
+ const local = `_next${index}`;
101
+ lines.push(` const ${local} = ${coerce("input")}`, ` if (${local} !== input && ${test}(${local})) {`, ` if (out === input) out = ${local}`, ` else if (!valuesEqual(out, ${local})) return input`, ` }`);
102
+ });
103
+ lines.push(` return out`);
43
104
  }
44
- return types.length > 0 ? types : null;
105
+ lines.push(`}`);
106
+ ctx.helpers.push(lines.join("\n"));
107
+ return (valueExpr) => `${name}(${valueExpr})`;
108
+ };
109
+ const emitUnionCoercer = (schema, keyword, ctx) => {
110
+ const scalars = bareScalarBranches(readKey(schema, keyword));
111
+ if (scalars !== null)
112
+ return (valueExpr) => `coerceUnion(${valueExpr}, ${JSON.stringify(scalars)})`;
113
+ return emitBranchCoercer(schema, keyword, ctx);
114
+ };
115
+ const emitAllOfCoercer = (schema, ctx) => {
116
+ const subschemas = readKey(schema, "allOf");
117
+ if (!Array.isArray(subschemas))
118
+ return null;
119
+ return compose(subschemas.map((sub) => coercerFor(sub, ctx)));
120
+ };
121
+ const emitConditionalCoercer = (schema, ctx) => {
122
+ if (!declaresKey(schema, "if"))
123
+ return null;
124
+ const thenCoercer = declaresKey(schema, "then") ? coercerFor(readKey(schema, "then"), ctx) : null;
125
+ const elseCoercer = declaresKey(schema, "else") ? coercerFor(readKey(schema, "else"), ctx) : null;
126
+ if (thenCoercer === null && elseCoercer === null)
127
+ return null;
128
+ const test = branchTest(readKey(schema, "if"), ctx);
129
+ if (test === true)
130
+ return thenCoercer;
131
+ if (test === false)
132
+ return elseCoercer;
133
+ const name = `_coerceIf${ctx.next()}`;
134
+ const whenThen = thenCoercer === null ? "input" : thenCoercer("input");
135
+ const whenElse = elseCoercer === null ? "input" : elseCoercer("input");
136
+ ctx.helpers.push(`const ${name} = (input: unknown): unknown => (${test}(input) ? ${whenThen} : ${whenElse})`);
137
+ return (valueExpr) => `${name}(${valueExpr})`;
138
+ };
139
+ const compose = (stages) => {
140
+ const present = stages.filter((stage) => stage !== null);
141
+ if (present.length === 0)
142
+ return null;
143
+ return (valueExpr) => present.reduce((expr, stage) => stage(expr), valueExpr);
45
144
  };
46
145
  const emitObjectCoercer = (schema, ctx) => {
47
146
  const properties = schemaMap(schema, "properties") ?? {};
@@ -74,17 +173,20 @@ const emitObjectCoercer = (schema, ctx) => {
74
173
  const quoted = JSON.stringify(key);
75
174
  const local = `_value${id}_${index}`;
76
175
  lines.push(
77
- // Own properties only. A plain `obj[key]` walks the prototype chain, and
78
- // this pass *writes*: with `Object.prototype.flag` set by any dependency, an
79
- // empty object was read as carrying `flag`, coerced, and handed back with
80
- // `flag` as its own — inventing data the caller never sent, and satisfying
81
- // a `required` the input actually violates. An absent key is not a value to
82
- // coerce, and `{ a: undefined }` counts as absent everywhere else in this
83
- // package, so it counts as absent here too.
84
- ` const ${local} = Object.hasOwn(obj, ${quoted}) ? obj[${quoted}] : undefined`,
176
+ // Own properties only, where it matters: at the write. A plain `obj[key]`
177
+ // walks the prototype chain, and this pass *writes* — with
178
+ // `Object.prototype.flag` set by any dependency, an empty object was read as
179
+ // carrying `flag`, coerced, and handed back with `flag` as its own,
180
+ // inventing data the caller never sent and satisfying a `required` the input
181
+ // actually violates. Coercing an inherited value is harmless (every walk is
182
+ // pure) as long as the result is never written, so the `Object.hasOwn` is
183
+ // asked only of a value that moved: on V8 it is the dearest thing in the
184
+ // walk, and most keys do not move. `{ a: undefined }` counts as absent
185
+ // everywhere else in this package, so it counts as absent here too.
186
+ ` const ${local} = obj[${quoted}]`,
85
187
  ` if (${local} !== undefined) {`,
86
188
  ` const next = ${coerce(local)}`,
87
- ` if (next !== ${local}) (out ??= { ...obj })[${quoted}] = next`,
189
+ ` if (next !== ${local} && Object.hasOwn(obj, ${quoted})) (out ??= { ...obj })[${quoted}] = next`,
88
190
  ` }`
89
191
  );
90
192
  });
@@ -143,17 +245,18 @@ const emitArrayCoercer = (schema, ctx) => {
143
245
  return;
144
246
  const read = `input[${index}]`;
145
247
  lines.push(
146
- // `Object.hasOwn` rather than a length test, for the same reason the
147
- // properties above use it: a hole reads through `Array.prototype`, and
148
- // coercing what it finds there would turn a hole into an own element.
149
- ` if (Object.hasOwn(input, ${index})) {`,
248
+ // `Object.hasOwn` rather than a length test, for the same reason the object
249
+ // walk uses it, and at the write for the same reason: a hole reads through
250
+ // `Array.prototype`, and writing what that coerces to would turn a hole into
251
+ // an own element.
252
+ ` {`,
150
253
  ` const next = ${coerce(read)}`,
151
- ` if (next !== ${read}) (out ??= [...input])[${index}] = next`,
254
+ ` if (next !== ${read} && Object.hasOwn(input, ${index})) (out ??= [...input])[${index}] = next`,
152
255
  ` }`
153
256
  );
154
257
  });
155
258
  if (restCoercer !== null) {
156
- lines.push(` for (let i = ${tuple.length}; i < input.length; i++) {`, ` if (!Object.hasOwn(input, i)) continue`, ` const value = input[i]`, ` const next = ${restCoercer("value")}`, ` if (next !== value) (out ??= [...input])[i] = next`, ` }`);
259
+ lines.push(` for (let i = ${tuple.length}; i < input.length; i++) {`, ` const value = input[i]`, ` const next = ${restCoercer("value")}`, ` if (next !== value && Object.hasOwn(input, i)) (out ??= [...input])[i] = next`, ` }`);
157
260
  }
158
261
  lines.push(` return out ?? input`, `}`);
159
262
  ctx.helpers.push(lines.join("\n"));
@@ -164,37 +267,61 @@ const coercerFor = (schema, ctx) => {
164
267
  return null;
165
268
  const node = schema;
166
269
  const ref = readKey(node, "$ref");
167
- if (typeof ref === "string") {
168
- const name = coercerNameFor(ref, ctx.typeSuffix);
169
- return (valueExpr) => `${name}(${valueExpr})`;
170
- }
171
270
  const scalar = soleScalarType(node);
172
- if (scalar !== null)
173
- return (valueExpr) => `coerceScalar(${valueExpr}, ${JSON.stringify(scalar)})`;
174
- const union = unionScalarTypes(node);
175
- if (union !== null)
176
- return (valueExpr) => `coerceUnion(${valueExpr}, ${JSON.stringify(union)})`;
271
+ const typeArray = typeArrayScalars(node);
177
272
  const type = readKey(node, "type");
178
- if (type === "object" || type === void 0 && declaresObjectKeys(node))
179
- return emitObjectCoercer(node, ctx);
180
- if (type === "array" || type === void 0 && declaresArrayItems(node))
181
- return emitArrayCoercer(node, ctx);
182
- return null;
273
+ return compose([
274
+ // Whether the target coerces anything is a question about another file, and
275
+ // following it here would have to chase cycles. The call is cheap and
276
+ // returns its argument when there is nothing to do.
277
+ typeof ref === "string" ? (valueExpr) => `${ctx.refCoercer(ref)}(${valueExpr})` : null,
278
+ scalar !== null ? (valueExpr) => `coerceScalar(${valueExpr}, ${JSON.stringify(scalar)})` : typeArray !== null ? (valueExpr) => `coerceUnion(${valueExpr}, ${JSON.stringify(typeArray)})` : type === "object" || type === void 0 && declaresObjectKeys(node) ? emitObjectCoercer(node, ctx) : type === "array" || type === void 0 && declaresArrayItems(node) ? emitArrayCoercer(node, ctx) : null,
279
+ emitAllOfCoercer(node, ctx),
280
+ emitUnionCoercer(node, "anyOf", ctx),
281
+ emitUnionCoercer(node, "oneOf", ctx),
282
+ emitConditionalCoercer(node, ctx)
283
+ ]);
183
284
  };
184
- const generateCoerceFunction = (schema, typeName, typeSuffix = "") => {
285
+ const generateCoerceWalk = (schema, options) => {
185
286
  const helpers = [];
186
287
  const hoisted = [];
187
288
  let counter = 0;
188
- const ctx = { typeSuffix, helpers, hoisted, next: () => counter++ };
289
+ const matcher = createSubschemaMatcher(options.typeSuffix, options.rootSchema ?? (isSchemaObject(schema) ? schema : void 0), options.formats ?? NO_FORMATS, options.hoistNamespace, ...options.refGuard !== void 0 ? [options.refGuard] : []);
290
+ const ctx = {
291
+ typeSuffix: options.typeSuffix,
292
+ helpers,
293
+ hoisted,
294
+ next: () => counter++,
295
+ matcher,
296
+ refCoercer: options.refCoercer
297
+ };
189
298
  const coerce = coercerFor(schema, ctx);
299
+ const rootMatch = options.withRootMatch === true ? matcher.test(schema) : null;
300
+ const reads = helpers.join("\n") + (typeof rootMatch === "string" ? `
301
+ ${rootMatch}` : "");
302
+ return {
303
+ declarations: [...hoisted, ...matcher.declarations(reads), ...helpers],
304
+ expression: coerce === null ? null : coerce("input"),
305
+ rootMatch
306
+ };
307
+ };
308
+ const generateCoerceFunction = (schema, typeName, typeSuffix = "", options = {}) => {
309
+ const { declarations, expression } = generateCoerceWalk(schema, {
310
+ typeSuffix,
311
+ ...options.rootSchema !== void 0 ? { rootSchema: options.rootSchema } : {},
312
+ ...options.formats !== void 0 ? { formats: options.formats } : {},
313
+ refCoercer: (ref) => coercerNameFor(ref, typeSuffix),
314
+ hoistNamespace: "_coerce"
315
+ });
190
316
  const valueName = `coerce${typeName}Value`;
191
- const body = coerce === null ? "input" : coerce("input");
192
- const parts = [...hoisted, ...helpers];
317
+ const body = expression ?? "input";
318
+ const passes = expression !== null && options.standaloneGuard === true ? `is${typeName}` : null;
193
319
  const walk = [
194
- ...parts.length > 0 ? [parts.join("\n\n"), ""] : [],
320
+ ...declarations.length > 0 ? [declarations.join("\n\n"), ""] : [],
195
321
  `export const ${valueName} = (input: unknown): unknown => ${body}`,
196
322
  "",
197
323
  `export const coerce${typeName} = (input: unknown, _path = ''): CoercionResult<${typeName}> => {`,
324
+ ...passes === null ? [] : [` if (${passes}(input)) return { valid: true, value: input as ${typeName} }`],
198
325
  ` const value = ${valueName}(input)`,
199
326
  ` const result = validate${typeName}(value, _path)`,
200
327
  ` return result === true ? { valid: true, value: value as ${typeName} } : { valid: false, errors: result.errors }`,
@@ -204,5 +331,6 @@ const generateCoerceFunction = (schema, typeName, typeSuffix = "") => {
204
331
  };
205
332
  export {
206
333
  coercerNameFor,
207
- generateCoerceFunction
334
+ generateCoerceFunction,
335
+ generateCoerceWalk
208
336
  };
@@ -6,7 +6,7 @@ import { formatCheckName } from "./emit-format-checks.js";
6
6
  import { NO_FORMATS } from "./enforced-keywords.js";
7
7
  import { generateCoerceFunction } from "./generate-coerce-function.js";
8
8
  import { generateRepairFunction } from "./generate-repair-function.js";
9
- import { generateBooleanGuard, generateCheckFunction, generateValidatorFunction } from "./generate-validator-function.js";
9
+ import { generateBooleanGuardSource, generateCheckFunction, generateValidatorFunction } from "./generate-validator-function.js";
10
10
  const generateValidatorFile = (schema, typeName, options) => {
11
11
  const typeSuffix = options?.typeSuffix ?? "";
12
12
  const typeDefinition = generateTypeDefinition(schema, typeName, {
@@ -17,11 +17,18 @@ const generateValidatorFile = (schema, typeName, options) => {
17
17
  const importExt = options?.importExt ?? "js";
18
18
  const formats = options?.formats ?? NO_FORMATS;
19
19
  const validatorFunction = generateValidatorFunction(schema, typeName, typeSuffix, options?.rootSchema, unknownKeys, formats, options?.branchErrors === true);
20
- const booleanGuard = generateBooleanGuard(schema, typeName, typeSuffix, unknownKeys, formats);
20
+ const { code: booleanGuard, standalone: standaloneGuard } = generateBooleanGuardSource(schema, typeName, unknownKeys, formats);
21
21
  const checker = options?.check === true ? generateCheckFunction(schema, typeName, typeSuffix, options?.rootSchema, unknownKeys, formats) : "";
22
22
  const wantsCoerce = options?.coerce === true || options?.repair === true;
23
- const coercer = wantsCoerce ? generateCoerceFunction(schema, typeName, typeSuffix).code : "";
24
- const repairer = options?.repair === true ? generateRepairFunction(schema, typeName, typeSuffix).code : "";
23
+ const coercer = wantsCoerce ? generateCoerceFunction(schema, typeName, typeSuffix, {
24
+ ...options?.rootSchema !== void 0 ? { rootSchema: options.rootSchema } : {},
25
+ formats,
26
+ standaloneGuard
27
+ }).code : "";
28
+ const repairer = options?.repair === true ? generateRepairFunction(schema, typeName, typeSuffix, {
29
+ ...options?.rootSchema !== void 0 ? { rootSchema: options.rootSchema } : {},
30
+ formats
31
+ }).code : "";
25
32
  const appended = [checker, coercer, repairer].filter((part) => part !== "");
26
33
  const body = validatorFunction + booleanGuard + (appended.length === 0 ? "" : "\n\n" + appended.join("\n\n"));
27
34
  const mentions = identifierMentions(typeDefinition + body);
@@ -30,9 +37,10 @@ const generateValidatorFile = (schema, typeName, options) => {
30
37
  rootSchema: options?.rootSchema,
31
38
  typeSuffix,
32
39
  importExt,
33
- reads: ({ typeName: name, validatorName, checkerName, coercerName, repairerName }) => ({
40
+ reads: ({ typeName: name, validatorName, guardName, checkerName, coercerName, repairerName }) => ({
34
41
  type: mentions(name),
35
42
  validator: mentions(validatorName),
43
+ guard: mentions(guardName),
36
44
  checker: mentions(checkerName),
37
45
  coercer: mentions(coercerName),
38
46
  repairer: mentions(repairerName)
@@ -22,6 +22,11 @@ export declare const repairLookupNameFor: (ref: string, typeSuffix: string) => s
22
22
  * then can its own children be judged. A position is repaired at most once, so
23
23
  * the loop terminates on progress rather than on the pass cap.
24
24
  */
25
- export declare const generateRepairFunction: (schema: JSONSchema, typeName: string, typeSuffix?: string) => {
25
+ export declare const generateRepairFunction: (schema: JSONSchema, typeName: string, typeSuffix?: string, options?: {
26
+ /** The whole document, for a `propertyNames` test that reads a `$ref`'s target. */
27
+ readonly rootSchema?: Record<string, unknown>;
28
+ /** The `format` names the validator enforces, so a key test agrees with it. */
29
+ readonly formats?: ReadonlySet<string>;
30
+ }) => {
26
31
  code: string;
27
32
  };
@@ -1,8 +1,24 @@
1
+ import { regexFlagsFor } from "@amritk/helpers/escape-regex-pattern";
1
2
  import { getDefaultValue } from "@amritk/helpers/get-default-value";
2
3
  import { readKey } from "@amritk/helpers/read-key";
3
4
  import { refToName } from "@amritk/helpers/ref-to-name";
4
5
  import { isSchemaObject } from "@amritk/helpers/schema-guards";
6
+ import { NO_FORMATS } from "./enforced-keywords.js";
7
+ import { createSubschemaMatcher } from "./generate-validator-function.js";
5
8
  const repairLookupNameFor = (ref, typeSuffix) => `repair${refToName(ref, typeSuffix)}At`;
9
+ const keyTest = (nameSchema, ctx) => {
10
+ const named = ctx.matcher.named(nameSchema);
11
+ if (named !== null)
12
+ return named;
13
+ const body = ctx.matcher.test(nameSchema);
14
+ if (typeof body !== "string")
15
+ return body;
16
+ const name = `_repairKey${ctx.next()}`;
17
+ const parameter = /\binput\b/.test(body) ? "input" : "_input";
18
+ const path = /\b_path\b/.test(body) ? [` const _path = ''`] : [];
19
+ ctx.helpers.push([`const ${name} = (${parameter}: unknown): boolean => {`, ...path, body, `}`].join("\n"));
20
+ return name;
21
+ };
6
22
  const fallbackLiteral = (schema) => {
7
23
  const literal = getDefaultValue(schema);
8
24
  return literal === "undefined" ? null : literal;
@@ -21,12 +37,20 @@ const emitWalker = (schema, ctx) => {
21
37
  const own = fallbackLiteral(schema);
22
38
  const properties = schemaMap(node, "properties") ?? {};
23
39
  const children = Object.entries(properties).map(([key, child]) => [key, emitWalker(child, ctx)]).filter((entry) => entry[1] !== null);
40
+ const patternProperties = schemaMap(node, "patternProperties") ?? {};
41
+ const patterns = Object.entries(patternProperties).map(([source, child]) => ({
42
+ source,
43
+ walker: emitWalker(child, ctx)
44
+ }));
45
+ const additional = readKey(node, "additionalProperties");
46
+ const additionalWalker = typeof additional === "object" && additional !== null ? emitWalker(additional, ctx) : null;
47
+ const mapped = additionalWalker !== null || patterns.some((pattern) => pattern.walker !== null);
24
48
  const prefixItems = readKey(node, "prefixItems");
25
49
  const tuple = Array.isArray(prefixItems) ? prefixItems : [];
26
50
  const tupleWalkers = tuple.map((item) => emitWalker(item, ctx));
27
51
  const items = readKey(node, "items");
28
52
  const itemsWalker = typeof items === "object" && items !== null && !Array.isArray(items) ? emitWalker(items, ctx) : null;
29
- const hasChildren = children.length > 0 || itemsWalker !== null || tupleWalkers.some((walker) => walker !== null);
53
+ const hasChildren = children.length > 0 || mapped || itemsWalker !== null || tupleWalkers.some((walker) => walker !== null);
30
54
  if (own === null && !hasChildren)
31
55
  return null;
32
56
  const id = ctx.next();
@@ -36,15 +60,28 @@ const emitWalker = (schema, ctx) => {
36
60
  ` if (segments.length === 0) return ${own === null ? "null" : `() => (${own})`}`
37
61
  ];
38
62
  if (hasChildren) {
39
- lines.push(` const [head, ...rest] = segments as [string, ...string[]]`);
40
- if (children.length > 0) {
63
+ const declared = mapped ? Object.keys(properties) : children.map(([key]) => key);
64
+ const indexed = tupleWalkers.some((walker) => walker !== null) || itemsWalker !== null;
65
+ const arrayKeywords = tuple.length > 0 || typeof items === "object" && items !== null && !Array.isArray(items);
66
+ const indexBlock = indexed || mapped && arrayKeywords;
67
+ const nameSchema = readKey(node, "propertyNames");
68
+ const keyCheck = nameSchema === void 0 ? true : keyTest(nameSchema, ctx);
69
+ const readsHead = declared.length > 0 || indexBlock || mapped && patterns.length > 0 || typeof keyCheck === "string";
70
+ lines.push(` const [${readsHead ? "head" : ""}, ...rest] = segments as [string, ...string[]]`);
71
+ if (keyCheck === false)
72
+ lines.push(` if (rest.length === 0) return null`);
73
+ if (typeof keyCheck === "string")
74
+ lines.push(` if (rest.length === 0 && !${keyCheck}(head)) return null`);
75
+ if (declared.length > 0) {
76
+ const walkers = new Map(children);
41
77
  lines.push(` switch (head) {`);
42
- for (const [key, walker] of children)
43
- lines.push(` case ${JSON.stringify(key)}: return ${walker}(rest)`);
78
+ for (const key of declared) {
79
+ const walker = walkers.get(key);
80
+ lines.push(` case ${JSON.stringify(key)}: return ${walker === void 0 ? "null" : `${walker}(rest)`}`);
81
+ }
44
82
  lines.push(` }`);
45
83
  }
46
- const indexed = tupleWalkers.some((walker) => walker !== null) || itemsWalker !== null;
47
- if (indexed) {
84
+ if (indexBlock) {
48
85
  lines.push(` const index = Number(head)`);
49
86
  lines.push(` if (Number.isInteger(index) && index >= 0) {`);
50
87
  tupleWalkers.forEach((walker, position) => {
@@ -55,21 +92,43 @@ const emitWalker = (schema, ctx) => {
55
92
  if (itemsWalker !== null) {
56
93
  lines.push(` if (index >= ${tuple.length}) return ${itemsWalker}(rest)`);
57
94
  }
95
+ if (mapped && arrayKeywords)
96
+ lines.push(` return null`);
58
97
  lines.push(` }`);
59
98
  }
99
+ if (mapped && patterns.length > 0) {
100
+ const regexNames = patterns.map((pattern, index) => {
101
+ const regexName = `_repairPattern${id}_${index}`;
102
+ ctx.helpers.push(`const ${regexName} = new RegExp(${JSON.stringify(pattern.source)}, ${JSON.stringify(regexFlagsFor(pattern.source))})`);
103
+ return regexName;
104
+ });
105
+ const answer = (walker) => walker === null ? "null" : `${walker}(rest)`;
106
+ const only = patterns[0];
107
+ if (patterns.length === 1 && only !== void 0) {
108
+ lines.push(` if (${regexNames[0]}.test(head)) return ${answer(only.walker)}`);
109
+ } else {
110
+ lines.push(` const claims = [${regexNames.join(", ")}].filter((pattern) => pattern.test(head))`);
111
+ lines.push(` if (claims.length > 1) return null`);
112
+ patterns.forEach((pattern, index) => {
113
+ lines.push(` if (claims[0] === ${regexNames[index]}) return ${answer(pattern.walker)}`);
114
+ });
115
+ }
116
+ }
60
117
  }
61
- lines.push(` return null`, `}`);
118
+ lines.push(additionalWalker === null ? ` return null` : ` return ${additionalWalker}(rest)`, `}`);
62
119
  ctx.helpers.push(lines.join("\n"));
63
120
  return name;
64
121
  };
65
- const generateRepairFunction = (schema, typeName, typeSuffix = "") => {
122
+ const generateRepairFunction = (schema, typeName, typeSuffix = "", options = {}) => {
66
123
  const helpers = [];
67
124
  let counter = 0;
68
- const ctx = { typeSuffix, helpers, next: () => counter++ };
125
+ const matcher = createSubschemaMatcher(typeSuffix, options.rootSchema ?? (isSchemaObject(schema) ? schema : void 0), options.formats ?? NO_FORMATS, "_repair");
126
+ const ctx = { typeSuffix, helpers, next: () => counter++, matcher };
69
127
  const walker = emitWalker(schema, ctx);
70
128
  const lookupName = `repair${typeName}At`;
129
+ const declarations = [...matcher.declarations(helpers.join("\n")), ...helpers];
71
130
  const parts = [
72
- ...helpers.length > 0 ? [helpers.join("\n\n"), ""] : [],
131
+ ...declarations.length > 0 ? [declarations.join("\n\n"), ""] : [],
73
132
  `export const ${lookupName}: RepairLookup = ${walker === null ? "() => null" : walker}`,
74
133
  "",
75
134
  `export const repair${typeName} = (input: unknown, _path = ''): RepairResult<${typeName}> => {`,
@@ -17,6 +17,19 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
17
17
  * {@link UnknownKeysStrategy}.
18
18
  */
19
19
  export declare const generateBooleanGuard: (schema: JSONSchema, typeName: string, _suffix?: string, unknownKeys?: UnknownKeysStrategy, formats?: ReadonlySet<string>) => string;
20
+ /**
21
+ * {@link generateBooleanGuard}, together with whether the guard it emitted stands
22
+ * on its own or falls back to calling `validateX`.
23
+ *
24
+ * The coercing half asks: it answers an already-valid document with one guard
25
+ * call, which is only a saving when the guard is a real fail-fast check. A
26
+ * fallback runs the whole validator and builds every error on the way to `false`,
27
+ * so there the coercer walks first instead.
28
+ */
29
+ export declare const generateBooleanGuardSource: (schema: JSONSchema, typeName: string, unknownKeys?: UnknownKeysStrategy, formats?: ReadonlySet<string>) => {
30
+ code: string;
31
+ standalone: boolean;
32
+ };
20
33
  /**
21
34
  * Generates a TypeScript validator function from a JSON Schema.
22
35
  *
@@ -56,3 +69,38 @@ export declare const generateValidatorFunction: (schema: JSONSchema, typeName: s
56
69
  * way; only the short circuit is lost.
57
70
  */
58
71
  export declare const generateCheckFunction: (schema: JSONSchema, typeName: string, suffix?: string, rootSchema?: Record<string, unknown>, unknownKeys?: UnknownKeysStrategy, formats?: ReadonlySet<string>) => string;
72
+ /**
73
+ * A yes/no matcher for subschemas, for generated code that has to ask "does this
74
+ * value satisfy that branch?" outside any validator body.
75
+ *
76
+ * The coercing half is the caller: a union only knows which branch a value was
77
+ * meant for by asking each branch, and the answer has to be the one `validateX`
78
+ * gives, or coercion would steer a value toward a branch the validator then
79
+ * rejects. So this hands out the very match expression the `anyOf` / `oneOf`
80
+ * emitters use, rather than a second opinion.
81
+ *
82
+ * Every expression built from one matcher shares one hoisted list, so the names
83
+ * it mints are unique across all of them; `hoistNamespace` keeps those names
84
+ * apart from the validator's own in the same module. `declarations` returns the
85
+ * hoisted lines a finished text actually reads, pruned the way
86
+ * {@link withHoisted} prunes a validator's.
87
+ */
88
+ export declare const createSubschemaMatcher: (suffix: string, rootSchema: Record<string, unknown> | undefined, formats: ReadonlySet<string>, hoistNamespace: string, refGuard?: (typeName: string) => string) => {
89
+ /**
90
+ * The test for `sub` as the body of a function whose parameter is `input` —
91
+ * statements that `return false` at the first failure and `true` at the end —
92
+ * or a constant when the answer never depends on the value.
93
+ *
94
+ * A body rather than an expression because an expression here is an IIFE, and
95
+ * JavaScriptCore allocates the closure on every call: roughly 50 ns per branch
96
+ * tested, which on a union-heavy document was most of what coercing it cost.
97
+ */
98
+ test: (sub: JSONSchema) => boolean | string;
99
+ /**
100
+ * The same test as a named function, hoisted with the rest and shared with
101
+ * every other test of the same subschema in this file — or a constant, or
102
+ * `null` for a body only the buffered form of {@link test} can express.
103
+ */
104
+ named: (sub: JSONSchema) => boolean | string | null;
105
+ declarations: (text: string) => string[];
106
+ };