@amritk/validation 0.4.0 → 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.
package/AI.md CHANGED
@@ -44,14 +44,16 @@ Every mode is reachable from `mjst` without dropping to the programmatic API:
44
44
  |:---|:---|
45
45
  | `types` | `--types-only` (alone), otherwise always emitted |
46
46
  | `guard`, `validate` | `--validators`, or `--validators-only` for no parser |
47
- | `check` | `--check` |
48
- | `coerce` | `--coerce` |
49
- | `repair` | `--repair` (emits `coerceX` too) |
47
+ | `check` | `--validators --check` |
48
+ | `coerce` | `--validators --coerce` |
49
+ | `repair` | `--validators --repair` (emits `coerceX` too) |
50
50
  | `parse` | the default on any run that is not `--types-only`/`--validators-only` |
51
51
  | `parseStrict` | `--strict` |
52
52
 
53
53
  `--validators-only` is this package's own default (`['types', 'guard', 'validate']`)
54
54
  spelled as a flag: everything that judges a document, nothing that builds one.
55
+ `--check`, `--coerce` and `--repair` shape the validator half, so the CLI rejects
56
+ any of them without one of those two flags.
55
57
 
56
58
  ## Gotchas — where agents fail
57
59
 
@@ -83,10 +85,10 @@ spelled as a flag: everything that judges a document, nothing that builds one.
83
85
  validateX(v)` pays for both passes and is no faster than `validateX`.
84
86
 
85
87
  8. **It emits each engine's exact bytes.** A single-mode build is byte-identical
86
- to what that engine emitted when it was its own package, so there is no runtime
87
- difference to reason about and no speedup to claim — do not tell a user this is
88
- faster. What it changes is that the whole matrix comes out as one type instead
89
- of two, and that a build asking for no validator mode ships no
88
+ to what the engine that owns that mode emits when called directly, so there is
89
+ no runtime difference to reason about and no speedup to claim — do not tell a
90
+ user this is faster. What it changes is that the whole matrix comes out as one
91
+ type instead of two, and that a build asking for no validator mode ships no
90
92
  `validation-result.ts`.
91
93
 
92
94
  9. **`coerceX` is not Ajv's `coerceTypes`, on purpose.** It never modifies its
package/README.md CHANGED
@@ -74,6 +74,7 @@ so no two can disagree about whether a document is valid.
74
74
  | `types` | — | — | — | — |
75
75
  | `guard` | `isX(input): input is X` | — | a boolean | the first problem |
76
76
  | `validate` | `validateX(input)` | — | every error | the end |
77
+ | `check` | `checkX(input)` | — | the first error | the first problem |
77
78
  | `coerce` | `coerceX(input)` | coerced | every error | the end |
78
79
  | `repair` | `repairX(input)` | repaired | repairs **and** errors | the end |
79
80
  | `parse` | `parseX(input)` | repaired | nothing | never fails |
@@ -84,7 +85,9 @@ it stops at the first thing wrong and builds no error object, which on invalid
84
85
  input is dramatically cheaper than reporting. `validate` is the other half: every
85
86
  error, each with a JSON Pointer, the keyword that rejected the value, and that
86
87
  keyword's own values. Reach for `guard` when the answer is a branch and
87
- `validate` when a human or an API client has to be told what to fix.
88
+ `validate` when a human or an API client has to be told what to fix. `check` sits
89
+ between them: `checkX` returns the same result type as `validateX`, holding only
90
+ the error `validateX` would have reported first, and walks no further.
88
91
 
89
92
  **Coerce and repair, with or without the errors.** `coerceX` and `repairX` hand
90
93
  back the value *and* the diagnostics; `parseX` hands back only the value and
@@ -92,6 +95,14 @@ never fails. Coercion moves a value that is already right but written in the
92
95
  wrong type; repair also substitutes a value the schema supplies for one that
93
96
  cannot be coerced, and reports the validator's own errors as the repairs.
94
97
 
98
+ That makes `repairX` an autofix tool rather than a stricter validator. It accepts
99
+ documents `validateX` rejects (a missing required name gets one; an unknown
100
+ `enum` value becomes the first member), and accepting only `valid` with an empty
101
+ `repairs` gives exactly the verdicts `coerceX` gives. An empty `repairs` alone is
102
+ not enough: a document nothing could repair comes back with none either, and
103
+ `valid: false`. To reject a bad config, use `coerceX`. To hand back a fixed one,
104
+ use `repairX`.
105
+
95
106
  **`parseX` agrees with `coerceX`.** Wherever `coerceX` accepts a document, the
96
107
  coercing `parseX` returns the very same value — through a union, an `allOf`, an
97
108
  `if`, a `$ref`, recursion included — and it only repairs what `coerceX` would
@@ -119,11 +130,12 @@ schemas with `anyOf`, `oneOf`, `allOf` and `if`/`then`/`else` checks every
119
130
  accepted document against an Ajv that does not coerce. Where the two differ, it
120
131
  is on purpose:
121
132
 
122
- - **`null` is never coerced**, in either direction. Ajv reads `null` as `""`, `0`
123
- or `false` for a string, number or boolean field, and reads those back as
124
- `null`. A `null` usually means "not set", and turning it into a real value
125
- erases that. Here it goes to the validator as written, and the validator
126
- rejects it.
133
+ - **`null` is never coerced**, in either direction, and it is the difference
134
+ you will meet most: on a real config corpus it was nearly every case where Ajv
135
+ accepted and `coerceX` did not. Ajv reads `null` as `""`, `0` or `false` for a
136
+ string, number or boolean field, and reads those back as `null`. A `null`
137
+ usually means "not set", and turning it into a real value erases that. Here it
138
+ goes to the validator as written, and the validator rejects it.
127
139
  - **Only clean numerals become numbers.** `" "`, `" 1 "`, `"0x10"`, `"Infinity"`
128
140
  and `"1."` are rejected. Ajv turns them into `0`, `1`, `16`, a value JSON
129
141
  cannot hold, and `1`. `"007"` and `"1e3"` are still coerced.
@@ -146,8 +158,8 @@ is on purpose:
146
158
  document has to clone it first. `coerceX` returns a new value that shares
147
159
  everything it did not touch, or the input itself when nothing needed coercing.
148
160
 
149
- **It is faster.** Against Ajv cloning first, so both leave the caller's document
150
- alone (`bun run bench:validators:coerce`, or
161
+ **It is faster where the input has to survive.** Against Ajv cloning first, so
162
+ both leave the caller's document alone (`bun run bench:validators:coerce`, or
151
163
  `bench:validators:coerce:node`):
152
164
 
153
165
  | schema | runtime | valid input | needs coercing | cannot be coerced |
@@ -166,7 +178,11 @@ each engine timed in its own process (Bun 1.3.11, Node 22.22, Linux x64). A
166
178
  valid document is the common case and the widest gap: where `isX` is a
167
179
  standalone guard, `coerceX` answers it with that guard and hands the input
168
180
  straight back, without walking it. On valid input Ajv rewrites nothing, so it can
169
- also be timed without the clone; `coerceX` is still ahead there, by 1.2–3.3×.
181
+ also be timed without the clone; `coerceX` is still ahead there on these
182
+ schemas, by 1.2–3.3×, but not on every one: on a large, union-heavy document
183
+ that the caller lets Ajv coerce in place, Ajv can come out modestly ahead. The
184
+ gap above is the clone, so it is real exactly where the caller cannot let Ajv
185
+ mutate its input.
170
186
  The config case is the shape `--coerce` exists for: every coercible scalar sits
171
187
  inside a union reached through `$ref`, and every union branch is tested by a
172
188
  named function rather than a closure built per call.</sub>
@@ -196,10 +212,17 @@ holding it (`/readme`). `params.additionalProperty` names the key in both.
196
212
  | `options.readonly` | `boolean` | `false` | Emit `readonly` type members. |
197
213
  | `options.caseInsensitive` | `boolean` | `false` | Normalize a mis-cased string onto an `enum` member it matches. |
198
214
  | `options.helpersMode` | `'package' \| 'embedded'` | `'package'` | Where the parser half gets its runtime helpers. |
215
+ | `options.helpersImportPrefix` | `string` | `'./'` | Prefix on emitted-helper import specifiers, when `helpersMode` is `'embedded'`. |
199
216
  | `options.extensions` | `SchemaExtensions` | — | Schema extensions, passed through to the parser half. |
217
+ | `options.importExt` | `'js' \| 'ts'` | `'js'` | Extension on every emitted relative import. `'ts'` emits the on-disk paths, so the output runs under Node's type stripping with no build step. |
218
+ | `options.logWarnings` | `boolean` | `false` | Print the parser half's warnings about the schema, such as a keyword it cannot enforce. |
200
219
 
201
220
  Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; content: string }`.
