ata-validator 1.47.0 → 1.49.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
@@ -43,6 +43,16 @@ npm install ata-validator --omit=optional
43
43
 
44
44
  or set `ATA_NO_NATIVE=1` at runtime. Typical schemas compile to specialized JS; shapes the compiler cannot represent (some `$dynamicRef`, cyclic `$ref`, unusual keyword interactions) fall back to an interpreted engine, so every schema validates in every environment. The pure-JS setup scores the same on the official suite as the native one, 1301 of 1301 Draft 2020-12 cases. The buffer and parallel APIs (`isValid` on raw buffers, `isValidPrepadded`, `isValidNDJSON`, `isValidParallel`, `countValid`, `batchIsValid`, `validateAndParse`) work without the addon too, answering through the same checks as `isValidJSON()`, slower than the addon: 177 ns against 92 for a small document on Node 25. In a browser, which has no `Buffer`, they throw and name the methods to use instead.
45
45
 
46
+ Where the size of what ships matters more than nanoseconds per call, `ata-validator/lite` is
47
+ the same `Validator` on the interpreted engine alone, without the code generator: the same
48
+ verdicts and errors, 58 KB gzipped in a bundled worker against 136 KB for the full entry.
49
+ It leaves out what needs the generator, `parse()` and the ahead-of-time bundle methods.
50
+
51
+ On Deno the same package is `npm:ata-validator` or `jsr:@ata/validator`; the JSR package
52
+ re-exports the npm one at the same version, and CI runs the official suite under Deno. The
53
+ details, and what Deno Deploy's refusal of dynamic code means for it, are in
54
+ [docs/edge-runtimes.md](docs/edge-runtimes.md).
55
+
46
56
  Those four now agree with `validate()` on every case of the official suite, 3365 across three dialects. The native walker behind them does not handle every shape (`contains`, `unevaluatedProperties`, `patternProperties`, tuple `items`, cross-document `$ref`, a few formats), so for schemas using one of those the buffer APIs parse the bytes and answer through `validate()`; the list is in `lib/buffer-gate.js`. Typical request schemas stay on the zero-copy path. `npm test` holds the disagreement count at zero.
47
57
 
48
58
  Where `new Function` is refused altogether, on Cloudflare Workers, Deno Deploy or under a strict Content-Security-Policy, ata drops to the interpreted engine and scores the same 1301 of 1301 with code generation blocked. No flags, and on Workers no `nodejs_compat` either. See [docs/edge-runtimes.md](docs/edge-runtimes.md).
@@ -99,6 +109,29 @@ file.
99
109
  passes with nothing failed, errored or skipped under Bowtie's own runner. The v1 dialect is
100
110
  declared in a pending harness change that waits for a Bowtie release.
101
111
 
