ata-validator 1.22.0 → 1.23.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/CHANGELOG.md +34 -0
- package/README.md +90 -8
- package/compat.d.ts +5 -0
- package/compat.js +23 -3
- package/index.d.ts +31 -0
- package/index.js +184 -77
- package/lib/compat-errors.js +49 -22
- package/lib/data-positions.js +10 -0
- package/lib/enrich-error.js +6 -0
- package/lib/interpreter.js +20 -1
- package/lib/js-compiler.js +56 -5
- package/lib/plan-compiler.js +38 -0
- package/lib/strict-check.js +142 -0
- package/lib/version.js +1 -1
- package/package.json +10 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to ata-validator are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/), and this project adheres to semantic versioning.
|
|
4
4
|
|
|
5
|
+
## 1.23.0 - 2026-09-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `strictSchema: true | 'log'`, and the compat shim's `strict`/`strictSchema` now enforce it: the authoring-time checks for the mistakes that fail open. An unknown keyword throws at construction with its path and the nearest known spelling, `strict mode: unknown keyword "maxLenght" (did you mean "maxLength"?) at #/properties/a/maxLenght`, and a local `$ref` that does not resolve in its own document throws instead of compiling a validator that rejects everything. `'log'` warns through the logger and continues. Off by default. The known-keyword list is derived from the vendored meta-schema documents rather than maintained by hand, plus the keywords ata implements beyond them; `x-` prefixed names and keywords registered through the `keywords` option pass. Property names, `enum` and `const` values, `default` and `examples` are data, not keywords, and the walk knows the difference, including the draft-07 `dependencies` form. `strictTypes`, `strictTuples` and `strictRequired` remain accepted and ignored, and `compat.d.ts` now says which is which. Reported as issue #44 by a migration that fuzzed 1,364 mutations against both validators, found zero divergence, and then shipped a typo.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- The error and combined generators compile on the first rejection instead of at startup. Every schema compiled three functions eagerly: the verdict, the combined validate-and-collect form, and the error generator, which on a cold first call cost 8.2, 10.4 and 7.8 ms on a 120-property config schema, most of it V8 compiling each generator's own code the first time it is entered. A caller that never reads an error never needs the last two, and `validate()` on a passing document does not either, so they are built on the first rejection. Compile plus first verdict on that schema: 30.7 to 10.8 ms, interleaved runs of committed builds. The warm path measured the same on all ten cases checked. The shared compile cache keeps `undefined` meaning not built yet and `null` meaning the compiler declined, in both places that seed it, and `tests/test_compile_cache_order.js` caught the one seam where the two seeders disagreed during this change.
|
|
14
|
+
- The static `unevaluatedProperties` tier stopped checking membership with a first-character switch. Keys sharing a first character, `ENV_0` through `ENV_999` say, all land in one case whose body chains every name, per key of the document: the `additionalProperties` quadratic in a different coat. Above 128 declared names the generated code reads the hoisted lookup that fixed the first one; below that the switch stays. A 1300-property config document with both of its objects closed: 87.7 to 24.5 µs, and the keyword now costs what `additionalProperties` costs, which is the membership test itself. Only the boolean generator has this code; the error and combined generators decline `unevaluated*` and the interpreted engine produces those errors.
|
|
15
|
+
- The compat shim derives each error's pointer segments and kinds once, into a key map shared by the reference-order sort and the shaping loop. The sort comparator used to re-split and re-classify both errors' pointers on every one of its comparisons, and the loop derived the same segments again per error, with the `if` wrapper's flush doing it a third time. A seven-error rejection through `ata-validator/compat` with `allErrors`: 5.79 to 3.27 µs. The shim's own overhead over `Validator.validate` fell from 4.4 µs to 1.9. The compat surface is pinned by `test_compat`, 106 error-format cases, 36 parity cases and the error-shape differential, all green before and after.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- The API table said `verbose` attaches only `parentSchema`; it attaches `parentSchema`, `schema` and `data`, and the migration guide now lists which `params` key each keyword carries. The performance notes recorded "length-bucketed key comparison for `additionalProperties: false`" as measured and dropped on the grounds that each comparison is a pointer compare; that was right about one comparison and wrong about the total, and the note now carries the measurements that replaced it.
|
|
20
|
+
|
|
21
|
+
## 1.22.1 - 2026-09-16
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- `validateJSON()` did not return on a document that ends inside an array. `validateJSON('[')` was one byte, any schema, the default configuration, and it never came back. On a rejection the error carries a caret, so a map from pointer to position is built from the raw text; its array loop asked for the next element without checking that the input had ended, `walk` returned without moving, and nothing else could close the array. `'[}'` hung the same way, because a character that begins no value leaves the position where it was. The object loop escaped only by accident: an unterminated string throws, and `lib/data-position-cache.js` catches throws. It cannot catch a loop. Both container loops now stop at the end of the input, and the array loop stops when an iteration does not advance, so a truncated document still yields a map of what was there.
|
|
26
|
+
- A regression here is an infinite loop, which an ordinary test cannot report because it never reaches a failure. `tests/test_malformed_json_termination.js` runs its corpus in a child process under a watchdog, so a hang becomes a timeout and a red test. The corpus is every prefix of ten documents plus characters inserted where they cannot begin a value: 1793 inputs, 7172 verdicts. It was confirmed to fail before this change and pass after.
|
|
27
|
+
- `tests/fuzz_positions.js` had never fed malformed text to this walker; it fuzzes schema positions with `JSON.stringify` output, which is always well formed. Nothing else changes: on the same five-field schema, `validate()` on a valid document, `validate()` on an invalid one with the errors read, and `validateJSON()` on both are all within measurement noise of 1.22.0.
|
|
28
|
+
|
|
29
|
+
- `unevaluatedProperties: false` reported the boolean schema underneath the keyword instead of the keyword itself: `keyword: 'not'`, empty `params`, and an `instancePath` pointing at the offending member rather than the object holding it. Nothing in that error said which key was unexpected, so a caller branching on `params.unevaluatedProperty` to flag unknown keys saw nothing at all. It now reports `unevaluatedProperties` on the object, with `params.unevaluatedProperty` naming the key, one error per key. `unevaluatedItems: false` had the same shape and now reports one error for the array, with `params.limit` at the first unevaluated position. Both engines were changed together and `tests/test_unevaluated_error_shape.js` holds them to it. Reported by a config linter reading `err.params.unevaluatedProperty` through `ata-validator/compat`.
|
|
30
|
+
- `verbose: true` attached only `parentSchema`. It now also attaches `data`, the value the error points at, and `schema`, the failing keyword's own value, which is the whole set the option is supposed to carry. Without `data` a caller had to walk the document by the instance path itself. The default error shape is unchanged: the two fields appear only when `verbose` is on, including on the errors `ata-validator/compat` synthesises for an `if` wrapper.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- `additionalProperties: false` no longer compiles to a comparison per declared name. It was one comparison per name per key of the document, which is quadratic in the number of properties: a 1000-property schema spent 75 µs on a document where the same schema without the keyword took 3, and doubling the properties quadrupled that. Above 128 declared names the generated code now reads a name lookup built once per compiled function; below that the comparison chain stays, because it allocates nothing and the two are the same within noise there.
|
|
35
|
+
- Cross-process medians, one variant per process, properties of type string: 250 names 8.76 µs to 5.49, 500 names 26.29 to 12.47. With no per-property work, so the membership test is all that is measured: 1000 names 77.3 µs to 18.1, 2000 names 273.6 to 38.9.
|
|
36
|
+
- Measured and not taken: `for...in` over the document instead of an indexed loop over `Object.keys` was about a quarter slower at every size, even though it allocates no key array.
|
|
37
|
+
- `tests/test_additional_properties_scaling.js` guards this structurally, on the emitted source: the lookup is present above the threshold, absent below it, and declared outside the function that runs per call. It asserts no timing, because a wall-clock ratio was not a guard here: the quadratic shape measured 7.7x for four times the properties on the machine the test was written on, where a CI runner measured 8.3x for the fixed code. The comparison chain short-circuits as soon as a key matches, so the ratio never separated the two. Found by a config linter whose schema is generated from a project's environment variables, so its property count is whatever the project has.
|
|
38
|
+
|
|
5
39
|
## 1.22.0 - 2026-09-14
|
|
6
40
|
|
|
7
41
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ata-validator
|
|
2
2
|
|
|
3
|
-
JSON Schema validation
|
|
3
|
+
JSON Schema validation that compiles for speed and still runs where code generation is blocked. The compiled and interpreted engines answer identically, at 100% of the official suite in both modes, across Draft 2020-12, draft 7 and the [JSON Schema v1 dialect](#dialects). First-class TypeScript inference, and `ata build` emits a standalone module that imports nothing.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/ata-validator)
|
|
6
6
|
[](LICENSE)
|
|
@@ -97,6 +97,55 @@ run on the interpreted engine. That engine compiles each schema into a tree of c
|
|
|
97
97
|
on a six-field object schema a passing payload costs about 136 ns against 8 ns for the same
|
|
98
98
|
shape on the compiled path, both measured warm in one run on the same machine.
|
|
99
99
|
|
|
100
|
+
## Schemas and models
|
|
101
|
+
|
|
102
|
+
If you ask a model for structured output and validate the result, the schema is
|
|
103
|
+
part of the prompt and the validation error is part of the retry. Two functions
|
|
104
|
+
produce those strings.
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
import { describeSchema, toRetryMessage, Validator } from 'ata-validator'
|
|
108
|
+
|
|
109
|
+
describeSchema(schema)
|
|
110
|
+
// output: object
|
|
111
|
+
// status: one of "AWAITING_CLEARANCE", "PART_SETTLED", "CLOSED_OUT"
|
|
112
|
+
// region_code: one of "R11", "R24", "R37", "R52"
|
|
113
|
+
// amount: integer, at least 1
|
|
114
|
+
// no other fields
|
|
115
|
+
|
|
116
|
+
const r = new Validator(schema).validate(fromTheModel)
|
|
117
|
+
if (!r.valid) toRetryMessage(r.errors)
|
|
118
|
+
// /status: expected one of ["AWAITING_CLEARANCE", "PART_SETTLED", "CLOSED_OUT"], found "partial"
|
|
119
|
+
// /region_code: expected one of ["R11", "R24", "R37", "R52"], found "HH"
|
|
120
|
+
// /amount: expected ≥1, found 0
|
|
121
|
+
// /: unknown property "extra"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Whether this is worth anything depends on the schema, and the measurement that
|
|
125
|
+
says so is
|
|
126
|
+
[public](https://github.com/mertcanaltin/retry-message-experiment), with the
|
|
127
|
+
harness and the raw results.
|
|
128
|
+
|
|
129
|
+
On constraints a model can work out from the document, a pattern, a date format,
|
|
130
|
+
a currency in uppercase, it changes nothing: 40 of 40 documents recovered on one
|
|
131
|
+
retry with the conventional error text, and 40 of 40 with the detailed one.
|
|
132
|
+
|
|
133
|
+
On constraints it cannot work out, an `enum` of internal codes while the
|
|
134
|
+
document says "paid in part" and "Hamburg office", the conventional text
|
|
135
|
+
recovered 0 of 30 and the detailed text 30 of 30. The model does not give up
|
|
136
|
+
when it is not told the values, it invents plausible ones, so no number of
|
|
137
|
+
retries closes it.
|
|
138
|
+
|
|
139
|
+
Stating the constraints up front does the same job earlier. Across four prompts
|
|
140
|
+
on the same fixtures, first attempts that validate: nothing 0 of 30, a lean
|
|
141
|
+
field list 0 of 30, a careful hand-written description 30 of 30 on the inferable
|
|
142
|
+
fixture and 0 of 30 on the opaque one, and `describeSchema` 30 and 23. A person
|
|
143
|
+
writing prose matches the generated description wherever prose works. It is the
|
|
144
|
+
enumerated values a person leaves out.
|
|
145
|
+
|
|
146
|
+
One model, Claude Haiku 4.5, one schema shape, 30 to 40 documents per arm, no
|
|
147
|
+
temperature control. Read it as a finding, not a law.
|
|
148
|
+
|
|
100
149
|
## Error messages
|
|
101
150
|
|
|
102
151
|
ata's error output is compiler-grade: each error carries a stable code, an inline source frame pointing at the schema file, and another pointing at the offending bytes in the request payload. Renderers ship in three styles:
|
|
@@ -153,6 +202,33 @@ Schemas without an `errorMessage` keyword pay nothing: the override pass is only
|
|
|
153
202
|
|
|
154
203
|
For consumers who built log dashboards on the v0.14 error shape, `new Validator(schema, { richErrors: false })` returns the legacy shape exactly. For high-throughput paths, `abortEarly: true` continues to short-circuit; the returned error carries `code: 'ATA9000'` and no enrichment.
|
|
155
204
|
|
|
205
|
+
## Which path, and where
|
|
206
|
+
|
|
207
|
+
There are two ways to run ata and they suit different places. Measuring the wrong one
|
|
208
|
+
is the most common way to get a misleading number out of this library.
|
|
209
|
+
|
|
210
|
+
| | compiled with `ata build` | runtime `new Validator(schema)` |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| In a bundle, gzipped | **1.9 KB** | 75.6 KB |
|
|
213
|
+
| Time to a served request | **3.5 ms** | 10.7 ms |
|
|
214
|
+
| Schema known when | build time | any time |
|
|
215
|
+
|
|
216
|
+
The bundle row is a ten-field user schema built with
|
|
217
|
+
`bun build --minify --target=browser`. The startup row is a Hono route on Bun 1.4, best
|
|
218
|
+
of seven, against 3.6 ms for the same app doing no validation at all, so the compiled
|
|
219
|
+
path costs nothing measurable to start. The runtime
|
|
220
|
+
figure is what it is because a schema that arrives at run time can use any keyword, so
|
|
221
|
+
the whole engine has to be there. The compiled module imports nothing and contains only
|
|
222
|
+
the checks your schema asks for.
|
|
223
|
+
|
|
224
|
+
**On a server, use whichever fits your schemas.** 76 KB of JavaScript on a Node or Bun
|
|
225
|
+
process is not a cost anyone notices, and the runtime API is the simpler thing to reach
|
|
226
|
+
for. Speed is the same either way once warm.
|
|
227
|
+
|
|
228
|
+
**In a browser, on an edge runtime, or anywhere cold starts are charged, compile.** This
|
|
229
|
+
is where the difference is the whole story, and it is also where `new Function` is often
|
|
230
|
+
blocked outright, which the compiled module does not need.
|
|
231
|
+
|
|
156
232
|
## When to use the runtime API instead
|
|
157
233
|
|
|
158
234
|
`ata build` is for schemas you know at build time. If your schemas are user-supplied at runtime (form builders, no-code platforms, dynamic API ingestion), use the runtime API:
|
|
@@ -374,7 +450,7 @@ const v = new Validator(schema, {
|
|
|
374
450
|
|
|
375
451
|
### Build-time compile (`ata compile`)
|
|
376
452
|
|
|
377
|
-
The `ata` CLI turns a JSON Schema file into a self-contained JavaScript module. No runtime dependency on `ata-validator`, so only the generated validator ships to the browser. Typical output is
|
|
453
|
+
The `ata` CLI turns a JSON Schema file into a self-contained JavaScript module. No runtime dependency on `ata-validator`, so only the generated validator ships to the browser. Typical output is about 2 KB gzipped for a ten-field schema, against 74 KB for the runtime bundled for the browser.
|
|
378
454
|
|
|
379
455
|
```bash
|
|
380
456
|
npx ata compile schemas/user.json -o src/generated/user.validator.mjs
|
|
@@ -404,7 +480,7 @@ CLI options:
|
|
|
404
480
|
| `-o, --output <file>` | `<schema>.validator.mjs` | Output path |
|
|
405
481
|
| `-f, --format <fmt>` | `esm` | `esm` or `cjs` |
|
|
406
482
|
| `--name <TypeName>` | from filename | Root type name in the `.d.ts` |
|
|
407
|
-
| `--abort-early` | off | Generate the stub-error variant
|
|
483
|
+
| `--abort-early` | off | Generate the stub-error variant, about a third of the source |
|
|
408
484
|
| `--no-types` | off | Skip the `.d.mts` / `.d.cts` output |
|
|
409
485
|
|
|
410
486
|
For a project with many schemas, `ata build <glob>` compiles them all in one command:
|
|
@@ -415,13 +491,19 @@ npx ata build 'schemas/*.json' --out-dir build/validators --check
|
|
|
415
491
|
|
|
416
492
|
Run with `--watch` during development for incremental rebuilds.
|
|
417
493
|
|
|
418
|
-
|
|
494
|
+
Bundle sizes for a 10-field user schema, minified and gzipped, measured with
|
|
495
|
+
`bun build --minify --target=browser`:
|
|
419
496
|
|
|
420
|
-
|
|
|
497
|
+
| What the app imports | Size | Notes |
|
|
421
498
|
|---|---|---|
|
|
422
|
-
| `ata-validator`
|
|
423
|
-
| `
|
|
424
|
-
| `
|
|
499
|
+
| `Validator` from `ata-validator` | 74.1 KB | The compiler ships with it, because a runtime schema can use any keyword |
|
|
500
|
+
| `isValid` from the compiled module | **1.9 KB** | Nothing else is reachable, so the error collector is dropped |
|
|
501
|
+
| `validate` from the compiled module | **3.6 KB** | Adds the detailed error collector |
|
|
502
|
+
|
|
503
|
+
`--abort-early` makes the generated source about three times smaller, and after
|
|
504
|
+
bundling it makes no difference: importing only `isValid` already leaves the error
|
|
505
|
+
collector unreachable, and a bundler drops it. Use the flag to cut the file on disk,
|
|
506
|
+
not to cut what ships.
|
|
425
507
|
|
|
426
508
|
Programmatic API if you prefer to script it:
|
|
427
509
|
|
package/compat.d.ts
CHANGED
|
@@ -78,10 +78,15 @@ declare namespace Ata {
|
|
|
78
78
|
formats?: Record<string, Format>;
|
|
79
79
|
keywords?: KeywordDefinition[];
|
|
80
80
|
schemas?: object[] | Record<string, object>;
|
|
81
|
+
/** Enforced: unknown keywords and dangling local $refs throw at compile ('log' warns instead). */
|
|
81
82
|
strict?: boolean | 'log';
|
|
83
|
+
/** Same checks as `strict`; the specific option wins over the umbrella one. */
|
|
82
84
|
strictSchema?: boolean | 'log';
|
|
85
|
+
/** Accepted for API compatibility; not enforced. */
|
|
83
86
|
strictTypes?: boolean | 'log';
|
|
87
|
+
/** Accepted for API compatibility; not enforced. */
|
|
84
88
|
strictTuples?: boolean | 'log';
|
|
89
|
+
/** Accepted for API compatibility; not enforced. */
|
|
85
90
|
strictRequired?: boolean | 'log';
|
|
86
91
|
allowUnionTypes?: boolean;
|
|
87
92
|
logger?: { log(...args: unknown[]): void; warn(...args: unknown[]): void; error(...args: unknown[]): void } | false;
|
package/compat.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { resolvePointer } = require('./lib/pointer.js');
|
|
4
|
+
|
|
3
5
|
// Drop-in for the default validator's class. `const Ajv = require('ata-validator/compat')`
|
|
4
6
|
// and the rest of the file stays as it was: `new Ajv(opts)`, `compile`,
|
|
5
7
|
// `validate`, `addSchema`, `getSchema`, `removeSchema`, `addFormat`,
|
|
@@ -13,8 +15,10 @@
|
|
|
13
15
|
// - `$data` references;
|
|
14
16
|
// - keywords defined only through `code` (a code generator hook);
|
|
15
17
|
// - the reference formats plugin, whose formats are built in here.
|
|
16
|
-
// Strict-mode schema checks
|
|
17
|
-
//
|
|
18
|
+
// Strict-mode schema checks: `strict` and `strictSchema` are enforced for the
|
|
19
|
+
// checks that fail open, an unknown keyword (`maxLenght` compiles and the
|
|
20
|
+
// constraint is simply absent) and a dangling local `$ref`. `strictTypes`,
|
|
21
|
+
// `strictTuples` and `strictRequired` are accepted and ignored.
|
|
18
22
|
|
|
19
23
|
const { Validator } = require('./index');
|
|
20
24
|
const { METASCHEMAS } = require('./lib/metaschemas');
|
|
@@ -97,6 +101,13 @@ class Ata {
|
|
|
97
101
|
removeAdditional: !!o.removeAdditional,
|
|
98
102
|
verbose: !!o.verbose,
|
|
99
103
|
};
|
|
104
|
+
// `strict` and `strictSchema`: the unknown-keyword and dangling-local-$ref
|
|
105
|
+
// checks are enforced; `strictTypes`, `strictTuples` and `strictRequired`
|
|
106
|
+
// are still accepted and ignored. The specific keyword option wins over
|
|
107
|
+
// the umbrella one, which is how the reference class reads them.
|
|
108
|
+
const strictness = o.strictSchema !== undefined ? o.strictSchema : o.strict;
|
|
109
|
+
if (strictness === true || strictness === 'log') out.strictSchema = strictness;
|
|
110
|
+
if (o.logger !== undefined) out.logger = o.logger;
|
|
100
111
|
if (o.validateFormats === false) out.assertFormat = false;
|
|
101
112
|
const formatNames = Object.keys(this._formats);
|
|
102
113
|
if (formatNames.length > 0) out.formats = { ...this._formats };
|
|
@@ -174,7 +185,16 @@ class Ata {
|
|
|
174
185
|
validate.errors = null;
|
|
175
186
|
return true;
|
|
176
187
|
}
|
|
177
|
-
|
|
188
|
+
let errors = shaper === null ? result.errors : shaper(result.errors, data);
|
|
189
|
+
// Errors the shaper synthesises, the `if` wrapper among them, are built
|
|
190
|
+
// from the group rather than carried up from the validator, so verbose
|
|
191
|
+
// mode has to fill in the value they point at here or they would be the
|
|
192
|
+
// only ones missing it.
|
|
193
|
+
if (this.opts.verbose) {
|
|
194
|
+
errors = errors.map((e) => (e && !('data' in e)
|
|
195
|
+
? { ...e, data: resolvePointer(data, e.instancePath || '', undefined) }
|
|
196
|
+
: e));
|
|
197
|
+
}
|
|
178
198
|
validate.errors = shaper === null && !allErrors ? errors.slice(0, 1) : errors;
|
|
179
199
|
return false;
|
|
180
200
|
};
|
package/index.d.ts
CHANGED
|
@@ -72,6 +72,37 @@ export function renderPretty(errors: RichValidationError[], opts?: PrettyOptions
|
|
|
72
72
|
export function renderCompact(errors: RichValidationError[], opts?: CompactOptions): string;
|
|
73
73
|
export function renderJSON(errors: RichValidationError[], opts?: JSONRenderOptions): string;
|
|
74
74
|
|
|
75
|
+
/** Options for {@link describeSchema}. */
|
|
76
|
+
export interface DescribeSchemaOptions {
|
|
77
|
+
/** What to call the top level in the description. Defaults to `output`. */
|
|
78
|
+
name?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The schema's constraints as prose, for the prompt that asks a model to
|
|
83
|
+
* produce a document. Values a model cannot infer, an `enum` of internal codes
|
|
84
|
+
* most of all, are the ones worth stating: measured on one fixture, naming them
|
|
85
|
+
* took first-attempt validity from 0 of 30 to 23 of 30, where a careful
|
|
86
|
+
* hand-written description scored 0 on exactly those fields.
|
|
87
|
+
* https://github.com/mertcanaltin/retry-message-experiment
|
|
88
|
+
*/
|
|
89
|
+
export function describeSchema(schema: object, opts?: DescribeSchemaOptions): string;
|
|
90
|
+
|
|
91
|
+
/** Options for {@link toRetryMessage}. */
|
|
92
|
+
export interface RetryMessageOptions {
|
|
93
|
+
/** Most errors to include. Defaults to 20, because a model does not act on a long list. */
|
|
94
|
+
limit?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The errors as a string to send back to a model, naming what was expected and
|
|
99
|
+
* what arrived. On constraints a model can infer from context this changes
|
|
100
|
+
* nothing; where a constraint is an internal vocabulary it was the difference
|
|
101
|
+
* between 0 of 30 and 30 of 30 recovered on one retry.
|
|
102
|
+
* https://github.com/mertcanaltin/retry-message-experiment
|
|
103
|
+
*/
|
|
104
|
+
export function toRetryMessage(errors: RichValidationError[], opts?: RetryMessageOptions): string;
|
|
105
|
+
|
|
75
106
|
/** A user-supplied format checker. Receives the candidate value, returns true if valid. */
|
|
76
107
|
export type FormatChecker = (value: string) => boolean;
|
|
77
108
|
|
package/index.js
CHANGED
|
@@ -885,6 +885,29 @@ class Validator {
|
|
|
885
885
|
// produced the error). Matches ajv's `verbose: true` behavior.
|
|
886
886
|
this._verbose = !!options.verbose;
|
|
887
887
|
|
|
888
|
+
// strictSchema: authoring-time checks, off by default. A mistyped keyword
|
|
889
|
+
// is the one schema mistake that fails open: to every dialect `maxLenght`
|
|
890
|
+
// is an annotation, so the constraint the author meant is simply absent
|
|
891
|
+
// and previously invalid data validates. `true` throws here, at
|
|
892
|
+
// construction, with every finding; `'log'` reports through
|
|
893
|
+
// `options.logger` or the console and continues. The check runs on the
|
|
894
|
+
// schema as written, before any normalization touches it.
|
|
895
|
+
if (options.strictSchema === true || options.strictSchema === 'log') {
|
|
896
|
+
const { checkSchemaStrict } = require('./lib/strict-check');
|
|
897
|
+
const problems = checkSchemaStrict(schema, { userKeywords: options.keywords || null });
|
|
898
|
+
if (problems.length > 0) {
|
|
899
|
+
const text = problems.map((x) => `strict mode: ${x.message} at ${x.path}`).join('\n');
|
|
900
|
+
if (options.strictSchema === true) {
|
|
901
|
+
throw new Error(text);
|
|
902
|
+
}
|
|
903
|
+
const logger = options.logger;
|
|
904
|
+
if (logger !== false) {
|
|
905
|
+
const warn = logger && typeof logger.warn === 'function' ? logger.warn.bind(logger) : console.warn;
|
|
906
|
+
warn(text);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
888
911
|
// richErrors: default true. Only the literal `false` opts back into the
|
|
889
912
|
// v0.14 error shape (no code/expected/received/docUrl, no aliases).
|
|
890
913
|
this._richErrors = options && options.richErrors === false ? false : true;
|
|
@@ -1014,27 +1037,40 @@ class Validator {
|
|
|
1014
1037
|
// because nothing has tried to build them yet. Both halves of that
|
|
1015
1038
|
// distinction are null, and reading the second as the first costs this
|
|
1016
1039
|
// schema its generated error function for the life of the process.
|
|
1017
|
-
} else if (cached && cached.
|
|
1040
|
+
} else if (cached && cached.jsFn !== undefined && !_forceNapi) {
|
|
1041
|
+
// `full` says the error and combined functions exist too. An entry
|
|
1042
|
+
// without it still carries a verdict function worth reusing; the pair is
|
|
1043
|
+
// built by _buildDeferred below if something asks for an error, and the
|
|
1044
|
+
// entry is upgraded then. `undefined` in `combined`/`errFn` means not
|
|
1045
|
+
// built yet; `null` means the compiler declined. Those two must never
|
|
1046
|
+
// blur: reading the first as the second is the bug this cache had once
|
|
1047
|
+
// already, and it silently cost schemas their generated error function.
|
|
1018
1048
|
jsFn = cached.jsFn;
|
|
1019
1049
|
jsCombinedFn = cached.combined;
|
|
1020
1050
|
jsErrFn = cached.errFn;
|
|
1021
1051
|
_isCodegen = !!cached.isCodegen;
|
|
1052
|
+
this._engine = _isCodegen ? 'codegen' : jsFn ? 'closure' : null;
|
|
1022
1053
|
} else if (!_forceNapi) {
|
|
1023
1054
|
const uf = this._userFormats;
|
|
1024
1055
|
const _cgFn = compileToJSCodegen(schemaObj, sm, uf);
|
|
1025
1056
|
jsFn = _cgFn || compileToJS(schemaObj, null, sm);
|
|
1026
|
-
|
|
1027
|
-
|
|
1057
|
+
// Only the verdict is compiled here. The error and combined generators
|
|
1058
|
+
// are the other two thirds of a cold first call (8.2, 10.4 and 7.8 ms on
|
|
1059
|
+
// a 120-property config schema, most of it V8 compiling each generator
|
|
1060
|
+
// the first time it is entered), and a caller that never reads an error
|
|
1061
|
+
// never needs them. _buildDeferred compiles them on the first rejection.
|
|
1062
|
+
jsCombinedFn = undefined;
|
|
1063
|
+
jsErrFn = undefined;
|
|
1028
1064
|
_isCodegen = !!_cgFn;
|
|
1029
1065
|
this._engine = _cgFn ? 'codegen' : jsFn ? 'closure' : null;
|
|
1030
1066
|
if (!uf) {
|
|
1031
|
-
_compileCache.set(mapKey, { jsFn, combined:
|
|
1067
|
+
_compileCache.set(mapKey, { jsFn, combined: undefined, errFn: undefined, isCodegen: _isCodegen, full: false });
|
|
1032
1068
|
}
|
|
1033
1069
|
} else {
|
|
1034
1070
|
jsFn = null; jsCombinedFn = null; jsErrFn = null;
|
|
1035
1071
|
}
|
|
1036
1072
|
this._jsFn = jsFn;
|
|
1037
|
-
if (this._engine === undefined) this._engine =
|
|
1073
|
+
if (this._engine === undefined) this._engine = null;
|
|
1038
1074
|
|
|
1039
1075
|
// Data mutators -- try codegen first (12x faster), fallback to closure arrays.
|
|
1040
1076
|
// Follow cross-refs so coercion/defaults/removeAdditional see the referenced
|
|
@@ -1080,14 +1116,26 @@ class Validator {
|
|
|
1080
1116
|
)));
|
|
1081
1117
|
const useSimdjsonForLarge = !hasArrayTraversal;
|
|
1082
1118
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1119
|
+
// Builds the two generators the compile step left out, once, and upgrades
|
|
1120
|
+
// the shared cache entry. `undefined` means not built yet; `null` means the
|
|
1121
|
+
// compiler declined. Conflating those is what once cost every schema its
|
|
1122
|
+
// generated error function for the life of the process, so they stay apart.
|
|
1123
|
+
const _buildDeferred = () => {
|
|
1124
|
+
if (jsCombinedFn !== undefined && jsErrFn !== undefined) return;
|
|
1125
|
+
const uf2 = this._userFormats;
|
|
1126
|
+
if (jsCombinedFn === undefined) jsCombinedFn = compileToJSCombined(schemaObj, VALID_RESULT, sm, uf2) || null;
|
|
1127
|
+
if (jsErrFn === undefined) jsErrFn = compileToJSCodegenWithErrors(schemaObj, sm, uf2) || null;
|
|
1128
|
+
if (!uf2) {
|
|
1129
|
+
const entry = _compileCache.get(mapKey);
|
|
1130
|
+
if (entry && entry.jsFn === jsFn) {
|
|
1131
|
+
entry.combined = jsCombinedFn;
|
|
1132
|
+
entry.errFn = jsErrFn;
|
|
1133
|
+
entry.full = true;
|
|
1134
|
+
}
|
|
1090
1135
|
}
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
if (jsFn) {
|
|
1091
1139
|
// errFn: use JS codegen if safe, else native fallback (only when native
|
|
1092
1140
|
// is available). Environments without the native addon — Cloudflare
|
|
1093
1141
|
// Workers, browser, Bun without N-API — get a JS-only fallback so the
|
|
@@ -1126,19 +1174,38 @@ class Validator {
|
|
|
1126
1174
|
// reports those schemas correctly, so failing data is re-validated
|
|
1127
1175
|
// there. This used to be a placeholder error with no keyword and no
|
|
1128
1176
|
// path, which hid whatever had actually failed.
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1177
|
+
// Resolved on the first rejection rather than at compile time, because
|
|
1178
|
+
// building the generator behind it is two thirds of what a first call
|
|
1179
|
+
// costs and a caller that never reads an error never needs it. The probe
|
|
1180
|
+
// moves here with it: it calls the generated function, so it cannot run
|
|
1181
|
+
// before the function exists.
|
|
1182
|
+
let _errOnlyImpl = null;
|
|
1183
|
+
const errOnly = (d) => {
|
|
1184
|
+
if (_errOnlyImpl === null) {
|
|
1185
|
+
_buildDeferred();
|
|
1186
|
+
let safe = null;
|
|
1187
|
+
if (jsErrFn) {
|
|
1188
|
+
try {
|
|
1189
|
+
jsErrFn({}, true);
|
|
1190
|
+
safe = (x) => jsErrFn(x, true);
|
|
1191
|
+
} catch {}
|
|
1192
|
+
}
|
|
1193
|
+
_errOnlyImpl =
|
|
1194
|
+
safe ||
|
|
1195
|
+
(hasUnevaluated || !native
|
|
1196
|
+
? jsOnlyFallback
|
|
1197
|
+
: hasDynRef
|
|
1198
|
+
? (x) => {
|
|
1199
|
+
this._ensureNative();
|
|
1200
|
+
return this._compiled.validateJSON(JSON.stringify(x));
|
|
1201
|
+
}
|
|
1202
|
+
: (x) => {
|
|
1203
|
+
this._ensureNative();
|
|
1204
|
+
return this._compiled.validate(x);
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
return _errOnlyImpl(d);
|
|
1208
|
+
};
|
|
1142
1209
|
|
|
1143
1210
|
// Best path: combined validator (single pass, validates + collects errors)
|
|
1144
1211
|
// Valid data: returns VALID_RESULT, no allocation
|
|
@@ -1148,24 +1215,41 @@ class Validator {
|
|
|
1148
1215
|
// Test combined at compile time -- some schemas (e.g. if/then/else)
|
|
1149
1216
|
// produce broken combined code that crashes on certain inputs.
|
|
1150
1217
|
// We probe with diverse data; if any throws, fall back to hybrid.
|
|
1151
|
-
let
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1218
|
+
let _combinedProbed = false;
|
|
1219
|
+
let _safeCombined = null;
|
|
1220
|
+
const combinedIfSafe = () => {
|
|
1221
|
+
if (_combinedProbed) return _safeCombined;
|
|
1222
|
+
_combinedProbed = true;
|
|
1223
|
+
_buildDeferred();
|
|
1224
|
+
if (jsCombinedFn) {
|
|
1225
|
+
try {
|
|
1226
|
+
const probe = {};
|
|
1227
|
+
// Populate probe with one key per known property to trigger nested paths
|
|
1228
|
+
if (schemaObj && schemaObj.properties) {
|
|
1229
|
+
for (const k of Object.keys(schemaObj.properties)) probe[k] = "";
|
|
1230
|
+
}
|
|
1231
|
+
if (schemaObj && schemaObj.if && schemaObj.if.properties) {
|
|
1232
|
+
for (const k of Object.keys(schemaObj.if.properties)) probe[k] = "";
|
|
1233
|
+
}
|
|
1234
|
+
jsCombinedFn(probe);
|
|
1235
|
+
jsCombinedFn({});
|
|
1236
|
+
jsCombinedFn(null);
|
|
1237
|
+
jsCombinedFn(0);
|
|
1238
|
+
_safeCombined = jsCombinedFn;
|
|
1239
|
+
} catch {}
|
|
1240
|
+
}
|
|
1241
|
+
return _safeCombined;
|
|
1242
|
+
};
|
|
1243
|
+
|
|
1244
|
+
// What the hybrid path hands to its error slot: the combined function
|
|
1245
|
+
// when it is usable, since it validates and collects in one pass, and
|
|
1246
|
+
// the error generator otherwise. Same order the eager code chose, just
|
|
1247
|
+
// chosen on the first rejection.
|
|
1248
|
+
let _errPreferredImpl = null;
|
|
1249
|
+
const errPreferCombined = (d) => {
|
|
1250
|
+
if (_errPreferredImpl === null) _errPreferredImpl = combinedIfSafe() || errOnly;
|
|
1251
|
+
return _errPreferredImpl(d);
|
|
1252
|
+
};
|
|
1169
1253
|
|
|
1170
1254
|
// The boolean engine is the verdict authority for these paths; the
|
|
1171
1255
|
// final lazy wrapper uses it to skip error construction entirely.
|
|
@@ -1182,7 +1266,7 @@ class Validator {
|
|
|
1182
1266
|
: (data) => (_fn(data) ? VALID_RESULT : ABORT_EARLY_RESULT);
|
|
1183
1267
|
} else if (hasDynRef && _isCodegen && jsFn) {
|
|
1184
1268
|
// $dynamicRef with JS codegen: direct path, no wrapper layers
|
|
1185
|
-
const _fn = jsFn, _efn =
|
|
1269
|
+
const _fn = jsFn, _efn = errOnly, _R = VALID_RESULT;
|
|
1186
1270
|
this.validate = preprocess
|
|
1187
1271
|
? (data) => { preprocess(data); return _fn(data) ? _R : _efn(data); }
|
|
1188
1272
|
: (data) => _fn(data) ? _R : _efn(data);
|
|
@@ -1207,45 +1291,60 @@ class Validator {
|
|
|
1207
1291
|
} else if (jsFn && jsFn._hybridFactory) {
|
|
1208
1292
|
// Zero-wrapper: hybridFactory bakes VALID_RESULT + errFn into a single function
|
|
1209
1293
|
// No arrow function wrapper, no ternary, one function call
|
|
1210
|
-
|
|
1294
|
+
// The factory bakes the error function in as an argument and never
|
|
1295
|
+
// calls it for a document that passes, so a resolver here costs the
|
|
1296
|
+
// accepted path nothing and keeps the compile off the first call.
|
|
1297
|
+
const hybridFn = jsFn._hybridFactory(VALID_RESULT, errPreferCombined);
|
|
1211
1298
|
this.validate = preprocess
|
|
1212
1299
|
? (data) => { preprocess(data); return hybridFn(data); }
|
|
1213
1300
|
: hybridFn;
|
|
1214
|
-
} else if (safeCombinedFn) {
|
|
1215
|
-
this.validate = preprocess
|
|
1216
|
-
? (data) => { preprocess(data); return safeCombinedFn(data); }
|
|
1217
|
-
: safeCombinedFn;
|
|
1218
1301
|
} else {
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1302
|
+
// No hybrid factory, so the assembly needs the function itself rather
|
|
1303
|
+
// than a reference it can call later: build it now.
|
|
1304
|
+
const safeCombinedFn = combinedIfSafe();
|
|
1305
|
+
if (safeCombinedFn) {
|
|
1306
|
+
this.validate = preprocess
|
|
1307
|
+
? (data) => { preprocess(data); return safeCombinedFn(data); }
|
|
1308
|
+
: safeCombinedFn;
|
|
1309
|
+
} else {
|
|
1310
|
+
this.validate = preprocess
|
|
1224
1311
|
? (data) => {
|
|
1225
1312
|
preprocess(data);
|
|
1226
|
-
return
|
|
1313
|
+
return jsFn(data) ? VALID_RESULT : errOnly(data);
|
|
1227
1314
|
}
|
|
1228
|
-
:
|
|
1229
|
-
|
|
1230
|
-
? (data) => {
|
|
1231
|
-
preprocess(data);
|
|
1232
|
-
return jsFn(data) ? VALID_RESULT : errFn(data);
|
|
1233
|
-
}
|
|
1234
|
-
: (data) => (jsFn(data) ? VALID_RESULT : errFn(data));
|
|
1315
|
+
: (data) => (jsFn(data) ? VALID_RESULT : errOnly(data));
|
|
1316
|
+
}
|
|
1235
1317
|
}
|
|
1236
|
-
// Verbose mode: populate parentSchema on each error
|
|
1237
|
-
//
|
|
1318
|
+
// Verbose mode: populate parentSchema, schema and data on each error, the
|
|
1319
|
+
// three fields the default error shape carries under the same option.
|
|
1320
|
+
// `data` is the value the error points at; without it a caller has to
|
|
1321
|
+
// walk the document by the instance path itself, which is what one
|
|
1322
|
+
// migration ended up writing by hand. Errors may be frozen, so clone
|
|
1323
|
+
// them with the extra fields.
|
|
1238
1324
|
if (this._verbose) {
|
|
1239
1325
|
const inner = this.validate;
|
|
1240
1326
|
const root = this._schemaObj;
|
|
1327
|
+
const { resolvePointer } = require('./lib/pointer.js');
|
|
1241
1328
|
this.validate = (data) => {
|
|
1242
1329
|
const result = inner(data);
|
|
1243
1330
|
if (result && !result.valid && result.errors) {
|
|
1244
|
-
const enriched = result.errors.map((err) =>
|
|
1245
|
-
err
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1331
|
+
const enriched = result.errors.map((err) => {
|
|
1332
|
+
if (!err || err.parentSchema !== undefined) return err;
|
|
1333
|
+
const parentSchema = resolveSchemaByPath(root, err.schemaPath);
|
|
1334
|
+
// The last segment of the schema path is the keyword that
|
|
1335
|
+
// failed, so its value on the parent is that keyword's schema.
|
|
1336
|
+
const sp = typeof err.schemaPath === 'string' ? err.schemaPath : '';
|
|
1337
|
+
const last = sp.slice(sp.lastIndexOf('/') + 1).replace(/~1/g, '/').replace(/~0/g, '~');
|
|
1338
|
+
const keywordSchema = (parentSchema !== null && typeof parentSchema === 'object' && last)
|
|
1339
|
+
? parentSchema[last]
|
|
1340
|
+
: undefined;
|
|
1341
|
+
return {
|
|
1342
|
+
...err,
|
|
1343
|
+
parentSchema,
|
|
1344
|
+
schema: keywordSchema,
|
|
1345
|
+
data: resolvePointer(data, err.instancePath, undefined),
|
|
1346
|
+
};
|
|
1347
|
+
});
|
|
1249
1348
|
return { valid: false, errors: enriched };
|
|
1250
1349
|
}
|
|
1251
1350
|
return result;
|
|
@@ -1257,12 +1356,15 @@ class Validator {
|
|
|
1257
1356
|
this.isValidObject = preprocess
|
|
1258
1357
|
? (data) => { preprocess(data); return jsFn(data) }
|
|
1259
1358
|
: jsFn;
|
|
1359
|
+
// Same preference as the object path: the combined function first, since
|
|
1360
|
+
// it validates and collects in one pass, and the error generator behind
|
|
1361
|
+
// it. `errPreferCombined` is that order, resolved on the first rejection
|
|
1362
|
+
// instead of at compile time.
|
|
1260
1363
|
const hybridFn = jsFn._hybridFactory
|
|
1261
|
-
? jsFn._hybridFactory(VALID_RESULT,
|
|
1364
|
+
? jsFn._hybridFactory(VALID_RESULT, errPreferCombined)
|
|
1262
1365
|
: null;
|
|
1263
|
-
const jsonValidateInner =
|
|
1264
|
-
||
|
|
1265
|
-
|| ((obj) => (jsFn(obj) ? VALID_RESULT : errFn(obj)));
|
|
1366
|
+
const jsonValidateInner = hybridFn
|
|
1367
|
+
|| ((obj) => (jsFn(obj) ? VALID_RESULT : errPreferCombined(obj)));
|
|
1266
1368
|
// Parsed text takes the same preprocess pass as a parsed object, so
|
|
1267
1369
|
// validate(obj) and validateJSON(text) answer the same for the same
|
|
1268
1370
|
// document. Without it, coercion, removal and defaults applied on one
|
|
@@ -1782,15 +1884,20 @@ class Validator {
|
|
|
1782
1884
|
return;
|
|
1783
1885
|
}
|
|
1784
1886
|
const uf = this._userFormats;
|
|
1785
|
-
const
|
|
1887
|
+
const _cg = compileToJSCodegen(this._schemaObj, sm, uf);
|
|
1888
|
+
const jsFn = _cg || compileToJS(this._schemaObj, null, sm);
|
|
1786
1889
|
this._jsFn = jsFn;
|
|
1787
1890
|
if (jsFn) {
|
|
1788
1891
|
this.isValidObject = jsFn;
|
|
1789
1892
|
// A partial entry: the verdict function is real, the other two are not
|
|
1790
|
-
// built yet rather than declined. `
|
|
1791
|
-
//
|
|
1893
|
+
// built yet rather than declined. `undefined` is the not-built marker
|
|
1894
|
+
// the full compile's _buildDeferred looks for; `null` would read as
|
|
1895
|
+
// "the compiler declined" and cost the schema its error function, which
|
|
1896
|
+
// is the bug this cache had once already. `isCodegen` rides along so a
|
|
1897
|
+
// validator that later reuses this entry reports the same engine it
|
|
1898
|
+
// would have compiled to.
|
|
1792
1899
|
if (!uf) {
|
|
1793
|
-
if (!cached) _compileCache.set(mapKey, { jsFn, combined:
|
|
1900
|
+
if (!cached) _compileCache.set(mapKey, { jsFn, combined: undefined, errFn: undefined, isCodegen: !!_cg, full: false });
|
|
1794
1901
|
else cached.jsFn = jsFn;
|
|
1795
1902
|
}
|
|
1796
1903
|
}
|
package/lib/compat-errors.js
CHANGED
|
@@ -49,7 +49,14 @@ function pointerOf(schemaPath) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function segments(pointer) {
|
|
52
|
-
|
|
52
|
+
// split-then-slice built two arrays per call, and this is called for every
|
|
53
|
+
// error's pointer and every pointer walk. Dropping everything before the
|
|
54
|
+
// first '/' first builds one. Same answer for every input shape, including
|
|
55
|
+
// a pointer with no slash at all, which the old form turned into [].
|
|
56
|
+
if (pointer === '') return [];
|
|
57
|
+
const i = pointer.indexOf('/');
|
|
58
|
+
if (i < 0) return [];
|
|
59
|
+
return pointer.slice(i + 1).split('/');
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
// Walks a pointer's segments and reports, per position, whether that segment
|
|
@@ -67,24 +74,41 @@ function classify(segs) {
|
|
|
67
74
|
return kinds;
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
77
|
+
// One derived key per error: its pointer relative to the hash, the split
|
|
78
|
+
// segments, and each segment's kind. Everything below used to re-derive these
|
|
79
|
+
// wherever it needed them, which for one shaped rejection meant splitting the
|
|
80
|
+
// same pointer three times per error.
|
|
81
|
+
function keyFor(e) {
|
|
82
|
+
const rel = pointerOf(e && e.schemaPath ? e.schemaPath : '#');
|
|
83
|
+
const segs = segments(rel);
|
|
84
|
+
return { rel, segs, kinds: classify(segs) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sortByKeys(errors, keys) {
|
|
88
|
+
if (errors.length < 2) return errors.slice();
|
|
89
|
+
const keyed = errors.map((e) => ({ e, k: keys.get(e) }));
|
|
90
|
+
keyed.sort((A, B) => {
|
|
91
|
+
const pa = A.k.segs, pb = B.k.segs, ka = A.k.kinds;
|
|
92
|
+
const n = Math.min(pa.length, pb.length);
|
|
93
|
+
for (let i = 0; i < n; i++) {
|
|
94
|
+
if (pa[i] === pb[i]) continue;
|
|
95
|
+
if (ka[i] !== 'kw') return 0;
|
|
96
|
+
const ra = REFERENCE_RANK.get(pa[i]);
|
|
97
|
+
const rb = REFERENCE_RANK.get(pb[i]);
|
|
98
|
+
if (ra === undefined || rb === undefined) return 0;
|
|
99
|
+
return ra - rb;
|
|
100
|
+
}
|
|
101
|
+
return 0;
|
|
102
|
+
});
|
|
103
|
+
for (let i = 0; i < keyed.length; i++) keyed[i] = keyed[i].e;
|
|
104
|
+
return keyed;
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
function sortLikeReference(errors) {
|
|
87
|
-
|
|
108
|
+
if (errors.length < 2) return errors;
|
|
109
|
+
const keys = new Map();
|
|
110
|
+
for (const e of errors) keys.set(e, keyFor(e));
|
|
111
|
+
return sortByKeys(errors, keys);
|
|
88
112
|
}
|
|
89
113
|
|
|
90
114
|
function walkPointer(node, pointer) {
|
|
@@ -151,14 +175,16 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
|
|
|
151
175
|
// `basePointer` is where `errors` were produced relative to the root
|
|
152
176
|
// document; a branch validator reports paths relative to its branch.
|
|
153
177
|
function shape(errors, data, basePointer) {
|
|
154
|
-
const
|
|
178
|
+
const keys = new Map();
|
|
179
|
+
for (const e of errors) keys.set(e, keyFor(e));
|
|
180
|
+
const sorted = sortByKeys(errors, keys);
|
|
155
181
|
const out = [];
|
|
156
182
|
const consumed = new Set();
|
|
157
183
|
let thenGroup = null; // { prefix, which } for the pending `if` wrapper
|
|
158
184
|
|
|
159
185
|
const flushIf = (e) => {
|
|
160
186
|
if (thenGroup === null) return;
|
|
161
|
-
const segs =
|
|
187
|
+
const segs = e ? keys.get(e).segs : [];
|
|
162
188
|
const still = e && segs.length > thenGroup.depth && segs[thenGroup.depth] === thenGroup.which &&
|
|
163
189
|
segs.slice(0, thenGroup.depth).join('/') === thenGroup.prefixSegs;
|
|
164
190
|
if (still) return;
|
|
@@ -170,10 +196,11 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
|
|
|
170
196
|
for (let idx = 0; idx < sorted.length; idx++) {
|
|
171
197
|
const e = sorted[idx];
|
|
172
198
|
if (consumed.has(e)) continue;
|
|
173
|
-
const
|
|
199
|
+
const K = keys.get(e);
|
|
200
|
+
const rel = K.rel;
|
|
174
201
|
const abs = basePointer + rel;
|
|
175
|
-
const segs =
|
|
176
|
-
const kinds =
|
|
202
|
+
const segs = K.segs;
|
|
203
|
+
const kinds = K.kinds;
|
|
177
204
|
const schemaPrefix = (e.schemaPath || '#').slice(0, (e.schemaPath || '#').indexOf('#') + 1);
|
|
178
205
|
|
|
179
206
|
if (allErrors) flushIf(e);
|
|
@@ -234,7 +261,7 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
|
|
|
234
261
|
// instance is replaced by the regenerated sequence.
|
|
235
262
|
for (let j = idx; j < sorted.length; j++) {
|
|
236
263
|
const o = sorted[j];
|
|
237
|
-
if ((o.instancePath || '') === (e.instancePath || '') &&
|
|
264
|
+
if ((o.instancePath || '') === (e.instancePath || '') && keys.get(o).rel.startsWith(pnPointer + '/')) consumed.add(o);
|
|
238
265
|
}
|
|
239
266
|
const pnSchemaPath = schemaPrefix + pnPointer;
|
|
240
267
|
for (const name of Object.keys(object)) {
|
package/lib/data-positions.js
CHANGED
|
@@ -63,6 +63,10 @@ function buildDataPositionMap (input) {
|
|
|
63
63
|
i++;
|
|
64
64
|
while (true) {
|
|
65
65
|
skipWs();
|
|
66
|
+
// The input is not required to be valid JSON: this map is built to
|
|
67
|
+
// put a caret on a syntax error, so a document that stops in the
|
|
68
|
+
// middle of a container is the normal case, not an impossible one.
|
|
69
|
+
if (i >= n) break;
|
|
66
70
|
if (text.charCodeAt(i) === 0x7d) { i++; break; }
|
|
67
71
|
if (text.charCodeAt(i) === 0x2c) { i++; continue; }
|
|
68
72
|
skipWs();
|
|
@@ -88,9 +92,15 @@ function buildDataPositionMap (input) {
|
|
|
88
92
|
let idx = 0;
|
|
89
93
|
while (true) {
|
|
90
94
|
skipWs();
|
|
95
|
+
if (i >= n) break;
|
|
91
96
|
if (text.charCodeAt(i) === 0x5d) { i++; break; }
|
|
92
97
|
if (text.charCodeAt(i) === 0x2c) { i++; continue; }
|
|
98
|
+
const before = i;
|
|
93
99
|
walk(path.concat([String(idx)]));
|
|
100
|
+
// A character that begins no value at all, a stray `}` for instance,
|
|
101
|
+
// leaves the position where it was, and a loop that does not advance
|
|
102
|
+
// does not end. `validateJSON('[')` hung here on one byte.
|
|
103
|
+
if (i === before) break;
|
|
94
104
|
idx++;
|
|
95
105
|
}
|
|
96
106
|
} else if (ch === 0x22) {
|
package/lib/enrich-error.js
CHANGED
|
@@ -219,6 +219,12 @@ function enrich (rawErr, opts) {
|
|
|
219
219
|
parentSchema: rawErr.parentSchema,
|
|
220
220
|
};
|
|
221
221
|
|
|
222
|
+
// Verbose mode's two other fields. Carried only when the raw error has them,
|
|
223
|
+
// so the default error shape does not grow two undefined keys for everyone
|
|
224
|
+
// who never asked for verbose.
|
|
225
|
+
if ('data' in rawErr) out.data = rawErr.data;
|
|
226
|
+
if ('schema' in rawErr) out.schema = rawErr.schema;
|
|
227
|
+
|
|
222
228
|
// oneOf/anyOf collapse: preserve the nested branch errors so the pretty
|
|
223
229
|
// renderer can surface the closest variant's diagnostics.
|
|
224
230
|
if (rawErr.branchErrors) out.branchErrors = rawErr.branchErrors;
|
package/lib/interpreter.js
CHANGED
|
@@ -461,6 +461,11 @@ class Plan {
|
|
|
461
461
|
|
|
462
462
|
this.unevaluatedProperties = schema.unevaluatedProperties !== undefined ? child(schema.unevaluatedProperties) : undefined;
|
|
463
463
|
this.unevaluatedItems = schema.unevaluatedItems !== undefined ? child(schema.unevaluatedItems) : undefined;
|
|
464
|
+
// A `false` here rejects the member outright, and the error belongs to this
|
|
465
|
+
// keyword on this object, not to a boolean schema that happens to sit
|
|
466
|
+
// under it. Recorded at compile time so the check below is one test.
|
|
467
|
+
this.unevaluatedPropertiesFalse = schema.unevaluatedProperties === false;
|
|
468
|
+
this.unevaluatedItemsFalse = schema.unevaluatedItems === false;
|
|
464
469
|
this.hasUnevaluated = this.unevaluatedProperties !== undefined || this.unevaluatedItems !== undefined;
|
|
465
470
|
|
|
466
471
|
// Custom keywords present on this node, in schema order. Each op holds
|
|
@@ -1183,7 +1188,14 @@ class Interpreter {
|
|
|
1183
1188
|
if (P.unevaluatedProperties !== undefined && bits === T_OBJECT) {
|
|
1184
1189
|
for (const key of Object.keys(data)) {
|
|
1185
1190
|
if (local.props && local.props.has(key)) continue;
|
|
1186
|
-
if (
|
|
1191
|
+
if (P.unevaluatedPropertiesFalse) {
|
|
1192
|
+
// The name is what is wrong, so the error names it and sits on the
|
|
1193
|
+
// object holding it. Descending instead reported a false boolean
|
|
1194
|
+
// schema at the member's own path, which says nothing about which
|
|
1195
|
+
// key was unexpected and left `params` empty.
|
|
1196
|
+
valid = false;
|
|
1197
|
+
if (errors !== NOERRORS) errors.push(err('unevaluatedProperties', 'unevaluatedProperties', instancePath, schemaPath + '/unevaluatedProperties', { unevaluatedProperty: key }, 'must NOT have unevaluated properties'));
|
|
1198
|
+
} else if (!this.eval(P.unevaluatedProperties, data[key], base, dynScope, errors, instancePath + '/' + escapePointer(key), schemaPath + '/unevaluatedProperties', stack, DISCARD)) valid = false;
|
|
1187
1199
|
if (!local.props) local.props = new Set();
|
|
1188
1200
|
local.props.add(key);
|
|
1189
1201
|
}
|
|
@@ -1191,6 +1203,13 @@ class Interpreter {
|
|
|
1191
1203
|
if (P.unevaluatedItems !== undefined && bits === T_ARRAY) {
|
|
1192
1204
|
for (let i = 0; i < data.length; i++) {
|
|
1193
1205
|
if (local.items && local.items.has(i)) continue;
|
|
1206
|
+
if (P.unevaluatedItemsFalse) {
|
|
1207
|
+
// One error for the array, not one per trailing element: what the
|
|
1208
|
+
// array got wrong is its length past the last evaluated position.
|
|
1209
|
+
valid = false;
|
|
1210
|
+
if (errors !== NOERRORS) errors.push(err('unevaluatedItems', 'unevaluatedItems', instancePath, schemaPath + '/unevaluatedItems', { limit: i }, 'must NOT have more than ' + i + ' items'));
|
|
1211
|
+
break;
|
|
1212
|
+
}
|
|
1194
1213
|
if (!this.eval(P.unevaluatedItems, data[i], base, dynScope, errors, instancePath + '/' + i, schemaPath + '/unevaluatedItems', stack, DISCARD)) valid = false;
|
|
1195
1214
|
if (!local.items) local.items = new Set();
|
|
1196
1215
|
local.items.add(i);
|
package/lib/js-compiler.js
CHANGED
|
@@ -35,6 +35,46 @@ function hoistOnce(ctx, key, code) {
|
|
|
35
35
|
else if (ctx.helperCode) ctx.helperCode.push(code)
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Above this many declared names, asking whether a key is one of them by
|
|
39
|
+
// testing it against each name in turn costs a comparison per name per key,
|
|
40
|
+
// which is quadratic in the number of properties: a 1000-property schema with
|
|
41
|
+
// `additionalProperties: false` spent 75 us where the same schema without the
|
|
42
|
+
// keyword took 3, and doubling the properties quadrupled that. Below this many
|
|
43
|
+
// the two are the same within noise and the chain allocates nothing, so it
|
|
44
|
+
// stays. Cross-process medians, one variant per process, properties of type
|
|
45
|
+
// string: 3 to 100 names are a tie; 250 names 8.76 us against 5.49, 500 26.29
|
|
46
|
+
// against 12.47, and with no per-property work 2000 names 273 against 49.
|
|
47
|
+
const AP_LOOKUP_MIN = 128
|
|
48
|
+
|
|
49
|
+
// A name set built once per compiled function rather than once per call, as
|
|
50
|
+
// source text so the standalone output carries it the same way. A null
|
|
51
|
+
// prototype means a key called `toString` is not a member by accident, and a
|
|
52
|
+
// plain property read measured faster than `Set#has` at every size tried.
|
|
53
|
+
function emitNameLookup (ctx, names) {
|
|
54
|
+
const id = `_apn${ctx.varCounter++}`
|
|
55
|
+
const list = names.map((n) => JSON.stringify(n)).join(',')
|
|
56
|
+
const decl = `const ${id}=Object.create(null);for(const _apx of [${list}])${id}[_apx]=1`
|
|
57
|
+
if (ctx.preamble) ctx.preamble.push(decl)
|
|
58
|
+
else if (ctx.helperCode) ctx.helperCode.push(decl)
|
|
59
|
+
else return null
|
|
60
|
+
return id
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// "every own key of `v` is one of `names`", as a `return false` check. A
|
|
64
|
+
// hoisted lookup above the crossover, the comparison chain below it.
|
|
65
|
+
function apMembershipCheck (ctx, names, v) {
|
|
66
|
+
if (names.length >= AP_LOOKUP_MIN) {
|
|
67
|
+
const id = emitNameLookup(ctx, names)
|
|
68
|
+
if (id !== null) {
|
|
69
|
+
// An indexed loop over `Object.keys`, not `for...in`: it allocates the key
|
|
70
|
+
// array but measured faster at every size tried, by about a quarter.
|
|
71
|
+
const i = ctx.varCounter++
|
|
72
|
+
return `var _apk${i}=Object.keys(${v});for(var _api${i}=0;_api${i}<_apk${i}.length;_api${i}++)if(${id}[_apk${i}[_api${i}]]===undefined)return false`
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return `for(var _k in ${v})if(${names.map((k) => `_k!==${JSON.stringify(k)}`).join('&&')})return false`
|
|
76
|
+
}
|
|
77
|
+
|
|
38
78
|
function emitDeq(ctx) {
|
|
39
79
|
hoistOnce(ctx, '_deqHoisted', DEQ_HELPER)
|
|
40
80
|
return '_deq'
|
|
@@ -2147,7 +2187,7 @@ function genCode(schema, v, lines, ctx, knownType) {
|
|
|
2147
2187
|
? (propCount <= 15
|
|
2148
2188
|
? `var _n=0;for(var _k in ${v})_n++;if(_n!==${propCount})return false`
|
|
2149
2189
|
: `if(Object.keys(${v}).length!==${propCount})return false`)
|
|
2150
|
-
:
|
|
2190
|
+
: apMembershipCheck(ctx, Object.keys(schema.properties), v)
|
|
2151
2191
|
_deferOrInline(ctx, lines, v, isObj ? inner : `if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v})){${inner}}`)
|
|
2152
2192
|
}
|
|
2153
2193
|
|
|
@@ -2572,8 +2612,14 @@ function genCode(schema, v, lines, ctx, knownType) {
|
|
|
2572
2612
|
}
|
|
2573
2613
|
// else: already emitted early (before properties)
|
|
2574
2614
|
} else if (propCount > 0) {
|
|
2575
|
-
// TRICK 3: charCodeAt switch tree
|
|
2576
|
-
|
|
2615
|
+
// TRICK 3: charCodeAt switch tree. Above the lookup threshold it is
|
|
2616
|
+
// the additionalProperties quadratic wearing a disguise: keys that
|
|
2617
|
+
// share a first character, ENV_0 through ENV_999 say, all land in
|
|
2618
|
+
// one case whose body is a chain of every name, per key of the
|
|
2619
|
+
// document. The hoisted name lookup is the same cure it was there.
|
|
2620
|
+
inner = propCount >= AP_LOOKUP_MIN
|
|
2621
|
+
? apMembershipCheck(ctx, knownKeys, v)
|
|
2622
|
+
: genCharCodeSwitch(knownKeys, v)
|
|
2577
2623
|
_deferOrInline(ctx, lines, v, isObj ? inner : `if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v})){${inner}}`)
|
|
2578
2624
|
} else {
|
|
2579
2625
|
inner = `for(var _k in ${v})return false`
|
|
@@ -2587,8 +2633,13 @@ function genCode(schema, v, lines, ctx, knownType) {
|
|
|
2587
2633
|
genCode(schema.unevaluatedProperties, `${v}[${ukVar}]`, subLines, ctx)
|
|
2588
2634
|
if (subLines.length > 0) {
|
|
2589
2635
|
const check = subLines.join(';')
|
|
2590
|
-
|
|
2591
|
-
|
|
2636
|
+
let skipKnown = ''
|
|
2637
|
+
if (knownKeys.length >= AP_LOOKUP_MIN) {
|
|
2638
|
+
const id = emitNameLookup(ctx, knownKeys)
|
|
2639
|
+
skipKnown = id !== null ? `if(${id}[${ukVar}]!==undefined)continue;` : `if(${knownKeys.map(k => `${ukVar}===${JSON.stringify(k)}`).join('||')})continue;`
|
|
2640
|
+
} else if (knownKeys.length > 0) {
|
|
2641
|
+
skipKnown = `if(${knownKeys.map(k => `${ukVar}===${JSON.stringify(k)}`).join('||')})continue;`
|
|
2642
|
+
}
|
|
2592
2643
|
const inner = `for(var ${ukVar} in ${v}){${skipKnown}${check}}`
|
|
2593
2644
|
_deferOrInline(ctx, lines, v, isObj ? inner : `if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v})){${inner}}`)
|
|
2594
2645
|
}
|
package/lib/plan-compiler.js
CHANGED
|
@@ -894,6 +894,27 @@ function install(deps) {
|
|
|
894
894
|
// unevaluated*: run last, against the annotations of everything above.
|
|
895
895
|
if (P.unevaluatedProperties !== undefined) {
|
|
896
896
|
const fn = child(P.unevaluatedProperties);
|
|
897
|
+
if (fn === FALSE_PAIR) {
|
|
898
|
+
// `false` rejects the member outright, and what is wrong is the name.
|
|
899
|
+
// Running the boolean schema against the value instead reported a
|
|
900
|
+
// false schema at the member's own path with empty params, which does
|
|
901
|
+
// not say which key was unexpected. The error belongs to this keyword,
|
|
902
|
+
// on the object holding the key, and it carries the key.
|
|
903
|
+
steps.push((data, errors, instancePath, schemaPath, stack, rec) => {
|
|
904
|
+
if (dataBits(data) !== T_OBJECT) return true;
|
|
905
|
+
let ok = true;
|
|
906
|
+
const keys = keysOf(rec, data);
|
|
907
|
+
for (let k = 0; k < keys.length; k++) {
|
|
908
|
+
const key = keys[k];
|
|
909
|
+
if (hasProp(rec, key)) continue;
|
|
910
|
+
ok = false;
|
|
911
|
+
if (errors === NOERRORS) return false;
|
|
912
|
+
errors.push(err('unevaluatedProperties', 'unevaluatedProperties', instancePath, schemaPath + '/unevaluatedProperties', { unevaluatedProperty: key }, 'must NOT have unevaluated properties'));
|
|
913
|
+
addProp(rec, key);
|
|
914
|
+
}
|
|
915
|
+
return ok;
|
|
916
|
+
});
|
|
917
|
+
} else {
|
|
897
918
|
steps.push((data, errors, instancePath, schemaPath, stack, rec) => {
|
|
898
919
|
if (dataBits(data) !== T_OBJECT) return true;
|
|
899
920
|
let ok = true;
|
|
@@ -909,6 +930,7 @@ function install(deps) {
|
|
|
909
930
|
}
|
|
910
931
|
return ok;
|
|
911
932
|
});
|
|
933
|
+
}
|
|
912
934
|
if (fn === FALSE_PAIR) {
|
|
913
935
|
// Every key must already be evaluated; on success there is nothing
|
|
914
936
|
// new to record.
|
|
@@ -936,6 +958,21 @@ function install(deps) {
|
|
|
936
958
|
}
|
|
937
959
|
if (P.unevaluatedItems !== undefined) {
|
|
938
960
|
const fn = child(P.unevaluatedItems);
|
|
961
|
+
if (fn === FALSE_PAIR) {
|
|
962
|
+
// One error for the array rather than one per trailing element: what
|
|
963
|
+
// the array got wrong is its length past the last evaluated position.
|
|
964
|
+
steps.push((data, errors, instancePath, schemaPath, stack, rec) => {
|
|
965
|
+
if (dataBits(data) !== T_ARRAY) return true;
|
|
966
|
+
for (let i = 0; i < data.length; i++) {
|
|
967
|
+
if (hasItem(rec, i)) continue;
|
|
968
|
+
if (errors !== NOERRORS) errors.push(err('unevaluatedItems', 'unevaluatedItems', instancePath, schemaPath + '/unevaluatedItems', { limit: i }, 'must NOT have more than ' + i + ' items'));
|
|
969
|
+
if (data.length > rec.n) rec.n = data.length;
|
|
970
|
+
return false;
|
|
971
|
+
}
|
|
972
|
+
if (data.length > rec.n) rec.n = data.length;
|
|
973
|
+
return true;
|
|
974
|
+
});
|
|
975
|
+
} else {
|
|
939
976
|
steps.push((data, errors, instancePath, schemaPath, stack, rec) => {
|
|
940
977
|
if (dataBits(data) !== T_ARRAY) return true;
|
|
941
978
|
let ok = true;
|
|
@@ -949,6 +986,7 @@ function install(deps) {
|
|
|
949
986
|
if (data.length > rec.n) rec.n = data.length;
|
|
950
987
|
return ok;
|
|
951
988
|
});
|
|
989
|
+
}
|
|
952
990
|
if (fn === FALSE_PAIR) {
|
|
953
991
|
vsteps.push((data, stack, rec) => {
|
|
954
992
|
if (dataBits(data) !== T_ARRAY) return true;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The authoring-time check behind `strictSchema`. A mistyped keyword is the
|
|
4
|
+
// one schema mistake that fails open: `maxLenght` is not an error to any
|
|
5
|
+
// JSON Schema dialect, it is an annotation, so the intended constraint is
|
|
6
|
+
// simply absent and previously invalid data starts validating. A validator
|
|
7
|
+
// cannot make that safe at validation time. It can be loud about it when the
|
|
8
|
+
// schema is compiled, which is what this is.
|
|
9
|
+
//
|
|
10
|
+
// The known-keyword list is not written out here. Each vendored meta-schema's
|
|
11
|
+
// `properties` are precisely its dialect's keywords, so the list comes from
|
|
12
|
+
// the specification documents in `metaschemas.js` rather than from a copy
|
|
13
|
+
// someone keeps in step. The union across vendored dialects is used, not the
|
|
14
|
+
// schema's own dialect: a draft-07 keyword in a 2020-12 schema is a real
|
|
15
|
+
// authoring question but not a fail-open one, and a union cannot produce a
|
|
16
|
+
// false positive on a valid document.
|
|
17
|
+
|
|
18
|
+
const { METASCHEMAS } = require('./metaschemas');
|
|
19
|
+
const { levenshtein } = require('./levenshtein');
|
|
20
|
+
|
|
21
|
+
// Keywords ata implements beyond the official vocabularies.
|
|
22
|
+
const ATA_KEYWORDS = ['nullable', 'errorMessage', 'propertyDependencies', 'discriminator'];
|
|
23
|
+
|
|
24
|
+
let SPEC_KEYWORDS = null;
|
|
25
|
+
function specKeywords() {
|
|
26
|
+
if (SPEC_KEYWORDS === null) {
|
|
27
|
+
SPEC_KEYWORDS = new Set(ATA_KEYWORDS);
|
|
28
|
+
for (const doc of METASCHEMAS.values()) {
|
|
29
|
+
if (doc && doc.properties) for (const k of Object.keys(doc.properties)) SPEC_KEYWORDS.add(k);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return SPEC_KEYWORDS;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Where subschemas live, so the walk knows a key under `properties` is a
|
|
36
|
+
// property name and a key directly on a schema object is a keyword.
|
|
37
|
+
const SCHEMA_MAPS = new Set(['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']);
|
|
38
|
+
const SCHEMA_LISTS = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']);
|
|
39
|
+
const SCHEMA_SINGLE = new Set([
|
|
40
|
+
'items', 'additionalItems', 'additionalProperties', 'unevaluatedProperties',
|
|
41
|
+
'unevaluatedItems', 'contains', 'propertyNames', 'not', 'if', 'then', 'else',
|
|
42
|
+
'contentSchema',
|
|
43
|
+
]);
|
|
44
|
+
// Values that are data, never schemas, however object-shaped they look.
|
|
45
|
+
const DATA_VALUED = new Set(['enum', 'const', 'default', 'examples', 'required', 'type', '$vocabulary']);
|
|
46
|
+
|
|
47
|
+
function nearest(word, known) {
|
|
48
|
+
let best = null;
|
|
49
|
+
let bestD = Infinity;
|
|
50
|
+
for (const k of known) {
|
|
51
|
+
// A candidate further away than half the word is noise, not a typo.
|
|
52
|
+
const d = levenshtein(word, k);
|
|
53
|
+
if (d < bestD) { bestD = d; best = k; }
|
|
54
|
+
}
|
|
55
|
+
return bestD > 0 && bestD <= 2 && best !== null ? best : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveLocalPointer(root, ref) {
|
|
59
|
+
let node = root;
|
|
60
|
+
for (const raw of ref.slice(2).split('/')) {
|
|
61
|
+
const token = raw.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
62
|
+
if (node === null || typeof node !== 'object') return false;
|
|
63
|
+
if (Array.isArray(node)) {
|
|
64
|
+
if (!/^\d+$/.test(token) || Number(token) >= node.length) return false;
|
|
65
|
+
node = node[Number(token)];
|
|
66
|
+
} else {
|
|
67
|
+
if (!Object.prototype.hasOwnProperty.call(node, token)) return false;
|
|
68
|
+
node = node[token];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return node !== undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Walks a schema and reports the authoring mistakes the validator would
|
|
76
|
+
* otherwise ignore. Returns an array of { path, message }, empty when clean.
|
|
77
|
+
*
|
|
78
|
+
* `userKeywords` are names registered through the `keywords` option, and
|
|
79
|
+
* `x-` prefixed names pass without comment: they are the conventional
|
|
80
|
+
* extension namespace and rejecting them would flag real documents.
|
|
81
|
+
*/
|
|
82
|
+
function checkSchemaStrict(root, options) {
|
|
83
|
+
const known = specKeywords();
|
|
84
|
+
const user = options && options.userKeywords ? options.userKeywords : null;
|
|
85
|
+
const problems = [];
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
|
|
88
|
+
function isKnown(key) {
|
|
89
|
+
if (known.has(key)) return true;
|
|
90
|
+
if (user && (Array.isArray(user) ? user.includes(key) : Object.prototype.hasOwnProperty.call(user, key))) return true;
|
|
91
|
+
if (key.startsWith('x-')) return true;
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function walkSchema(node, path) {
|
|
96
|
+
if (node === null || typeof node !== 'object' || Array.isArray(node)) return;
|
|
97
|
+
if (seen.has(node)) return;
|
|
98
|
+
seen.add(node);
|
|
99
|
+
for (const key of Object.keys(node)) {
|
|
100
|
+
const value = node[key];
|
|
101
|
+
const at = path + '/' + key;
|
|
102
|
+
if (!isKnown(key)) {
|
|
103
|
+
const hint = nearest(key, known);
|
|
104
|
+
problems.push({
|
|
105
|
+
path: at,
|
|
106
|
+
message: hint
|
|
107
|
+
? `unknown keyword "${key}" (did you mean "${hint}"?)`
|
|
108
|
+
: `unknown keyword "${key}"`,
|
|
109
|
+
});
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (key === '$ref' && typeof value === 'string' && value.startsWith('#/') && !resolveLocalPointer(root, value)) {
|
|
113
|
+
problems.push({ path: at, message: `$ref "${value}" does not resolve in this document` });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (DATA_VALUED.has(key)) continue;
|
|
117
|
+
if (SCHEMA_MAPS.has(key)) {
|
|
118
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
119
|
+
for (const name of Object.keys(value)) walkSchema(value[name], at + '/' + name.replace(/~/g, '~0').replace(/\//g, '~1'));
|
|
120
|
+
}
|
|
121
|
+
} else if (SCHEMA_LISTS.has(key)) {
|
|
122
|
+
if (Array.isArray(value)) for (let i = 0; i < value.length; i++) walkSchema(value[i], at + '/' + i);
|
|
123
|
+
} else if (SCHEMA_SINGLE.has(key)) {
|
|
124
|
+
// draft-07 lets `items` be an array of schemas.
|
|
125
|
+
if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) walkSchema(value[i], at + '/' + i); }
|
|
126
|
+
else walkSchema(value, at);
|
|
127
|
+
} else if (key === 'dependencies') {
|
|
128
|
+
// draft-07: each value is either a schema or an array of names.
|
|
129
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
130
|
+
for (const name of Object.keys(value)) {
|
|
131
|
+
if (!Array.isArray(value[name])) walkSchema(value[name], at + '/' + name);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
walkSchema(root, '#');
|
|
139
|
+
return problems;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { checkSchemaStrict };
|
package/lib/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ata-validator",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "JSON Schema validation
|
|
3
|
+
"version": "1.23.0",
|
|
4
|
+
"description": "JSON Schema validation that compiles for speed and still runs where code generation is blocked. Compiled and interpreted engines answer identically at 100% of the official suite. TypeScript inference, Standard Schema V1, and a build step that emits dependency-free modules.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"module": "index.mjs",
|
|
7
7
|
"types": "index.d.ts",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"release:check": "node scripts/regen-safe-regex-source.js && node tests/test_pack_purity.js && node scripts/check-doc-coverage.js && node tests/test_error_codes_lock.js && node tests/test_safe_regex_source_sync.js && node tests/test_version_sync.js",
|
|
52
52
|
"build": "cmake-js build --target ata",
|
|
53
53
|
"rebuild": "cmake-js rebuild --target ata",
|
|
54
|
-
"test": "node test.js && node tests/test_removed_aot_methods.js && node tests/test_no_native.js && node tests/test_no_eval.js && node tests/test_property_dependencies.js && node tests/test_v1_dialect.js && node tests/test_buffer_path_parity.js && node tests/test_buffer_gate.js && node tests/test_buffer_reject_cost.js && node tests/test_nan_verdict.js && node tests/test_remove_additional_nested.js && node tests/test_exclusive_bounds.js && node tests/test_engine_differential.js && node tests/test_error_shape_differential.js && node tests/test_output_format.js && node tests/test_retry_message.js && node tests/test_describe_schema.js && node tests/test_aot_parse.js && node tests/test_draft7_semantics.js && node tests/test_metaschema_ref.js && node tests/test_pure_js_unsupported.js && node tests/test_native_load_order.js && node tests/test_pack_purity.js && node tests/test_make_native_package.js && node tests/test_browser_nofs.js && node tests/test_browser_imports_guard.js && node tests/test_version_sync.js && node tests/test_native_loaded.js && node tests/test_safe_regex_source_sync.js && node tests/test_t_builder.js && node tests/test_async_refine.js && node tests/test_safe_regex.js && node tests/test_safe_regex_integration.js && node tests/test_aot_build.js && node tests/test_aot_differential.js && node tests/test_aot_cli_build.js && node tests/test_aot_cli_smoke.js && node tests/test_bundle_standalone.js && node tests/test_standalone_anyof.js && node tests/test_standalone_formats.js && node tests/test_aot_format_mode.js && node tests/test_aot_additional_props_errors.js && node tests/test_id_anchor_refs.js && node tests/test_engine_routing.js && node tests/test_compile_cache_order.js && node tests/test_engine_diagnostic.js && node tests/test_format_engine_parity.js && node tests/test_uri_helper_parity.js && node tests/test_formats_single_pass.js && node tests/test_format_single_error.js && node tests/test_defs_pointer_alias.js && node tests/test_cross_doc_root_ref.js && node tests/test_codegen_entrypoint_agreement.js && node tests/test_hybrid_agreement.js && node tests/test_codegen_edge_shapes.js && node tests/test_ajv_errors.js && node tests/test_custom_keywords.js && node tests/test_aot_external_checks.js && node tests/test_ajv_parity.js && node tests/test_user_format_error_path.js && node tests/test_unevaluated_error_path.js && node tests/test_pattern_properties_errors.js && node tests/test_no_input_mutation.js && node tests/test_typed_validator_runner.js && node tests/test_define_schema.js && node tests/test_error_codes_lock.js && node tests/test_error_code_lookup.js && node tests/test_error_order.js && node tests/test_error_order_ordinal.js && node tests/test_rejection_shape.js && node tests/test_lazy_normalization.js && node tests/test_value_equality.js && node tests/test_vocabulary.js && node tests/test_schema_scan.js && node tests/test_lazy_errors.js && node tests/test_lazy_instance.js && node tests/test_cyclic_input.js && node tests/test_native_error_codes.js && node tests/test_verdict_preprocess.js && node tests/test_plan_compiler.js && node tests/test_nullable.js && node tests/test_validate_and_parse.js && node tests/test_validate_data.js && node tests/test_enrich_error.js && node tests/test_enrich_received.js && node tests/test_rich_errors_optout.js && node tests/test_error_messages.js && node tests/test_source_positions.js && node tests/fuzz_positions.js && node tests/test_data_positions.js && node tests/test_render_shared.js && node tests/test_renderers.js && node tests/test_additive_fields.js && node tests/test_diagnostic_source.js && node tests/test_diagnose.js && node tests/test_correlate.js && node tests/test_diagnostics_score.js && node tests/test_runtime_error_dx.js && node tests/test_aot_error_dx.js && node tests/test_abort_early.js && node tests/test_branch_collapse.js && node tests/test_suggestions.js && node tests/test_cli_validate.js && node tests/test_cli_version.js && node benchmark/bench_aot_size.mjs",
|
|
54
|
+
"test": "node test.js && node tests/test_removed_aot_methods.js && node tests/test_no_native.js && node tests/test_no_eval.js && node tests/test_property_dependencies.js && node tests/test_v1_dialect.js && node tests/test_buffer_path_parity.js && node tests/test_buffer_gate.js && node tests/test_buffer_reject_cost.js && node tests/test_nan_verdict.js && node tests/test_remove_additional_nested.js && node tests/test_exclusive_bounds.js && node tests/test_engine_differential.js && node tests/test_error_shape_differential.js && node tests/test_output_format.js && node tests/test_retry_message.js && node tests/test_describe_schema.js && node tests/test_aot_parse.js && node tests/test_draft7_semantics.js && node tests/test_metaschema_ref.js && node tests/test_pure_js_unsupported.js && node tests/test_native_load_order.js && node tests/test_pack_purity.js && node tests/test_make_native_package.js && node tests/test_browser_nofs.js && node tests/test_browser_imports_guard.js && node tests/test_version_sync.js && node tests/test_native_loaded.js && node tests/test_safe_regex_source_sync.js && node tests/test_t_builder.js && node tests/test_async_refine.js && node tests/test_safe_regex.js && node tests/test_safe_regex_integration.js && node tests/test_aot_build.js && node tests/test_aot_differential.js && node tests/test_aot_cli_build.js && node tests/test_aot_cli_smoke.js && node tests/test_bundle_standalone.js && node tests/test_standalone_anyof.js && node tests/test_standalone_formats.js && node tests/test_aot_format_mode.js && node tests/test_aot_additional_props_errors.js && node tests/test_id_anchor_refs.js && node tests/test_engine_routing.js && node tests/test_compile_cache_order.js && node tests/test_engine_diagnostic.js && node tests/test_format_engine_parity.js && node tests/test_uri_helper_parity.js && node tests/test_formats_single_pass.js && node tests/test_format_single_error.js && node tests/test_defs_pointer_alias.js && node tests/test_cross_doc_root_ref.js && node tests/test_codegen_entrypoint_agreement.js && node tests/test_hybrid_agreement.js && node tests/test_codegen_edge_shapes.js && node tests/test_ajv_errors.js && node tests/test_custom_keywords.js && node tests/test_strict_schema.js && node tests/test_aot_external_checks.js && node tests/test_ajv_parity.js && node tests/test_user_format_error_path.js && node tests/test_unevaluated_error_path.js && node tests/test_unevaluated_error_shape.js && node tests/test_pattern_properties_errors.js && node tests/test_additional_properties_scaling.js && node tests/test_no_input_mutation.js && node tests/test_typed_validator_runner.js && node tests/test_define_schema.js && node tests/test_error_codes_lock.js && node tests/test_error_code_lookup.js && node tests/test_error_order.js && node tests/test_error_order_ordinal.js && node tests/test_rejection_shape.js && node tests/test_lazy_normalization.js && node tests/test_value_equality.js && node tests/test_vocabulary.js && node tests/test_schema_scan.js && node tests/test_lazy_errors.js && node tests/test_lazy_instance.js && node tests/test_cyclic_input.js && node tests/test_native_error_codes.js && node tests/test_verdict_preprocess.js && node tests/test_plan_compiler.js && node tests/test_nullable.js && node tests/test_validate_and_parse.js && node tests/test_validate_data.js && node tests/test_enrich_error.js && node tests/test_enrich_received.js && node tests/test_rich_errors_optout.js && node tests/test_error_messages.js && node tests/test_source_positions.js && node tests/fuzz_positions.js && node tests/test_malformed_json_termination.js && node tests/test_data_positions.js && node tests/test_render_shared.js && node tests/test_renderers.js && node tests/test_additive_fields.js && node tests/test_diagnostic_source.js && node tests/test_diagnose.js && node tests/test_correlate.js && node tests/test_diagnostics_score.js && node tests/test_runtime_error_dx.js && node tests/test_aot_error_dx.js && node tests/test_abort_early.js && node tests/test_branch_collapse.js && node tests/test_suggestions.js && node tests/test_cli_validate.js && node tests/test_cli_version.js && node benchmark/bench_aot_size.mjs",
|
|
55
55
|
"bench:size": "node benchmark/bench_aot_size.mjs",
|
|
56
56
|
"test:suite": "node tests/run_suite.js && node tests/run_suite.js draft7 && node tests/run_suite.js v1",
|
|
57
57
|
"test:compat": "node tests/test_compat.js",
|
|
@@ -122,13 +122,13 @@
|
|
|
122
122
|
"LICENSE"
|
|
123
123
|
],
|
|
124
124
|
"optionalDependencies": {
|
|
125
|
-
"@ata-validator/native-darwin-arm64": "1.
|
|
126
|
-
"@ata-validator/native-darwin-x64": "1.
|
|
127
|
-
"@ata-validator/native-linux-arm64-gnu": "1.
|
|
128
|
-
"@ata-validator/native-linux-arm64-musl": "1.
|
|
129
|
-
"@ata-validator/native-linux-x64-gnu": "1.
|
|
130
|
-
"@ata-validator/native-linux-x64-musl": "1.
|
|
131
|
-
"@ata-validator/native-win32-x64": "1.
|
|
125
|
+
"@ata-validator/native-darwin-arm64": "1.23.0",
|
|
126
|
+
"@ata-validator/native-darwin-x64": "1.23.0",
|
|
127
|
+
"@ata-validator/native-linux-arm64-gnu": "1.23.0",
|
|
128
|
+
"@ata-validator/native-linux-arm64-musl": "1.23.0",
|
|
129
|
+
"@ata-validator/native-linux-x64-gnu": "1.23.0",
|
|
130
|
+
"@ata-validator/native-linux-x64-musl": "1.23.0",
|
|
131
|
+
"@ata-validator/native-win32-x64": "1.23.0"
|
|
132
132
|
},
|
|
133
133
|
"peerDependencies": {
|
|
134
134
|
"yaml": "^2.0.0"
|