202
221
 
222
+ The package also exports `ALL_MODES` (every mode but `parseStrict`, for a caller
223
+ who wants the lot) and the types `Mode`, `GenerateOptions`, `GeneratedFile` and
224
+ `ImportExtension`.
225
+
203
226
  ---
204
227
 
205
228
  ## Output layout
@@ -208,6 +231,8 @@ Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; c
208
231
  config.ts the type, plus isX / validateX / coerceX / repairX
209
232
  config.parse.ts parseX, importing the type from ./config.js
210
233
  validation-result.ts the shared error and result types, and the runtime helpers
234
+ formats.ts the format checks, only when `formats` enforces one the schema names
235
+ _helpers/ the parser's runtime helpers, only when `helpersMode` is 'embedded'
211
236
  index.ts a barrel over all of it
212
237
  ```
213
238
 
@@ -25,7 +25,10 @@ const generateValidatorFile = (schema, typeName, options) => {
25
25
  formats,
26
26
  standaloneGuard
27
27
  }).code : "";
28
- const repairer = options?.repair === true ? generateRepairFunction(schema, typeName, typeSuffix).code : "";
28
+ const repairer = options?.repair === true ? generateRepairFunction(schema, typeName, typeSuffix, {
29
+ ...options?.rootSchema !== void 0 ? { rootSchema: options.rootSchema } : {},
30
+ formats
31
+ }).code : "";
29
32
  const appended = [checker, coercer, repairer].filter((part) => part !== "");
30
33
  const body = validatorFunction + booleanGuard + (appended.length === 0 ? "" : "\n\n" + appended.join("\n\n"));
31
34
  const mentions = identifierMentions(typeDefinition + body);
@@ -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}> => {`,
@@ -292,7 +292,7 @@ const generateKeywordChecks = (key, raw, path, schema, suffix, ctx, presence) =>
292
292
  lines.push(` }`);