112
+ ### Side by side
113
+
114
+ [validator-benchmarks](https://github.com/ata-core/validator-benchmarks) is ata's own
115
+ comparison, so it comes after the ones above: eleven validators on three realistic bodies
116
+ and four scenes, each library in its own process, 27 samples per cell, every schema and
117
+ document in the repository. On an order body with formats and a pattern, 900 valid
118
+ documents, Node 24:
119
+
120
+ [![Documents a second on one order body, eleven validators](https://raw.githubusercontent.com/ata-core/validator-benchmarks/main/media/race-order-verdict-valid.gif)](https://github.com/ata-core/validator-benchmarks/blob/main/media/race-order-verdict-valid.mp4)
121
+
122
+ | | documents a second, the verdict | same, `new Function` refused |
123
+ |---|---:|---:|
124
+ | ata | 3,295,880 | 607,614 |
125
+ | ata, compiled module | 3,319,594 | 3,344,948 |
126
+ | typebox | 1,638,813 | EvalError |
127
+ | ajv | 1,285,492 | EvalError |
128
+ | typia | 939,572 | 964,507 |
129
+ | zod | 440,473 | 237,176 |
130
+
131
+ Where ata is behind is in the same tables: on a four-field body that fails, ajv answers the
132
+ verdict faster, and with every error read it does 6.6M a second against ata's 2.9M with rich
133
+ errors (7.6M with `richErrors: false`).
134
+
102
135
  ## Why AOT
103
136
 
104
137
  | Dimension | Schema | ata-AOT | runtime validator | Difference |
@@ -244,13 +277,13 @@ is the most common way to get a misleading number out of this library.
244
277
 
245
278
  | | compiled with `ata build` | runtime `new Validator(schema)` |
246
279
  |---|---|---|
247
- | In a bundle, gzipped | **2.1 KB** | 125.0 KB |
280
+ | In a bundle, gzipped | **2.1 KB** | 126.1 KB |
248
281
  | Time to a served request | **3.5 ms** | 7.6 ms |
249
282
  | Schema known when | build time | any time |
250
283
 
251
284
  The bundle row is the ten-field user schema in
252
285
  `tests/fixtures/error-dx/user.schema.json`, every export of the compiled module against
253
- `new Validator(schema)`, built with `bun build --minify --target=browser` on ata 1.47.0.
286
+ `new Validator(schema)`, built with `bun build --minify --target=browser` on ata 1.48.0.
254
287
  The startup row is a Hono route on Bun 1.4, the median of three rounds of best-of-seven, from
255
288
  `benchmark/bundle`, against 3.5 ms for the same app doing no validation at all, so the
256
289
  compiled path costs nothing measurable to start. The runtime figure is what it is because
@@ -280,6 +313,15 @@ const result = v.validate(data)
280
313
 
281
314
  The runtime API is unchanged from previous releases. Code written against the default validator's class keeps working through `ata-validator/compat`, which covers `compile`, `addSchema`, `addFormat`, `addKeyword`, `errorsText` and the rest of that surface, and reports errors in the same shape and order. `npx ata migrate` reports what the switch would change in a project, and what it cannot translate, before anything is written. See [docs/migration-from-ajv.md](docs/migration-from-ajv.md).
282
315
 
316
+ Coming from the default validator, one command reports what the switch touches and a second applies the mechanical part:
317
+
318
+ ```bash
319
+ npx ata migrate # per file: imports to rewrite, addFormats() calls that go, sites to look at by hand
320
+ npx ata migrate --write # rewrites the imports to ata-validator/compat and removes the calls, leaves the rest
321
+ ```
322
+
323
+ Coercion, defaults, `allErrors` and the error objects behave as before; the details and the limits are in [docs/migration-from-ajv.md](docs/migration-from-ajv.md).
324
+
283
325
  ## Usage
284
326
 
285
327
  ### Node.js
@@ -684,7 +726,7 @@ Copy-paste recipes for the common frameworks. Most need 10-20 lines of glue. See
684
726
 
685
727
  ### Dialects
686
728
 
687
- `$schema` selects the dialect. Draft 2020-12 is the default, draft-07 is normalized on the way in, and `https://json-schema.org/v1` (or the dated `https://json-schema.org/v1/2026`) selects JSON Schema v1.
729
+ `$schema` selects the dialect. Draft 2020-12 is the default; draft-07, draft-06 and Draft 2019-09 are rewritten to the 2020-12 spelling on the way in (draft-06 as draft-07 without `if`/`then`/`else`, 2019-09's tuple `items` and `$recursiveRef` as `prefixItems` and `$dynamicRef`); and `https://json-schema.org/v1` (or the dated `https://json-schema.org/v1/2026`) selects JSON Schema v1. The official suite passes in full on all five: 1301 of 1301 on 2020-12, 1261 of 1261 on 2019-09, 929 of 929 on draft-07, 841 of 841 on draft-06 and 1135 of 1135 on v1, with code generation allowed and blocked. Draft-04 and earlier are not supported; draft-04's boolean `exclusiveMinimum`/`exclusiveMaximum` is read wherever it appears.
688
730
 
689
731
  Two things differ under v1. `propertyDependencies` selects a subschema by the value of a property rather than by its presence, which is what `dependentSchemas` does. And `$dynamicRef` no longer requires bookending: the reference resolves through the dynamic scope whether or not the schema it first lands on carries a matching `$dynamicAnchor`, so the outermost matching anchor still in scope wins. Everything else ata implements is identical under both dialects, so a schema that declares no `$schema` behaves exactly as before.
690
732
 
package/index.js CHANGED
@@ -814,22 +814,40 @@ function hasDefaultsInside(node, seen) {
814
814
  function emitDefaults(node, ov, lines, st) {
815
815
  if (st.seen.has(node)) { st.cycle = true; return; }
816
816
  st.seen.add(node);
817
+ // A default is written only where the key is not an own property, and the
818
+ // own-property test used to be Object.hasOwn for every default on every
819
+ // call: on a configuration schema of 82 defaults that was 820 ns a call
820
+ // for a document that already carried every key, against 12 ns with the
821
+ // pass off. For a plain object (prototype Object.prototype) a key the
822
+ // prototype does not have is own exactly when reading it gives a value, so
823
+ // the read, an inline-cached load, settles the common case and hasOwn is
824
+ // consulted only when the read gives undefined. Keys Object.prototype does
825
+ // have (constructor, toString, __proto__) keep the hasOwn test, since the
826
+ // read would find the inherited one; so does any object with another
827
+ // prototype, where a read may find an inherited value of the same name.
828
+ const pk = '_pk' + st.n++;
829
+ lines.push(`const ${pk}=Object.getPrototypeOf(${ov})===Object.prototype`);
817
830
  for (const [key, prop] of Object.entries(node.properties || {})) {
818
831
  if (!prop || typeof prop !== 'object') continue;
819
832
  const k = JSON.stringify(key);
833
+ const inherited = key in Object.prototype;
834
+ const absent = inherited ? `!Object.hasOwn(${ov},${k})` : `(${ov}[${k}]===undefined||!${pk})&&!Object.hasOwn(${ov},${k})`;
820
835
  if (prop.default !== undefined) {
821
836
  const def = JSON.stringify(prop.default);
822
837
  // Assignment to a key named __proto__ hits the prototype setter
823
838
  // instead of creating a property; defineProperty writes an own key.
824
839
  lines.push(key === '__proto__'
825
- ? `if(!Object.hasOwn(${ov},${k}))Object.defineProperty(${ov},${k},{value:${def},writable:true,enumerable:true,configurable:true})`
826
- : `if(!Object.hasOwn(${ov},${k}))${ov}[${k}]=${def}`);
840
+ ? `if(${absent})Object.defineProperty(${ov},${k},{value:${def},writable:true,enumerable:true,configurable:true})`
841
+ : `if(${absent})${ov}[${k}]=${def}`);
827
842
  }
828
843
  // Into an own property that holds an object, arrays included, as the
829
- // closure pass walks it.
844
+ // closure pass walks it. Object.prototype's own properties are functions
845
+ // apart from the __proto__ accessor, so on a plain object a read that
846
+ // gives an object is an own property for every other key.
830
847
  if (prop.properties && hasDefaultsInside(prop, new Set())) {
831
848
  const n = '_d' + st.n++;
832
- lines.push(`if(Object.hasOwn(${ov},${k})){const ${n}=${ov}[${k}];if(typeof ${n}==='object'&&${n}!==null){`);
849
+ const own = inherited ? `Object.hasOwn(${ov},${k})` : `(${pk}||Object.hasOwn(${ov},${k}))`;
850
+ lines.push(`{const ${n}=${ov}[${k}];if(typeof ${n}==='object'&&${n}!==null&&${own}){`);
833
851
  emitDefaults(prop, n, lines, st);
834
852
  lines.push('}}');
835
853
  }
package/lib/aot-impl.js CHANGED
@@ -113,6 +113,9 @@ function toStandalone(validator) {
113
113
  // Also capture error function source for zero-compile standalone load
114
114
  const jsErrFn = compileToJSCodegenWithErrors(
115
115
  typeof validator._schemaObj === 'object' ? validator._schemaObj : {},
116
+ null,
117
+ null,
118
+ { noOrdinal: true },
116
119
  );
117
120
  const errSrc = jsErrFn && jsErrFn._errSource ? jsErrFn._errSource : '';
118
121
 
@@ -388,11 +391,14 @@ function toStandaloneModule(validator, opts) {
388
391
  `const _ER = function(e) { _ataStamp(e); this.valid = false; this.errors = e; };\n` +
389
392
  `${codeTableSource(onePass.keywords, onePass.formats)}const _vC = (function() {\n${onePass.core}\n})();\n`;
390
393
  } else if (!abortEarly) {
394
+ // A module returns the collector's errors as they are and never sorts
395
+ // them, so the ordinal the runtime sorts by (`_o`) has no reader there;
396
+ // it leaked into every error object a module reported.
391
397
  jsErrFn = compileToJSCodegenWithErrors(
392
398
  typeof validator._schemaObj === 'object' ? validator._schemaObj : {},
393
399
  null,
394
400
  validator._userFormats,
395
- (source && sourceMap && schemaFile) ? { sourceMap, schemaFile, frames: sourceFrames } : null,
401
+ (source && sourceMap && schemaFile) ? { sourceMap, schemaFile, frames: sourceFrames, noOrdinal: true } : { noOrdinal: true },
396
402
  );
397
403
  const errSrc = jsErrFn && jsErrFn._errSource ? jsErrFn._errSource : '';
398
404
  if (errSrc && jsErrFn._errFactory) {
@@ -665,6 +671,7 @@ function bundleStandalone(Validator, schemas, opts) {
665
671
  v._schemaObj,
666
672
  v._schemaMap,
667
673
  v._userFormats,
674
+ { noOrdinal: true },
668
675
  );
669
676
  if (jsFn._usesSafeRe || (jsErrFn && jsErrFn._usesSafeRe)) bundleUsesSafeRe = true;
670
677
  return { v, jsFn, jsErrFn };
@@ -757,6 +764,7 @@ function bundleCompact(Validator, schemas, opts) {
757
764
  v._schemaObj,
758
765
  v._schemaMap,
759
766
  v._userFormats,
767
+ { noOrdinal: true },
760
768
  );
761
769
  if (jsFn._usesSafeRe || (jsErrFn && jsErrFn._usesSafeRe)) bundleUsesSafeRe = true;
762
770
  // Hoisted anyOf/oneOf branch helpers (e.g. `_af1_b0`) must travel with the
package/lib/draft7.js CHANGED
@@ -1,19 +1,36 @@
1
1
  'use strict'
2
2
 
3
+ // draft-06 is read through the same rules: it differs from draft-07 only in
4
+ // lacking `if`/`then`/`else` (and the content and readOnly annotations), so a
5
+ // draft-06 schema is a draft-07 schema with those three keywords inert. Before
6
+ // this, a draft-06 schema was read under 2020-12 and its tuple `items`,
7
+ // `additionalItems` and `dependencies` were not applied: 30 cases of the
8
+ // official draft-06 suite accepted documents they must reject.
3
9
  const DRAFT7_SCHEMAS = new Set([
4
10
  'http://json-schema.org/draft-07/schema#',
5
11
  'http://json-schema.org/draft-07/schema',
12
+ 'http://json-schema.org/draft-06/schema#',
13
+ 'http://json-schema.org/draft-06/schema',
14
+ ])
15
+ const DRAFT6_SCHEMAS = new Set([
16
+ 'http://json-schema.org/draft-06/schema#',
17
+ 'http://json-schema.org/draft-06/schema',
6
18
  ])
7
19
 
8
20
  function isDraft7(schema) {
9
- return !!(schema && schema.$schema && DRAFT7_SCHEMAS.has(schema.$schema))
21
+ return !!(schema && typeof schema.$schema === 'string' && DRAFT7_SCHEMAS.has(schema.$schema))
22
+ }
23
+
24
+ function isDraft6(schema) {
25
+ return !!(schema && typeof schema.$schema === 'string' && DRAFT6_SCHEMAS.has(schema.$schema))
10
26
  }
11
27
 
12
28
  // `force` applies the draft-07 rules to a document that declares no dialect
13
29
  // of its own, which is how a retrieved schema inherits the root's draft.
14
- function normalizeDraft7(schema, force) {
30
+ // `draft6` says the document, or the root it inherits from, is draft-06.
31
+ function normalizeDraft7(schema, force, draft6) {
15
32
  if (!force && !isDraft7(schema)) return schema
16
- _normalize(schema)
33
+ _normalize(schema, draft6 === true || isDraft6(schema))
17
34
  return schema
18
35
  }
19
36
 
@@ -21,9 +38,13 @@ function normalizeDraft7(schema, force) {
21
38
  // definitions are addressable by pointer, annotations are inert.
22
39
  const REF_SIBLINGS_KEPT = new Set(['$ref', '$defs', 'definitions', '$schema', '$comment', 'title', 'description', 'examples', 'default', 'readOnly', 'writeOnly'])
23
40
 
24
- function _normalize(schema) {
41
+ function _normalize(schema, d6) {
25
42
  if (typeof schema !== 'object' || schema === null) return
26
43
 
44
+ // draft-06 has no conditional: `if`, `then` and `else` are unknown keywords
45
+ // there, so they are dropped rather than applied.
46
+ if (d6) { delete schema.if; delete schema.then; delete schema.else }
47
+
27
48
  // In draft-07 an object with `$ref` is that reference and nothing else:
28
49
  // sibling keywords, `$id` included, are ignored. Dropping them here gives
29
50
  // every engine the same reading without each having to know the draft.
@@ -74,7 +95,7 @@ function _normalize(schema) {
74
95
  for (const key of objSubs) {
75
96
  if (schema[key] && typeof schema[key] === 'object') {
76
97
  for (const v of Object.values(schema[key])) {
77
- if (typeof v === 'object' && v !== null) _normalize(v)
98
+ if (typeof v === 'object' && v !== null) _normalize(v, d6)
78
99
  }
79
100
  }
80
101
  }
@@ -84,7 +105,7 @@ function _normalize(schema) {
84
105
  for (const key of arrSubs) {
85
106
  if (Array.isArray(schema[key])) {
86
107
  for (const s of schema[key]) {
87
- if (typeof s === 'object' && s !== null) _normalize(s)
108
+ if (typeof s === 'object' && s !== null) _normalize(s, d6)
88
109
  }
89
110
  }
90
111
  }
@@ -94,7 +115,7 @@ function _normalize(schema) {
94
115
  'additionalProperties', 'propertyNames']
95
116
  for (const key of singleSubs) {
96
117
  if (typeof schema[key] === 'object' && schema[key] !== null) {
97
- _normalize(schema[key])
118
+ _normalize(schema[key], d6)
98
119
  }
99
120
  }
100
121
  }
@@ -248,4 +269,81 @@ function _stripFormat(schema, seen) {
248
269
  }
249
270
  }
250
271
 
251
- module.exports = { isDraft7, normalizeDraft7, normalizeNullable, normalizeExclusiveBounds, stripFormatAssertions }
272
+ // Draft 2019-09 differs from 2020-12, which every engine here reads, in two
273
+ // places: the array form of `items` with `additionalItems` (2020-12 spells
274
+ // them `prefixItems` and `items`), and `$recursiveRef`/`$recursiveAnchor`,
275
+ // which 2020-12 generalised into `$dynamicRef`/`$dynamicAnchor`. Both are
276
+ // rewritten to the 2020-12 spelling. Before, a 2019-09 schema was read as
277
+ // 2020-12 as written, and 38 cases of the official 2019-09 suite accepted
278
+ // documents they must reject.
279
+ //
280
+ // The recursive pair maps one to one: a resource whose root says
281
+ // `$recursiveAnchor: true` gets a `$dynamicAnchor` of a reserved name, and
282
+ // `$recursiveRef: "#"` becomes a `$dynamicRef` to that name when its resource
283
+ // carries the anchor, and a plain `$ref: "#"` when it does not, which is what
284
+ // 2019-09 says it means then. The outward search for the outermost anchored
285
+ // resource is the same rule in both drafts.
286
+ const DRAFT2019_SCHEMAS = new Set([
287
+ 'https://json-schema.org/draft/2019-09/schema',
288
+ 'https://json-schema.org/draft/2019-09/schema#',
289
+ ])
290
+ const RECURSIVE_ANCHOR = '__ata_recursive_anchor'
291
+
292
+ function isDraft2019(schema) {
293
+ return !!(schema && typeof schema.$schema === 'string' && DRAFT2019_SCHEMAS.has(schema.$schema))
294
+ }
295
+
296
+ const SUB_OBJECT = ['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']
297
+ const SUB_ARRAY = ['allOf', 'anyOf', 'oneOf', 'prefixItems', 'items']
298
+ const SUB_SINGLE = ['items', 'additionalItems', 'contains', 'not', 'if', 'then', 'else', 'additionalProperties',
299
+ 'propertyNames', 'unevaluatedItems', 'unevaluatedProperties', 'contentSchema']
300
+
301
+ function normalizeDraft2019(schema, force) {
302
+ if (!force && !isDraft2019(schema)) return schema
303
+ _normalize2019(schema, schema, false)
304
+ return schema
305
+ }
306
+
307
+ function _normalize2019(node, resourceRoot, isRoot) {
308
+ if (typeof node !== 'object' || node === null || Array.isArray(node)) return
309
+ // A subschema with its own `$id` starts a new resource.
310
+ const root = !isRoot && typeof node.$id === 'string' && !node.$id.startsWith('#') ? node : resourceRoot
311
+ if (node.$recursiveAnchor === true && node === root) node.$dynamicAnchor = RECURSIVE_ANCHOR
312
+ delete node.$recursiveAnchor
313
+ if (typeof node.$recursiveRef === 'string') {
314
+ if (node.$recursiveRef === '#' && root.$dynamicAnchor === RECURSIVE_ANCHOR) node.$dynamicRef = '#' + RECURSIVE_ANCHOR
315
+ else if (node.$ref === undefined) node.$ref = node.$recursiveRef
316
+ delete node.$recursiveRef
317
+ }
318
+ // Recurse before the items rewrite, so tuple members are walked as members.
319
+ for (const key of SUB_OBJECT) {
320
+ const sub = node[key]
321
+ if (sub && typeof sub === 'object') for (const v of Object.values(sub)) _normalize2019(v, root, false)
322
+ }
323
+ for (const key of SUB_ARRAY) {
324
+ if (Array.isArray(node[key])) for (const v of node[key]) _normalize2019(v, root, false)
325
+ }
326
+ for (const key of SUB_SINGLE) {
327
+ const sub = node[key]
328
+ if (sub && typeof sub === 'object' && !Array.isArray(sub)) _normalize2019(sub, root, false)
329
+ }
330
+ if (Array.isArray(node.items)) {
331
+ node.prefixItems = node.items
332
+ if (node.additionalItems !== undefined) { node.items = node.additionalItems; delete node.additionalItems } else delete node.items
333
+ } else {
334
+ delete node.additionalItems
335
+ }
336
+ }
337
+
338
+ // The draft a `$schema` value names, for the normalizers: 2 draft-06,
339
+ // 1 draft-07, 3 2019-09, 0 anything else. Takes the string so the caller
340
+ // reads the property once.
341
+ function draftCode(uri) {
342
+ if (typeof uri !== 'string') return 0
343
+ if (DRAFT6_SCHEMAS.has(uri)) return 2
344
+ if (DRAFT7_SCHEMAS.has(uri)) return 1
345
+ if (DRAFT2019_SCHEMAS.has(uri)) return 3
346
+ return 0
347
+ }
348
+
349
+ module.exports = { draftCode, isDraft7, isDraft6, isDraft2019, normalizeDraft2019, normalizeDraft7, normalizeNullable, normalizeExclusiveBounds, stripFormatAssertions }
@@ -77,7 +77,19 @@ function reprValue (v) {
77
77
  if (t === 'number' || t === 'boolean') return String(v);
78
78
  if (Array.isArray(v)) return `[array, ${v.length} items]`;
79
79
  if (t === 'object') {
80
- if (jsonSizeWithin(v, 60) >= 0) {
80
+ // The keys first: every key costs at least five characters serialised
81
+ // (`"k":` and a one-character value), so past twelve keys the 60-character
82
+ // body is out of reach and the size walk is skipped. The count is what the
83
+ // fallback prints, so it is taken once either way; the walk and the
84
+ // serialisation used to run on every container an error named, and a
85
+ // `required` or `unevaluatedProperties` error names its container.
86
+ let n;
87
+ try {
88
+ n = Object.keys(v).length;
89
+ } catch {
90
+ return '[object, unserializable]';
91
+ }
92
+ if (n <= 12 && jsonSizeWithin(v, 60) >= 0) {
81
93
  try {
82
94
  const s = JSON.stringify(v);
83
95
  if (s !== undefined && s.length <= 60) return s;
@@ -85,12 +97,6 @@ function reprValue (v) {
85
97
  return '[object, unserializable]';
86
98
  }
87
99
  }
88
- let n;
89
- try {
90
- n = Object.keys(v).length;
91
- } catch {
92
- return '[object, unserializable]';
93
- }
94
100
  return `[object, ${n} ${n === 1 ? 'key' : 'keys'}]`;
95
101
  }
96
102
  return `[${t}]`;
@@ -131,11 +137,12 @@ function resolveAt (err, data) {
131
137
  */
132
138
  // Observation-first wording. `message` stays as it is for parity, so this is a
133
139
  // separate field a consumer opts into.
134
- function detailFor (err, out) {
140
+ function detailFor (err, out, at) {
135
141
  const p = err.params || {};
136
142
  switch (err.keyword) {
137
143
  case 'type':
138
- return `expected ${p.type}, found ${typeNameOf(out.received)}`;
144
+ // From the value when the caller resolved it; from the repr otherwise.
145
+ return `expected ${p.type}, found ${(at === undefined || at === MISSING ? null : typeNameOfValue(at)) || typeNameOf(out.received)}`;
139
146
  case 'required':
140
147
  return `missing required property "${p.missingProperty}"`;
141
148
  case 'additionalProperties':
@@ -157,6 +164,24 @@ function detailFor (err, out) {
157
164
  }
158
165
  }
159
166
 
167
+ // The same names from the value itself, for the callers that hold it: the
168
+ // repr is a string built to be read, and parsing it back cost a regex per
169
+ // type error. Agrees with typeNameOf on every value reprValue can produce:
170
+ // a number that is not finite prints as NaN or Infinity, which the repr
171
+ // parser calls a value, and so does this.
172
+ // An object with its own toJSON prints as whatever that returns, so it is
173
+ // left to the repr route (null here).
174
+ function typeNameOfValue (v) {
175
+ if (v === null) return 'null';
176
+ switch (typeof v) {
177
+ case 'boolean': return 'boolean';
178
+ case 'string': return 'string';
179
+ case 'number': return Number.isFinite(v) ? 'number' : 'value';
180
+ case 'object': return Array.isArray(v) ? 'array' : typeof v.toJSON === 'function' ? null : 'object';
181
+ default: return 'value';
182
+ }
183
+ }
184
+
160
185
  // Derived from the repr in `received`, which is already a JSON-ish string.
161
186
  function typeNameOf (received) {
162
187
  if (received === undefined) return 'nothing';
@@ -252,11 +277,25 @@ function enrich (rawErr, opts) {
252
277
  // renderer can surface the closest variant's diagnostics. They are enriched
253
278
  // like any other error: passed through raw, they carried whatever the engine
254
279
  // that answered wrote, the generator's internal ordering key included.
255
- if (rawErr.branchErrors) out.branchErrors = rawErr.branchErrors.map((b) => enrich(b, data !== undefined ? { data } : null));
280
+ // A branch error the rich combined function already built is kept:
281
+ // enriching it again recomputed every field to the same values, and a
282
+ // collapsed oneOf paid that for each branch error on top of its own
283
+ // enrichment. Such an error carries `rank`, which only this enrichment and
284
+ // makeRich write; a standalone module's error carries a docUrl but no
285
+ // rank and is enriched as before (fromCompiled wraps those).
286
+ if (rawErr.branchErrors) out.branchErrors = rawErr.branchErrors.map((b) => (b !== null && typeof b === 'object' && b.rank !== undefined && b.docUrl !== undefined && b._o === undefined) ? b : enrich(b, data !== undefined ? { data } : null));
256
287
 
288
+ // The frames come from the position map, which is a walk of the document.
289
+ // Given lazily (validateJSON's rejections), the walk waits for the first
290
+ // read of any error's `dataFrame` or `anchor`: a caller that reads the
291
+ // messages and paths never pays for it, and one that reads every frame
292
+ // pays once, as before. Given eagerly, the fields are set as before.
293
+ const named = (rawErr.params && (rawErr.params.additionalProperty || rawErr.params.unevaluatedProperty)) || null;
257
294
  if (positions && positions[path]) {
258
295
  const p = positions[path];
259
296
  out.dataFrame = { byteOffset: p.byteOffset, length: p.length, line: p.line, col: p.col, text: p.text };
297
+ } else if (!positions && opts && opts.lazyPositions) {
298
+ defineLazyFrames(out, path, named, opts.lazyPositions);
260
299
  }
261
300
 
262
301
  // Attach schema source frame when the validator was constructed with a
@@ -278,7 +317,7 @@ function enrich (rawErr, opts) {
278
317
  const sugg = suggestFor(out, data, at === MISSING ? undefined : at);
279
318
  if (sugg) out.suggestion = sugg;
280
319
 
281
- const detail = detailFor(rawErr, out);
320
+ const detail = detailFor(rawErr, out, at);
282
321
  if (detail !== undefined) out.detail = detail;
283
322
  out.rank = st.rank;
284
323
 
@@ -286,7 +325,6 @@ function enrich (rawErr, opts) {
286
325
  // adds the key span so a caret can sit on the property that is wrong rather
287
326
  // than on the object containing it.
288
327
  if (opts && opts.positions) {
289
- const named = (rawErr.params && (rawErr.params.additionalProperty || rawErr.params.unevaluatedProperty)) || null;
290
328
  const own = opts.positions[path];
291
329
  const child = named ? opts.positions[(path === '' ? '' : path) + '/' + named] : null;
292
330
  const src = child || own;
@@ -303,4 +341,42 @@ function enrich (rawErr, opts) {
303
341
  return out;
304
342
  }
305
343
 
344
+ // `dataFrame` and `anchor` as accessors that resolve the position map on the
345
+ // first read of either, on any error of the list (the map is shared through
346
+ // `lazy`), and then become plain data properties, or go away when the map has
347
+ // no entry for the error, which is the shape the eager path gives. Both are
348
+ // enumerable, so JSON.stringify and a deep comparison read them as values;
349
+ // a write replaces the accessor the way an assignment to a plain field would.
350
+ function defineLazyFrames (out, path, named, lazy) {
351
+ let done = false;
352
+ let frames = null;
353
+ const resolve = () => {
354
+ if (done) return frames;
355
+ done = true;
356
+ const positions = lazy();
357
+ const own = positions ? positions[path] : null;
358
+ const dataFrame = own ? { byteOffset: own.byteOffset, length: own.length, line: own.line, col: own.col, text: own.text } : undefined;
359
+ const child = positions && named ? positions[(path === '' ? '' : path) + '/' + named] : null;
360
+ const src = child || own;
361
+ let anchor;
362
+ if (src) {
363
+ anchor = { line: src.line, col: src.col, length: src.length };
364
+ if (src.keyOffset !== undefined) {
365
+ anchor.keyLine = src.keyLine;
366
+ anchor.keyCol = src.keyCol;
367
+ anchor.keyLength = src.keyLength;
368
+ }
369
+ }
370
+ frames = { dataFrame, anchor };
371
+ if (dataFrame !== undefined) Object.defineProperty(out, 'dataFrame', { value: dataFrame, enumerable: true, configurable: true, writable: true });
372
+ else delete out.dataFrame;
373
+ if (anchor !== undefined) Object.defineProperty(out, 'anchor', { value: anchor, enumerable: true, configurable: true, writable: true });
374
+ else delete out.anchor;
375
+ return frames;
376
+ };
377
+ const settable = (name) => (v) => Object.defineProperty(out, name, { value: v, enumerable: true, configurable: true, writable: true });
378
+ Object.defineProperty(out, 'dataFrame', { get () { return resolve().dataFrame; }, set: settable('dataFrame'), enumerable: true, configurable: true });
379
+ Object.defineProperty(out, 'anchor', { get () { return resolve().anchor; }, set: settable('anchor'), enumerable: true, configurable: true });
380
+ }
381
+
306
382
  module.exports = { enrich, reprValue, expectedFor, detailFor, rankFor, MISSING, DOC_BASE };
@@ -49,7 +49,7 @@ function makeRich (site, path, params, message, value, root, spPrefix) {
49
49
  };
50
50
  const sugg = suggestFor(out, root, at === MISSING ? undefined : at);
51
51
  if (sugg) out.suggestion = sugg;
52
- const detail = detailFor({ keyword: site.keyword, params }, out);
52
+ const detail = detailFor({ keyword: site.keyword, params }, out, at);
53
53
  if (detail !== undefined) out.detail = detail;
54
54
  out.rank = site.rank;
55
55
  return out;