ata-validator 1.25.0 → 1.27.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 +23 -0
- package/README.md +16 -14
- package/lib/aot-build.js +5 -3
- package/lib/aot-impl.js +196 -55
- package/lib/js-compiler.js +21 -18
- package/lib/ts-gen.js +2 -1
- package/lib/version.js +1 -1
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
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.27.0 - 2026-09-17
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `parse()` is now emitted for the record shape: `additionalProperties` as a schema, which is what `z.record` and every generator's map type compile to, with or without declared `properties` beside it. The key set is open by declaration, so keeping every key is exact, and every undeclared value is rebuilt against the one schema that governs it, stripping unknown keys inside it the same way a declared property's rebuild would. Defaults under a record value are not filled, because the runtime's `useDefaults` does not fill them there and `parse()` output stays equal to `validate().data`. Still declined, loudly: `additionalProperties: true` (the value is unconstrained, and this pass only copies what it can prove), applicators next to a record (a branch could constrain some keys' values beyond the record schema), and `patternProperties` (a key matching several patterns must satisfy all of them at once, which the rebuild cannot pick a shape for).
|
|
10
|
+
- `onWarning` now receives a second argument, `{ kind }`, naming which capability degraded: `'error-detail'` or `'parse'`. A build that requested both can tell the warnings apart instead of treating any warning as degraded errors. `build()` result warnings carry the same `kind` field. Existing single-argument callbacks keep working unchanged.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Two more fixed-name collisions in the generated-code path, the same family as the `patternProperties` helper-scope bug in 1.26.0: a record whose values are themselves records (`additionalProperties` nested in itself) generated an inner loop that redeclared the outer loop's variables and threw `ReferenceError: Cannot access '_av' before initialization` at validation time, and `patternProperties` inside a `patternProperties` value schema had the same collision on its own loop variables. All three loop names now carry a unique suffix. The combined error generator already did this; the boolean generator now matches it.
|
|
15
|
+
|
|
16
|
+
## 1.26.0 - 2026-09-17
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `toStandaloneModule(schema, { parse: true })` inlines local, acyclic `$ref`s before the clone proof runs, so schemas that keep their shapes in `$defs` and reference them, which is every schema a generator emits, now get a `parse()`. Only a node that is exactly a `$ref` plus annotations is inlined; a cycle, an external reference or a constraining sibling keyword leaves the reference in place and the clone declines as before. Defaults follow the runtime exactly: a `default` written next to the `$ref` fills, a `default` written inside the referenced definition does not, because `validate()` with `useDefaults` draws the same line and `parse()` must not be more generous than `validate()`. A new test holds `parse()` output equal to the runtime's `validate().data` on the same input.
|
|
21
|
+
- When `parse: true` is requested and the clone still cannot be proven, the decline is loud: `onWarning` fires with the reason, and the emitted module carries a NOTE comment saying it has no `parse` export. Previously the only way to notice was reading the export list.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- The runtime compiler built `patternProperties` (and combined `additionalProperties`) child validators with `new Function`, which discarded the parent's helper scope: a child schema that needed a compiled pattern or format helper threw `ReferenceError` at validation time. Child checks are now generated inline in the parent validator and share its helper bindings. Reported and fixed by @jdalton in #46.
|
|
26
|
+
- The TypeScript declarations emitted next to a compiled module now declare the `schemaHash` export; 1.25.0 added it to every module but not to the generated `.d.mts`, so importing it by name failed type checking while working at runtime.
|
|
27
|
+
|
|
5
28
|
## 1.25.0 - 2026-09-17
|
|
6
29
|
|
|
7
30
|
### Fixed
|
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ file.
|
|
|
60
60
|
libraries maintained outside this project. On its validation page, valid data, the run of
|
|
61
61
|
2026-09-13 against ata 1.14.1 puts ata first at 603 ns, with the next entry at 1.77 times
|
|
62
62
|
that. The same site puts ata last on the download page, at 64.9 KB gzipped, because the entry
|
|
63
|
-
it bundles is the runtime compiler; the module `ata build` emits for that schema is
|
|
63
|
+
it bundles is the runtime compiler; the module `ata build` emits for that schema is 5.1 KB
|
|
64
64
|
minified and gzipped, and a compiled entry for the harness is in preparation.
|
|
65
65
|
- [Bowtie](https://bowtie.report/), the cross-implementation JSON Schema test harness. ata's
|
|
66
66
|
harness runs Draft 2020-12 and draft 7 there; on the harness at ata 1.16.1 the official suite
|
|
@@ -71,17 +71,19 @@ file.
|
|
|
71
71
|
|
|
72
72
|
| Dimension | Schema | ata-AOT | runtime validator | Difference |
|
|
73
73
|
|---|---|---|---|---|
|
|
74
|
-
| Bundle (gzipped) | simple | 1.
|
|
75
|
-
| Bundle (gzipped) | complex |
|
|
76
|
-
| Bundle (gzipped) | nested |
|
|
74
|
+
| Bundle (gzipped) | simple | 1.1 KB | 52.7 KB | 48.1x smaller |
|
|
75
|
+
| Bundle (gzipped) | complex | 7.6 KB | 52.7 KB | 7.0x smaller |
|
|
76
|
+
| Bundle (gzipped) | nested | 4.1 KB | 52.7 KB | 13.0x smaller |
|
|
77
77
|
| Cold start | simple | 21 ms | 40 ms | 1.9x faster |
|
|
78
|
-
| Throughput (1M ops) | simple |
|
|
79
|
-
| Compile time | simple |
|
|
78
|
+
| Throughput (1M ops) | simple | 257 Mops/s | 114 Mops/s | 2.3x faster |
|
|
79
|
+
| Compile time | simple | 14 µs | 1.52 ms | 111x faster |
|
|
80
80
|
|
|
81
81
|
The runtime column is the default validator most frameworks ship. Reproduce on your machine
|
|
82
|
-
with `npm run bench:aot-vs-ajv`. Numbers from one run on Apple M4 Pro, Node 25.2.1, 2026-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
with `npm run bench:aot-vs-ajv`. Numbers from one run on Apple M4 Pro, Node 25.2.1, 2026-09-17,
|
|
83
|
+
on ata-validator 1.25.0, whose emitted modules carry full error detail and a schema hash, which
|
|
84
|
+
is where the growth over earlier 1.x module sizes comes from. Across three runs throughput moved
|
|
85
|
+
between 257 and 297 Mops/s and the compile ratio between 104x and 112x, so treat the last two
|
|
86
|
+
rows as an order of magnitude rather than a constant.
|
|
85
87
|
|
|
86
88
|
The wins are largest on bundle size and compile time because AOT moves work from runtime to
|
|
87
89
|
build time. Throughput and cold start are also faster because the compiled validator is a
|
|
@@ -209,19 +211,19 @@ is the most common way to get a misleading number out of this library.
|
|
|
209
211
|
|
|
210
212
|
| | compiled with `ata build` | runtime `new Validator(schema)` |
|
|
211
213
|
|---|---|---|
|
|
212
|
-
| In a bundle, gzipped | **
|
|
213
|
-
| Time to a served request | **3.
|
|
214
|
+
| In a bundle, gzipped | **4.5 KB** | 87.0 KB |
|
|
215
|
+
| Time to a served request | **3.4 ms** | 10.6 ms |
|
|
214
216
|
| Schema known when | build time | any time |
|
|
215
217
|
|
|
216
218
|
The bundle row is a ten-field user schema built with
|
|
217
219
|
`bun build --minify --target=browser`. The startup row is a Hono route on Bun 1.4, best
|
|
218
|
-
of seven, against 3.
|
|
220
|
+
of seven, against 3.7 ms for the same app doing no validation at all, so the compiled
|
|
219
221
|
path costs nothing measurable to start. The runtime
|
|
220
222
|
figure is what it is because a schema that arrives at run time can use any keyword, so
|
|
221
223
|
the whole engine has to be there. The compiled module imports nothing and contains only
|
|
222
224
|
the checks your schema asks for.
|
|
223
225
|
|
|
224
|
-
**On a server, use whichever fits your schemas.**
|
|
226
|
+
**On a server, use whichever fits your schemas.** 87 KB of JavaScript on a Node or Bun
|
|
225
227
|
process is not a cost anyone notices, and the runtime API is the simpler thing to reach
|
|
226
228
|
for. Speed is the same either way once warm.
|
|
227
229
|
|
|
@@ -450,7 +452,7 @@ const v = new Validator(schema, {
|
|
|
450
452
|
|
|
451
453
|
### Build-time compile (`ata compile`)
|
|
452
454
|
|
|
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
|
|
455
|
+
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 4.5 KB gzipped for a ten-field schema, full error detail included, against 87 KB for the runtime bundled for the browser.
|
|
454
456
|
|
|
455
457
|
```bash
|
|
456
458
|
npx ata compile schemas/user.json -o src/generated/user.validator.mjs
|
package/lib/aot-build.js
CHANGED
|
@@ -159,14 +159,16 @@ async function build(opts) {
|
|
|
159
159
|
sourceMap,
|
|
160
160
|
schemaFile,
|
|
161
161
|
formatMode: opts.formatMode,
|
|
162
|
-
onWarning: (w) => inputWarnings.push(w),
|
|
162
|
+
onWarning: (w, meta) => inputWarnings.push({ message: w, kind: (meta && meta.kind) || 'general' }),
|
|
163
163
|
});
|
|
164
164
|
if (inputWarnings.length > 0) {
|
|
165
165
|
if (opts.strict) {
|
|
166
|
-
failed.push({ input, error: inputWarnings.join('; ') });
|
|
166
|
+
failed.push({ input, error: inputWarnings.map((x) => x.message).join('; ') });
|
|
167
167
|
continue;
|
|
168
168
|
}
|
|
169
|
-
|
|
169
|
+
// `kind` says which capability degraded ('error-detail', 'parse'),
|
|
170
|
+
// so a caller that requested both can tell the warnings apart.
|
|
171
|
+
for (const x of inputWarnings) warnings.push({ input, warning: x.message, kind: x.kind });
|
|
170
172
|
}
|
|
171
173
|
if (!src) {
|
|
172
174
|
const reason = 'schema is not AOT-compatible (toStandaloneModule returned null)';
|
package/lib/aot-impl.js
CHANGED
|
@@ -199,6 +199,74 @@ function emitFormatDecls(closures, mode, declKW) {
|
|
|
199
199
|
// Returns null when the clone cannot be proven exact, and the module then
|
|
200
200
|
// simply has no parse(). Declining is recoverable; a sanitiser that quietly
|
|
201
201
|
// drops a property the schema allows is not.
|
|
202
|
+
// Resolve a local JSON pointer ('#', '#/$defs/x', draft-7 '#/definitions/x',
|
|
203
|
+
// any '#/...' path) against the schema root. Returns null for anything else:
|
|
204
|
+
// external documents, anchors, URNs. Those stay with the runtime engines.
|
|
205
|
+
function resolveLocalPointer(root, ref) {
|
|
206
|
+
if (ref === '#') return root;
|
|
207
|
+
if (typeof ref !== 'string' || !ref.startsWith('#/')) return null;
|
|
208
|
+
let cur = root;
|
|
209
|
+
for (const raw of ref.slice(2).split('/')) {
|
|
210
|
+
let seg = raw.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
211
|
+
if (cur === null || typeof cur !== 'object') return null;
|
|
212
|
+
if (!Object.prototype.hasOwnProperty.call(cur, seg)) {
|
|
213
|
+
try { seg = decodeURIComponent(seg); } catch (_) { return null; }
|
|
214
|
+
if (!Object.prototype.hasOwnProperty.call(cur, seg)) return null;
|
|
215
|
+
}
|
|
216
|
+
cur = cur[seg];
|
|
217
|
+
}
|
|
218
|
+
return cur;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Inline local, acyclic $refs before the clone proof runs, so a generated
|
|
222
|
+
// schema ($defs + $ref, the shape every schema generator emits) can still get
|
|
223
|
+
// a parse(). Only a node that is exactly a $ref plus annotations is replaced;
|
|
224
|
+
// a $ref with a constraining sibling, a cycle, an unresolvable target or an
|
|
225
|
+
// external reference is left in place, and emitClone declines it as before.
|
|
226
|
+
// The walk never mutates its input and never descends into data positions
|
|
227
|
+
// (default, const, enum, examples), where an object holding a "$ref" key is
|
|
228
|
+
// a value, not a schema.
|
|
229
|
+
const _INLINE_ANNOTATIONS = new Set(['$ref', 'title', 'description', '$comment', 'examples', 'deprecated', 'readOnly', 'writeOnly', 'default']);
|
|
230
|
+
const _INLINE_DATA_KEYS = new Set(['default', 'const', 'enum', 'examples']);
|
|
231
|
+
function inlineRefsForClone(root) {
|
|
232
|
+
const state = { budget: 512 };
|
|
233
|
+
// `inRef` marks content brought in from behind a reference. The runtime's
|
|
234
|
+
// useDefaults fills a default written next to the $ref, and does not fill
|
|
235
|
+
// defaults written inside the referenced definition, so the inlined copy
|
|
236
|
+
// keeps the first and drops the second. parse() must stay exactly as
|
|
237
|
+
// generous as validate(); a parse that fills more is a disagreement, not a
|
|
238
|
+
// feature.
|
|
239
|
+
const walk = (node, active, inRef) => {
|
|
240
|
+
if (!node || typeof node !== 'object') return node;
|
|
241
|
+
if (Array.isArray(node)) return node.map((n) => walk(n, active, inRef));
|
|
242
|
+
let cur = node;
|
|
243
|
+
let entered = false;
|
|
244
|
+
let siblingDefault;
|
|
245
|
+
while (cur && typeof cur === 'object' && !Array.isArray(cur) && typeof cur.$ref === 'string') {
|
|
246
|
+
for (const k of Object.keys(cur)) if (!_INLINE_ANNOTATIONS.has(k)) return node;
|
|
247
|
+
if (active.has(cur.$ref) || state.budget-- <= 0) return node;
|
|
248
|
+
const target = resolveLocalPointer(root, cur.$ref);
|
|
249
|
+
if (!target || typeof target !== 'object' || Array.isArray(target)) return node;
|
|
250
|
+
if (!entered && !inRef && cur.default !== undefined) siblingDefault = cur.default;
|
|
251
|
+
active = new Set(active);
|
|
252
|
+
active.add(cur.$ref);
|
|
253
|
+
cur = target;
|
|
254
|
+
entered = true;
|
|
255
|
+
}
|
|
256
|
+
const strip = inRef || entered;
|
|
257
|
+
const out = {};
|
|
258
|
+
for (const k of Object.keys(cur)) {
|
|
259
|
+
if (strip && k === 'default') continue;
|
|
260
|
+
const v = cur[k];
|
|
261
|
+
if (_INLINE_DATA_KEYS.has(k) || k === '$defs' || k === 'definitions') { out[k] = v; continue; }
|
|
262
|
+
out[k] = walk(v, active, strip);
|
|
263
|
+
}
|
|
264
|
+
if (siblingDefault !== undefined) out.default = siblingDefault;
|
|
265
|
+
return out;
|
|
266
|
+
};
|
|
267
|
+
return walk(root, new Set(), false);
|
|
268
|
+
}
|
|
269
|
+
|
|
202
270
|
function emitClone(node, access, depth) {
|
|
203
271
|
if (!node || typeof node !== 'object') return null;
|
|
204
272
|
if (depth > 12) return null;
|
|
@@ -211,15 +279,35 @@ function emitClone(node, access, depth) {
|
|
|
211
279
|
// never widen the key set. `not` never widens it. `unevaluatedProperties`
|
|
212
280
|
// is admitted only as `false` under that proof, where it is exactly
|
|
213
281
|
// `additionalProperties: false`.
|
|
214
|
-
if (node.$ref || node.patternProperties ||
|
|
215
|
-
node.additionalProperties === true ||
|
|
216
|
-
(node.additionalProperties && typeof node.additionalProperties === 'object')) {
|
|
282
|
+
if (node.$ref || node.patternProperties || node.additionalProperties === true) {
|
|
217
283
|
return null;
|
|
218
284
|
}
|
|
285
|
+
// additionalProperties as a schema is the record shape (z.record and every
|
|
286
|
+
// generator's map type): the key set is open by declaration, so keeping
|
|
287
|
+
// every key is exact, and every undeclared value is rebuilt against that
|
|
288
|
+
// one schema. That is a proof, not a guess, so it is admitted. What stays
|
|
289
|
+
// out: applicators next to a record (a branch could constrain some keys'
|
|
290
|
+
// values beyond the record schema, and the rebuild would ignore it), and
|
|
291
|
+
// `additionalProperties: true`, where the value is unconstrained and this
|
|
292
|
+
// pass only copies what it can name. An open object with no
|
|
293
|
+
// `additionalProperties` at all also stays out, for the original reason:
|
|
294
|
+
// validate() accepts its unknown keys, so a parse() that strips them would
|
|
295
|
+
// silently drop allowed data, and one that keeps them raw would not be a
|
|
296
|
+
// sanitiser.
|
|
297
|
+
const apSchema = (node.additionalProperties && typeof node.additionalProperties === 'object')
|
|
298
|
+
? node.additionalProperties
|
|
299
|
+
: null;
|
|
300
|
+
if (apSchema) {
|
|
301
|
+
if (node.unevaluatedProperties !== undefined) return null;
|
|
302
|
+
if (node.allOf || node.anyOf || node.oneOf || node.if || node.then || node.else ||
|
|
303
|
+
node.dependentSchemas !== undefined || node.dependencies !== undefined ||
|
|
304
|
+
node.$dynamicRef !== undefined || node.$recursiveRef !== undefined) return null;
|
|
305
|
+
}
|
|
219
306
|
if (node.unevaluatedProperties !== undefined && node.unevaluatedProperties !== false) return null;
|
|
220
|
-
if (node.type !== 'object'
|
|
221
|
-
|
|
222
|
-
|
|
307
|
+
if (node.type !== 'object') return null;
|
|
308
|
+
if (!node.properties && !apSchema) return null;
|
|
309
|
+
const keys = node.properties ? Object.keys(node.properties) : [];
|
|
310
|
+
if (keys.length === 0 && !apSchema) return null;
|
|
223
311
|
if (node.allOf || node.anyOf || node.oneOf || node.if || node.then || node.else ||
|
|
224
312
|
node.unevaluatedProperties === false) {
|
|
225
313
|
if (node.$dynamicRef !== undefined || node.$recursiveRef !== undefined ||
|
|
@@ -239,13 +327,6 @@ function emitClone(node, access, depth) {
|
|
|
239
327
|
}
|
|
240
328
|
const required = new Set(Array.isArray(node.required) ? node.required : []);
|
|
241
329
|
|
|
242
|
-
// A property is copyable only when the emitter can name everything that
|
|
243
|
-
// may live under it: a primitive holds nothing, and an object with declared
|
|
244
|
-
// properties is rebuilt the same way. A $ref, a composition, an array of
|
|
245
|
-
// objects or an untyped node could all carry keys this code cannot see, and
|
|
246
|
-
// copying the reference would smuggle them into a value that claims to be
|
|
247
|
-
// sanitised, so the whole clone is declined instead.
|
|
248
|
-
const PRIMITIVE = new Set(['string', 'number', 'integer', 'boolean', 'null']);
|
|
249
330
|
const fixed = [];
|
|
250
331
|
const conditional = [];
|
|
251
332
|
for (const key of keys) {
|
|
@@ -268,47 +349,41 @@ function emitClone(node, access, depth) {
|
|
|
268
349
|
if (!probe || !probe(prop.default)) return null;
|
|
269
350
|
}
|
|
270
351
|
const read = `${access}[${JSON.stringify(key)}]`;
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (types.every((t) => PRIMITIVE.has(t))) {
|
|
274
|
-
value = read;
|
|
275
|
-
} else if (prop.type === 'object' && prop.properties) {
|
|
276
|
-
const nested = emitClone(prop, read, depth + 1);
|
|
277
|
-
if (!nested) return null;
|
|
278
|
-
// The nested schema may also admit null, in which case there is nothing
|
|
279
|
-
// to rebuild and the value passes through.
|
|
280
|
-
value = required.has(key) ? nested : nested;
|
|
281
|
-
} else if (prop.type === 'array' && prop.items && typeof prop.items === 'object' &&
|
|
282
|
-
!Array.isArray(prop.items) &&
|
|
283
|
-
(Array.isArray(prop.items.type) ? prop.items.type : [prop.items.type])
|
|
284
|
-
.every((t) => PRIMITIVE.has(t))) {
|
|
285
|
-
// An array of primitives has nowhere to hide a key. The reference is
|
|
286
|
-
// shared with the input, exactly as a shallow strip would leave it.
|
|
287
|
-
value = read;
|
|
288
|
-
} else if (prop.type === 'array' && prop.items && typeof prop.items === 'object' &&
|
|
289
|
-
!Array.isArray(prop.items) && prop.items.type === 'object' &&
|
|
290
|
-
prop.items.properties) {
|
|
291
|
-
// An array of objects: rebuild each element the same way, so unknown
|
|
292
|
-
// keys inside the array are dropped too. Most real schemas have one of
|
|
293
|
-
// these, and declining them left parse() emitted only for shapes nobody
|
|
294
|
-
// writes.
|
|
295
|
-
const el = '_e' + depth;
|
|
296
|
-
const inner = emitClone(prop.items, el, depth + 1);
|
|
297
|
-
if (!inner) return null;
|
|
298
|
-
value = `${read}.map((${el}) => (${inner}))`;
|
|
299
|
-
} else {
|
|
300
|
-
return null;
|
|
301
|
-
}
|
|
352
|
+
const value = emitValueExpr(prop, read, depth);
|
|
353
|
+
if (value === null) return null;
|
|
302
354
|
if (required.has(key)) fixed.push(`${JSON.stringify(key)}: ${value}`);
|
|
303
355
|
else conditional.push({ key, value, dflt: prop.default });
|
|
304
356
|
}
|
|
305
|
-
if (fixed.length === 0 && conditional.length === 0) return null;
|
|
357
|
+
if (fixed.length === 0 && conditional.length === 0 && !apSchema) return null;
|
|
358
|
+
|
|
359
|
+
const tmp = `_c${depth}`;
|
|
360
|
+
// The record loop: every key of the input that is not a declared property
|
|
361
|
+
// is kept, its value rebuilt against the additionalProperties schema. The
|
|
362
|
+
// input is already validated, so every such value satisfies that schema;
|
|
363
|
+
// the rebuild only strips inside it. Defaults under a record value are
|
|
364
|
+
// dropped before emission because the runtime's useDefaults does not fill
|
|
365
|
+
// them there, and parse() must equal validate().data, not improve on it.
|
|
366
|
+
let recordLoop = '';
|
|
367
|
+
if (apSchema) {
|
|
368
|
+
const kVar = `_k${depth}`;
|
|
369
|
+
const vVar = `_v${depth}`;
|
|
370
|
+
const valueExpr = emitValueExpr(stripDefaultsDeep(apSchema), vVar, depth);
|
|
371
|
+
if (valueExpr === null) return null;
|
|
372
|
+
let skip = '';
|
|
373
|
+
let declSet = '';
|
|
374
|
+
if (keys.length > 8) {
|
|
375
|
+
declSet = `const _d${depth} = new Set(${JSON.stringify(keys)}); `;
|
|
376
|
+
skip = `if (_d${depth}.has(${kVar})) continue; `;
|
|
377
|
+
} else if (keys.length > 0) {
|
|
378
|
+
skip = `if (${keys.map((k) => `${kVar} === ${JSON.stringify(k)}`).join(' || ')}) continue; `;
|
|
379
|
+
}
|
|
380
|
+
recordLoop = ` ${declSet}for (const ${kVar} of Object.keys(${access})) { ${skip}const ${vVar} = ${access}[${kVar}]; ${tmp}[${kVar}] = ${valueExpr}; }`;
|
|
381
|
+
}
|
|
306
382
|
|
|
307
383
|
const literal = `{ ${fixed.join(', ')} }`;
|
|
308
|
-
if (conditional.length === 0) return literal;
|
|
384
|
+
if (conditional.length === 0 && !recordLoop) return literal;
|
|
309
385
|
// Optional properties are added only when present, so the result never
|
|
310
386
|
// gains a key the input did not have.
|
|
311
|
-
const tmp = `_c${depth}`;
|
|
312
387
|
const adds = conditional
|
|
313
388
|
.map(({ key, value, dflt }) => {
|
|
314
389
|
const set = `if (${JSON.stringify(key)} in ${access}) ${tmp}[${JSON.stringify(key)}] = ${value};`;
|
|
@@ -317,7 +392,55 @@ function emitClone(node, access, depth) {
|
|
|
317
392
|
return dflt === undefined ? set : `${set} else ${tmp}[${JSON.stringify(key)}] = ${JSON.stringify(dflt)};`;
|
|
318
393
|
})
|
|
319
394
|
.join(' ');
|
|
320
|
-
return `(function(){ const ${tmp} = ${literal}; ${adds} return ${tmp}; })()`;
|
|
395
|
+
return `(function(){ const ${tmp} = ${literal}; ${adds}${recordLoop} return ${tmp}; })()`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// The value dispatch shared by declared properties and the record loop. A
|
|
399
|
+
// value is copyable only when the emitter can name everything that may live
|
|
400
|
+
// under it: a primitive holds nothing, an object with declared properties or
|
|
401
|
+
// a record schema is rebuilt the same way, an array of primitives has
|
|
402
|
+
// nowhere to hide a key. A $ref, a composition or an untyped node could all
|
|
403
|
+
// carry keys this code cannot see, and copying the reference would smuggle
|
|
404
|
+
// them into a value that claims to be sanitised, so the caller declines the
|
|
405
|
+
// whole clone instead.
|
|
406
|
+
// Drop every schema-position `default` in a subtree, leaving data positions
|
|
407
|
+
// (const, enum, examples) untouched. Used on a record's value schema, where
|
|
408
|
+
// the runtime's useDefaults fills nothing.
|
|
409
|
+
function stripDefaultsDeep(node) {
|
|
410
|
+
if (!node || typeof node !== 'object') return node;
|
|
411
|
+
if (Array.isArray(node)) return node.map(stripDefaultsDeep);
|
|
412
|
+
const out = {};
|
|
413
|
+
for (const k of Object.keys(node)) {
|
|
414
|
+
if (k === 'default') continue;
|
|
415
|
+
out[k] = _INLINE_DATA_KEYS.has(k) ? node[k] : stripDefaultsDeep(node[k]);
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const _CLONE_PRIMITIVE = new Set(['string', 'number', 'integer', 'boolean', 'null']);
|
|
421
|
+
function emitValueExpr(prop, read, depth) {
|
|
422
|
+
if (!prop || typeof prop !== 'object') return null;
|
|
423
|
+
if (prop.$ref) return null;
|
|
424
|
+
const types = Array.isArray(prop.type) ? prop.type : [prop.type];
|
|
425
|
+
if (types.every((t) => _CLONE_PRIMITIVE.has(t))) return read;
|
|
426
|
+
const isRecordable = (s) => s.properties ||
|
|
427
|
+
(s.additionalProperties && typeof s.additionalProperties === 'object');
|
|
428
|
+
if (prop.type === 'object' && isRecordable(prop)) {
|
|
429
|
+
return emitClone(prop, read, depth + 1);
|
|
430
|
+
}
|
|
431
|
+
if (prop.type === 'array' && prop.items && typeof prop.items === 'object' &&
|
|
432
|
+
!Array.isArray(prop.items)) {
|
|
433
|
+
const it = prop.items;
|
|
434
|
+
const itemTypes = Array.isArray(it.type) ? it.type : [it.type];
|
|
435
|
+
if (itemTypes.every((t) => _CLONE_PRIMITIVE.has(t))) return read;
|
|
436
|
+
if (it.type === 'object' && isRecordable(it)) {
|
|
437
|
+
const el = '_e' + depth;
|
|
438
|
+
const inner = emitClone(it, el, depth + 1);
|
|
439
|
+
if (!inner) return null;
|
|
440
|
+
return `${read}.map((${el}) => (${inner}))`;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
321
444
|
}
|
|
322
445
|
|
|
323
446
|
function toStandaloneModule(validator, opts) {
|
|
@@ -351,8 +474,12 @@ function toStandaloneModule(validator, opts) {
|
|
|
351
474
|
// way the runtime validator does. The module still ships, with the
|
|
352
475
|
// verdict exact, but every failure reports the single ATA9000 stub.
|
|
353
476
|
// Silence here cost a user a debugging session; hence the channel.
|
|
477
|
+
// The second argument names which capability degraded, so a build that
|
|
478
|
+
// requested several can tell this warning from a parse decline instead
|
|
479
|
+
// of treating any warning as "errors degraded".
|
|
354
480
|
opts.onWarning(
|
|
355
|
-
'error detail could not be generated for this schema; the module reports failures as the single ATA9000 abort-early error. The verdict is unaffected. For detailed errors, validate failing documents with the runtime Validator.'
|
|
481
|
+
'error detail could not be generated for this schema; the module reports failures as the single ATA9000 abort-early error. The verdict is unaffected. For detailed errors, validate failing documents with the runtime Validator.',
|
|
482
|
+
{ kind: 'error-detail' }
|
|
356
483
|
);
|
|
357
484
|
}
|
|
358
485
|
}
|
|
@@ -422,11 +549,22 @@ function toStandaloneModule(validator, opts) {
|
|
|
422
549
|
// schema declares. Off by default: it costs bytes in every emitted module,
|
|
423
550
|
// and the reason to compile a schema ahead of time is usually to ship as
|
|
424
551
|
// little as possible. Ask for it with { parse: true }.
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
552
|
+
// Local acyclic $refs are inlined first, so the schemas generators emit
|
|
553
|
+
// ($defs + $ref everywhere) still get a parse(). When the clone is still
|
|
554
|
+
// not provable, the decline is loud: the caller asked for parse and is not
|
|
555
|
+
// getting it, and discovering that by reading the export list cost a user
|
|
556
|
+
// an afternoon. Same channel as the error-detail decline above.
|
|
557
|
+
let cloneExpr = null;
|
|
558
|
+
if (opts && opts.parse) {
|
|
559
|
+
const baseSchema = typeof validator._schemaObj === 'object' ? validator._schemaObj : null;
|
|
560
|
+
cloneExpr = baseSchema ? emitClone(inlineRefsForClone(baseSchema), 'data', 0) : null;
|
|
561
|
+
if (!cloneExpr && typeof opts.onWarning === 'function') {
|
|
562
|
+
opts.onWarning(
|
|
563
|
+
'parse() could not be generated for this schema: the rebuild is only emitted where the allowed key set is provable, and a remaining $ref (cyclic, external, or carrying constraining siblings), patternProperties, applicators next to an open key set, or an unconstrained additionalProperties makes it someone else\'s decision. The module ships without a parse export; validate and strip with the runtime Validator instead.',
|
|
564
|
+
{ kind: 'parse' }
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
430
568
|
// Named _ataParse rather than parse: the emitted module also carries the
|
|
431
569
|
// safe-regex prelude, which has a module-scope parse() of its own for
|
|
432
570
|
// reading patterns. A second declaration of that name shadowed it and the
|
|
@@ -483,9 +621,12 @@ function validateJSON(text) {
|
|
|
483
621
|
? `export { ${names}${parseAlias} };\nexport default { ${names}${parseProp} };\n`
|
|
484
622
|
: `module.exports = { ${names}${parseProp} };\nmodule.exports.default = module.exports;\n`;
|
|
485
623
|
|
|
486
|
-
|
|
624
|
+
let degradedNote = (!abortEarly && !errCore)
|
|
487
625
|
? '// NOTE: error detail was requested but could not be generated for this\n// schema; failures report the single ATA9000 abort-early error. The verdict\n// is exact. Validate failing documents with the runtime Validator for detail.\n'
|
|
488
626
|
: '';
|
|
627
|
+
if (opts && opts.parse && !cloneExpr) {
|
|
628
|
+
degradedNote += '// NOTE: parse() was requested but could not be generated for this schema;\n// the module has no parse export. Validate and strip with the runtime Validator.\n';
|
|
629
|
+
}
|
|
489
630
|
return `// Auto-generated by ata-validator — do not edit.
|
|
490
631
|
// Schema is embedded; runtime has zero dependency on ata-validator.
|
|
491
632
|
${degradedNote}'use strict';
|
package/lib/js-compiler.js
CHANGED
|
@@ -2201,17 +2201,24 @@ function genCode(schema, v, lines, ctx, knownType) {
|
|
|
2201
2201
|
// against that sub-schema. Skip if patternProperties is present (handled by
|
|
2202
2202
|
// the unified loop). Composition cases are filtered out by codegenSafe.
|
|
2203
2203
|
if (typeof schema.additionalProperties === 'object' && schema.additionalProperties !== null && !schema.patternProperties) {
|
|
2204
|
+
// The loop variables carry a unique suffix: a record whose values are
|
|
2205
|
+
// themselves records nests this loop inside itself, and a fixed `_av`
|
|
2206
|
+
// made the inner `const _av=_av[_k]` a self-reference that threw at
|
|
2207
|
+
// validation time.
|
|
2208
|
+
const apId = ctx._apLoopId = (ctx._apLoopId || 0) + 1
|
|
2209
|
+
const kVar = `_k${apId}`
|
|
2210
|
+
const avVar = `_av${apId}`
|
|
2204
2211
|
const declared = schema.properties ? Object.keys(schema.properties) : []
|
|
2205
2212
|
const skipCheck = declared.length === 0
|
|
2206
2213
|
? null
|
|
2207
|
-
: declared.map(k =>
|
|
2214
|
+
: declared.map(k => `${kVar}===${JSON.stringify(k)}`).join('||')
|
|
2208
2215
|
const subLines = []
|
|
2209
|
-
genCode(schema.additionalProperties,
|
|
2216
|
+
genCode(schema.additionalProperties, avVar, subLines, ctx)
|
|
2210
2217
|
if (subLines.length > 0) {
|
|
2211
2218
|
const body = subLines.join(';')
|
|
2212
2219
|
const loop = skipCheck
|
|
2213
|
-
? `for(var
|
|
2214
|
-
: `for(var
|
|
2220
|
+
? `for(var ${kVar} in ${v}){if(${skipCheck})continue;const ${avVar}=${v}[${kVar}];${body}}`
|
|
2221
|
+
: `for(var ${kVar} in ${v}){const ${avVar}=${v}[${kVar}];${body}}`
|
|
2215
2222
|
_deferOrInline(ctx, lines, v, isObj ? loop : `if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v})){${loop}}`)
|
|
2216
2223
|
}
|
|
2217
2224
|
}
|
|
@@ -2246,15 +2253,13 @@ function genCode(schema, v, lines, ctx, knownType) {
|
|
|
2246
2253
|
}
|
|
2247
2254
|
}
|
|
2248
2255
|
|
|
2249
|
-
// Build sub-schema
|
|
2256
|
+
// Build sub-schema checks inline so they share the parent helper scope.
|
|
2257
|
+
const subChecks = []
|
|
2250
2258
|
for (let i = 0; i < ppEntries.length; i++) {
|
|
2251
2259
|
const [, sub] = ppEntries[i]
|
|
2252
2260
|
const subLines = []
|
|
2253
|
-
genCode(sub, `_ppv`, subLines, ctx)
|
|
2254
|
-
|
|
2255
|
-
const fnVar = `_ppf${pi}_${i}`
|
|
2256
|
-
ctx.closureVars.push(fnVar)
|
|
2257
|
-
ctx.closureVals.push(new Function('_ppv', fnBody))
|
|
2261
|
+
genCode(sub, `_ppv${pi}`, subLines, ctx)
|
|
2262
|
+
subChecks.push(subLines.join(';'))
|
|
2258
2263
|
}
|
|
2259
2264
|
|
|
2260
2265
|
const guard = isObj ? '' : `if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v}))`
|
|
@@ -2268,13 +2273,11 @@ function genCode(schema, v, lines, ctx, knownType) {
|
|
|
2268
2273
|
ctx._ppHandledAdditional = true
|
|
2269
2274
|
ctx._ppHandledPropertyNames = !!pn
|
|
2270
2275
|
const propKeys = Object.keys(schema.properties || {})
|
|
2271
|
-
let
|
|
2276
|
+
let apCheck = null
|
|
2272
2277
|
if (apSchema) {
|
|
2273
2278
|
const apLines = []
|
|
2274
|
-
genCode(apSchema,
|
|
2275
|
-
|
|
2276
|
-
ctx.closureVars.push(apFn)
|
|
2277
|
-
ctx.closureVals.push(new Function('_apv', apLines.length === 0 ? 'return true' : `${apLines.join(';')};return true`))
|
|
2279
|
+
genCode(apSchema, `_apv${pi}`, apLines, ctx)
|
|
2280
|
+
apCheck = apLines.join(';')
|
|
2278
2281
|
}
|
|
2279
2282
|
lines.push(`${guard}{for(const ${kVar} in ${v}){`)
|
|
2280
2283
|
// propertyNames checks (merged into same loop)
|
|
@@ -2308,14 +2311,14 @@ function genCode(schema, v, lines, ctx, knownType) {
|
|
|
2308
2311
|
if (ppEntries.length > 0) {
|
|
2309
2312
|
lines.push(`let _pm${pi}=false`)
|
|
2310
2313
|
for (let i = 0; i < ppEntries.length; i++) {
|
|
2311
|
-
lines.push(`if(${matchers[i].check}){_pm${pi}=true;
|
|
2314
|
+
lines.push(`if(${matchers[i].check}){_pm${pi}=true;const _ppv${pi}=${v}[${kVar}];${subChecks[i]}}`)
|
|
2312
2315
|
}
|
|
2313
2316
|
}
|
|
2314
2317
|
// A key that is neither declared nor matched is additional. switch on
|
|
2315
2318
|
// the declared names (V8 compiles string cases to a jump table); no
|
|
2316
2319
|
// switch at all when nothing is declared, since a switch with no case
|
|
2317
2320
|
// clause is a syntax error.
|
|
2318
|
-
const additional =
|
|
2321
|
+
const additional = apCheck !== null ? `const _apv${pi}=${v}[${kVar}];${apCheck}` : `return false`
|
|
2319
2322
|
const notMatched = ppEntries.length > 0 ? `if(!_pm${pi}){${additional}}` : additional
|
|
2320
2323
|
if (propKeys.length) {
|
|
2321
2324
|
const switchCases = propKeys.map(k => `case ${JSON.stringify(k)}:`).join('')
|
|
@@ -2352,7 +2355,7 @@ function genCode(schema, v, lines, ctx, knownType) {
|
|
|
2352
2355
|
}
|
|
2353
2356
|
}
|
|
2354
2357
|
for (let i = 0; i < ppEntries.length; i++) {
|
|
2355
|
-
lines.push(`if(${matchers[i].check}
|
|
2358
|
+
lines.push(`if(${matchers[i].check}){const _ppv${pi}=${v}[${kVar}];${subChecks[i]}}`)
|
|
2356
2359
|
}
|
|
2357
2360
|
lines.push(`}}`)
|
|
2358
2361
|
}
|
package/lib/ts-gen.js
CHANGED
|
@@ -238,7 +238,8 @@ export type Result = ValidResult | InvalidResult;
|
|
|
238
238
|
|
|
239
239
|
export declare function isValid(data: unknown): data is ${rootName};
|
|
240
240
|
export declare function validate(data: unknown): Result;
|
|
241
|
-
declare const
|
|
241
|
+
export declare const schemaHash: string;
|
|
242
|
+
declare const _default: { validate: typeof validate; isValid: typeof isValid; schemaHash: typeof schemaHash };
|
|
242
243
|
export default _default;
|
|
243
244
|
`;
|
|
244
245
|
}
|
package/lib/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ata-validator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
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",
|
|
@@ -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.27.0",
|
|
126
|
+
"@ata-validator/native-darwin-x64": "1.27.0",
|
|
127
|
+
"@ata-validator/native-linux-arm64-gnu": "1.27.0",
|
|
128
|
+
"@ata-validator/native-linux-arm64-musl": "1.27.0",
|
|
129
|
+
"@ata-validator/native-linux-x64-gnu": "1.27.0",
|
|
130
|
+
"@ata-validator/native-linux-x64-musl": "1.27.0",
|
|
131
|
+
"@ata-validator/native-win32-x64": "1.27.0"
|
|
132
132
|
},
|
|
133
133
|
"peerDependencies": {
|
|
134
134
|
"yaml": "^2.0.0"
|