293
293
  }
294
294
  if (hasConst(schema)) {
295
- const mismatch = constMismatchCondition(raw, schema.const);
295
+ const mismatch = constMismatchCondition(typeTestAccessor(raw, ctx), schema.const);
296
296
  const msg = JSON.stringify(`must be ${JSON.stringify(schema.const)}`);
297
297
  lines.push(` if (${presence}${mismatch}) {`);
298
298
  lines.push(` ${pushError(ctx.sink, msg, path, "const", JSON.stringify({ allowedValue: schema.const }))}`);
@@ -300,7 +300,7 @@ const generateKeywordChecks = (key, raw, path, schema, suffix, ctx, presence) =>
300
300
  }
301
301
  if (hasEnum(schema)) {
302
302
  const label = schema.enum.map((v) => JSON.stringify(v)).join(", ");
303
- lines.push(` if (${presence}!${enumMembershipExpr(schema.enum, raw)}) {`);
303
+ lines.push(` if (${presence}!${enumMembershipExpr(schema.enum, typeTestAccessor(raw, ctx))}) {`);
304
304
  lines.push(` ${pushError(ctx.sink, JSON.stringify(`must be one of: ${label}`), path, "enum", JSON.stringify({ allowedValues: schema.enum }))}`);
305
305
  lines.push(` }`);
306
306
  }
@@ -609,7 +609,31 @@ const namedMatcher = (sub, suffix, ctx) => {
609
609
  ctx.hoisted.push({ declaration, reference: `${name}(`, calls: true });
610
610
  return name;
611
611
  };
612
- const generateMatchesExpr = (raw, sub, suffix, ctx, required = false, branch) => {
612
+ const namedBranchErrors = (sub, suffix, ctx) => {
613
+ if (!isSchemaObject(sub))
614
+ return null;
615
+ const key = `explain:${JSON.stringify(sub)}`;
616
+ const existing = ctx.namedMatchers.get(key);
617
+ if (existing !== void 0)
618
+ return existing;
619
+ const fnCtx = { ...ctx, objVar: "obj", pathPrefix: "${_path}", depth: 0, sink: MATCH_ERROR_SINK };
620
+ const checks = generateValueChecks("", "input", "`${_path}`", sub, suffix, fnCtx, true);
621
+ if (checks.length === 0)
622
+ return null;
623
+ const body = checks.join("\n");
624
+ const name = `${hoistPrefix(ctx)}_explain${ctx.namedMatchers.size}`;
625
+ const declaration = [
626
+ `const ${name} = (${readsBinding("input", body) ? "input" : "_input"}: unknown, _path: string): ValidationError[] => {`,
627
+ ` const ${MATCH_ERROR_SINK}: ValidationError[] = []`,
628
+ body,
629
+ ` return ${MATCH_ERROR_SINK}`,
630
+ `}`
631
+ ].join("\n");
632
+ ctx.namedMatchers.set(key, name);
633
+ ctx.hoisted.push({ declaration, reference: `${name}(`, calls: true });
634
+ return name;
635
+ };
636
+ const generateMatchesExpr = (raw, sub, suffix, ctx, required = false) => {
613
637
  if (sub === true)
614
638
  return "true";
615
639
  if (sub === false)
@@ -617,31 +641,26 @@ const generateMatchesExpr = (raw, sub, suffix, ctx, required = false, branch) =>
617
641
  if (!isSchemaObject(sub))
618
642
  return "true";
619
643
  const value = NARROWABLE_REFERENCE.test(raw) ? raw : `_mv${ctx.depth}`;
620
- const valuePath = branch === void 0 ? "`${_path}`" : branch.path;
644
+ const valuePath = "`${_path}`";
621
645
  const binding = value === raw ? "" : `const ${value}: unknown = ${raw}
622
646
  `;
623
- if (branch === void 0) {
624
- const named = namedMatcher(sub, suffix, ctx);
625
- if (named !== null)
626
- return named === "true" ? "true" : `${named}(${raw})`;
627
- const matchState = { on: ctx.failFast.on, blocked: false };
628
- const matchCtx = { ...ctx, sink: MATCH_BOOL_SINK, failFast: matchState, branchErrors: false };
629
- const boolChecks = generateValueChecks("", value, valuePath, sub, suffix, matchCtx, required);
630
- if (boolChecks.length === 0)
631
- return "true";
632
- if (!matchState.blocked)
633
- return `((): boolean => {
647
+ const named = namedMatcher(sub, suffix, ctx);
648
+ if (named !== null)
649
+ return named === "true" ? "true" : `${named}(${raw})`;
650
+ const matchState = { on: ctx.failFast.on, blocked: false };
651
+ const matchCtx = { ...ctx, sink: MATCH_BOOL_SINK, failFast: matchState, branchErrors: false };
652
+ const boolChecks = generateValueChecks("", value, valuePath, sub, suffix, matchCtx, required);
653
+ if (boolChecks.length === 0)
654
+ return "true";
655
+ if (!matchState.blocked)
656
+ return `((): boolean => {
634
657
  ${binding}${boolChecks.join("\n")}
635
658
  return true })()`;
636
- }
637
659
  const checks = generateValueChecks("", value, valuePath, sub, suffix, { ...ctx, sink: MATCH_ERROR_SINK }, required);
638
660
  if (checks.length === 0)
639
661
  return "true";
640
- const body = checks.join("\n");
641
- const collect = branch === void 0 ? "" : `
642
- if (_m.length !== 0) (${branch.buffer} ??= []).push(_m)`;
643
662
  return `((): boolean => { const _m: ValidationError[] = []
644
- ${binding}${body}${collect}
663
+ ${binding}${checks.join("\n")}
645
664
  return _m.length === 0 })()`;
646
665
  };
647
666
  const unevaluatedMatcher = (suffix, ctx) => (accessor, schema, depth) => (
@@ -691,17 +710,18 @@ const generateUnevaluatedChecks = (raw, path, schema, suffix, ctx) => {
691
710
  }
692
711
  return lines;
693
712
  };
694
- const BRANCH_BUFFER = "_br";
695
- const selectBranchErrorsLine = (path, ctx) => `for (const _e of selectBranchErrors(${BRANCH_BUFFER} ?? [], ${path})) ${ctx.sink}.push(_e)`;
696
- const branchBufferBlock = (guard, message, keyword, path, ctx) => [
697
- ` {`,
698
- ` let ${BRANCH_BUFFER}: ValidationError[][] | null = null`,
699
- ` if (${guard}) {`,
700
- ` ${pushError(ctx.sink, message, path, keyword)}`,
701
- ` ${selectBranchErrorsLine(path, ctx)}`,
702
- ` }`,
713
+ const branchErrorsReport = (guard, raw, explain, message, keyword, path, ctx) => [
714
+ ` if (${guard}) {`,
715
+ ` ${pushError(ctx.sink, message, path, keyword)}`,
716
+ ...explain.length === 0 ? [] : [
717
+ ` const _bp = ${path}`,
718
+ ` const _br: ValidationError[][] = []`,
719
+ ` for (const _b of [${explain.map((name) => `${name}(${raw}, _bp)`).join(", ")}]) if (_b.length !== 0) _br.push(_b)`,
720
+ ` for (const _e of selectBranchErrors(_br, _bp)) ${ctx.sink}.push(_e)`
721
+ ],
703
722
  ` }`
704
723
  ];
724
+ const branchExplainers = (branches, suffix, ctx) => branches.map((branch) => namedBranchErrors(branch, suffix, ctx)).filter((name) => name !== null);
705
725
  const generateCombinatorChecks = (key, raw, path, schema, suffix, ctx) => {
706
726
  if (!isSchemaObject(schema))
707
727
  return [];
@@ -711,11 +731,10 @@ const generateCombinatorChecks = (key, raw, path, schema, suffix, ctx) => {
711
731
  lines.push(...generateValueChecks(key, raw, path, branch, suffix, ctx, true));
712
732
  }
713
733
  if (hasAnyOf(schema) && schema.anyOf.length > 0) {
714
- const collector = ctx.branchErrors ? { buffer: BRANCH_BUFFER, path } : void 0;
715
- const conds = schema.anyOf.map((b) => generateMatchesExpr(raw, b, suffix, ctx, true, collector));
734
+ const conds = schema.anyOf.map((b) => generateMatchesExpr(raw, b, suffix, ctx, true));
716
735
  if (!conds.includes("true")) {
717
736
  if (ctx.branchErrors) {
718
- lines.push(...branchBufferBlock(`!(${conds.join(" || ")})`, `'must match a schema in anyOf'`, "anyOf", path, ctx));
737
+ lines.push(...branchErrorsReport(`!(${conds.join(" || ")})`, raw, branchExplainers(schema.anyOf, suffix, ctx), `'must match a schema in anyOf'`, "anyOf", path, ctx));
719
738
  } else {
720
739
  const condition = `!(${conds.join(" || ")})`;
721
740
  if (LITERAL_CONDITION.test(condition))
@@ -727,20 +746,18 @@ const generateCombinatorChecks = (key, raw, path, schema, suffix, ctx) => {
727
746
  }
728
747
  }
729
748
  if (hasOneOf(schema) && schema.oneOf.length > 0) {
730
- const collector = ctx.branchErrors ? { buffer: BRANCH_BUFFER, path } : void 0;
731
- const conds = schema.oneOf.map((b) => `(${generateMatchesExpr(raw, b, suffix, ctx, true, collector)} ? 1 : 0)`);
749
+ const conds = schema.oneOf.map((b) => `(${generateMatchesExpr(raw, b, suffix, ctx, true)} ? 1 : 0)`);
732
750
  const message = `'must match exactly one schema in oneOf'`;
733
751
  if (!ctx.branchErrors) {
734
752
  lines.push(` if ((${conds.join(" + ")}) !== 1) {`);
735
753
  lines.push(` ${pushError(ctx.sink, message, path, "oneOf")}`);
736
754
  lines.push(` }`);
737
755
  } else {
756
+ const explain = branchExplainers(schema.oneOf, suffix, ctx);
738
757
  lines.push(` {`);
739
- lines.push(` let ${BRANCH_BUFFER}: ValidationError[][] | null = null`);
740
758
  lines.push(` const _n = ${conds.join(" + ")}`);
741
- lines.push(` if (_n === 0) {`);
742
- lines.push(` ${pushError(ctx.sink, message, path, "oneOf")}`);
743
- lines.push(` ${selectBranchErrorsLine(path, ctx)}`);
759
+ const report = branchErrorsReport("_n === 0", raw, explain, message, "oneOf", path, ctx).map((line) => ` ${line}`);
760
+ lines.push(...report.slice(0, -1));
744
761
  lines.push(` } else if (_n !== 1) {`);
745
762
  lines.push(` ${pushError(ctx.sink, message, path, "oneOf")}`);
746
763
  lines.push(` }`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/validation",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "One generator surface over mjst's types, validators, coercers, repairers and parsers.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",