ata-validator 1.24.0 → 1.26.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 +27 -0
- package/README.md +16 -14
- package/bin/ata.js +7 -1
- package/build.d.ts +42 -5
- package/compat.d.ts +8 -0
- package/compat.js +20 -0
- package/index.d.ts +12 -1
- package/lib/aot-build.js +13 -0
- package/lib/aot-impl.js +231 -45
- package/lib/data-positions.js +6 -3
- package/lib/js-compiler.js +211 -20
- package/lib/schema-hash.js +39 -0
- package/lib/ts-gen.js +2 -1
- package/lib/version.js +1 -1
- package/package.json +9 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
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.26.0 - 2026-09-17
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `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.
|
|
10
|
+
- 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.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- 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.
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
## 1.25.0 - 2026-09-17
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Emitted standalone modules compiled their regex patterns on every call. The compiler stores each pattern both as a closure variable and as an inline `const _reN = __ataSafeRe(...)` in the stored source; in process the inline line never runs (the closure binds the compiled matcher once), but every emitter placed it inside the emitted function, where it shadowed the module-scope binding and rebuilt the matcher per call. A schema with one `pattern` validated at 202 thousand ops/s as a standalone module while the in-process path ran at millions. Patterns are now declared once at load scope in all four emitters (`toStandalone`, `toStandaloneModule`, `bundleStandalone`, `bundleCompact`); the same document on the same schema now measures 27.0 million ops/s, 134x. `bundleCompact` needed care: its deduplication keyed on body text alone, and with the pattern no longer in the body, two schemas differing only in their pattern would have merged, so the key now includes the factory-scope declarations.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Provably local `unevaluated*` compiles real error detail on the generated-code path, and therefore in standalone modules. When every occurrence of `unevaluatedProperties`/`unevaluatedItems` sits on a node with no `$ref` and no `patternProperties`, and every in-place applicator (`allOf`, `anyOf`, `oneOf`, `if`/`then`/`else`) contributes only property names the node's own `properties` already declares, the keyword is exactly `additionalProperties`/`items` in disguise and both the error and combined generators now emit it as such, keeping the keyword's own identity: one error per stray key with `params.unevaluatedProperty` (ATA7003), one error with `params.limit` for extra items. `toStandaloneModule` with error detail requested no longer degrades on these schemas; a config schema of the common `if/then/else` plus root `unevaluatedProperties: false` shape ships an AOT module with the same per-field errors the runtime reports. Schemas where the annotations cannot be proven local (`unevaluated*` next to a `$ref`, cousin-visible shapes) still decline, loudly, as before. Verified by the error-shape differential (2,816 cases), the engine differential (18,126 cases) and the official suite on all three dialects.
|
|
26
|
+
- `toStandaloneModule(schema, { positions: true })` also exports `validateJSON(text)`: parse, validate, and on failure attach a `dataFrame` (`byteOffset`, `length`, `line`, `col`, `text`) to every error by walking the original text once. The mapping goes through `instancePath`, so a key that appears in several sections frames each error on its own occurrence, which a first-occurrence string search does not. A syntax error is the single ATA9001 error with a frame on the document. The walker is the runtime's own `buildDataPositionMap`, embedded verbatim so the two cannot drift, and it costs about 5 KB in the module (1.7 KB gzipped), which is why it is opt-in.
|
|
27
|
+
- `toStandaloneModule(schema, { parse: true })`'s `parse(data)` now fills declared `default` values for absent optional properties (object and array defaults are fresh per call) and is emitted for composed schemas under the same proof the `unevaluated*` error generators use: in-place applicators are admitted when every property name they mention is already declared in the node's own `properties`. The common config shape, `if`/`then`/`else` over declared fields with root `unevaluatedProperties: false`, now gets a `parse` that enforces the conditional, rejects undeclared keys, and returns the ready object. Two default shapes decline because they are where `parse` and the runtime would disagree: a required property with a default, and a default the property's own schema rejects.
|
|
28
|
+
- Every emitted module exports `schemaHash`, a 16 character content hash of the schema it was compiled from, over canonical JSON so key order does not matter, computed from the schema as the caller wrote it so dialect normalization does not break the comparison. `schemaHash(schema)` from `ata-validator/build` computes the same value; a build detects a stale artifact by comparing the two instead of embedding its own fingerprint.
|
|
29
|
+
- The agreement between compiled modules and the runtime is now a stated contract, in `docs/STABILITY.md` and the API reference: same version, same verdict on every document, enforced by the differential test in every CI run, with the emitters declining or degrading loudly where they cannot prove equivalence.
|
|
30
|
+
- The compat shim exports `attachDataFrames(errors, text)`: the same one-pass mapping for Ajv-shaped errors, for callers that keep the source text around. Not an Ajv API; it exists because hand-written line finders based on a string search get repeated keys wrong.
|
|
31
|
+
|
|
5
32
|
## 1.24.0 - 2026-09-16
|
|
6
33
|
|
|
7
34
|
### Added
|
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/bin/ata.js
CHANGED
|
@@ -161,7 +161,13 @@ function cmdCompile(args) {
|
|
|
161
161
|
process.exit(1);
|
|
162
162
|
}
|
|
163
163
|
const input = args._[0];
|
|
164
|
-
|
|
164
|
+
// With no --format, the output filename already says what the caller wants:
|
|
165
|
+
// -o validator.cjs used to emit an ES module into a .cjs file, which Node
|
|
166
|
+
// then refused to load.
|
|
167
|
+
const extFormat = args.opts.output && /\.cjs$/.test(args.opts.output) ? 'cjs'
|
|
168
|
+
: args.opts.output && /\.mjs$/.test(args.opts.output) ? 'esm'
|
|
169
|
+
: null;
|
|
170
|
+
const format = args.opts.format || extFormat || 'esm';
|
|
165
171
|
if (format !== 'esm' && format !== 'cjs') {
|
|
166
172
|
process.stderr.write(`error: --format must be esm or cjs (got "${format}")\n`);
|
|
167
173
|
process.exit(1);
|
package/build.d.ts
CHANGED
|
@@ -89,6 +89,18 @@ export interface BundleStandaloneOptions {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
export interface ToStandaloneModuleOptions {
|
|
92
|
+
/**
|
|
93
|
+
* Run the authoring-time schema checks before emitting: unknown keywords
|
|
94
|
+
* (with a spelling suggestion), inert keywords, unsatisfiable required
|
|
95
|
+
* names, unresolvable local $refs. Throws with every finding.
|
|
96
|
+
*/
|
|
97
|
+
strictSchema?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Called when the module ships degraded: error detail was requested but the
|
|
100
|
+
* error generator declined this schema, so failures report the single
|
|
101
|
+
* ATA9000 abort-early error while the verdict stays exact.
|
|
102
|
+
*/
|
|
103
|
+
onWarning?: (message: string) => void;
|
|
92
104
|
format?: 'cjs' | 'esm';
|
|
93
105
|
abortEarly?: boolean;
|
|
94
106
|
source?: boolean;
|
|
@@ -99,12 +111,27 @@ export interface ToStandaloneModuleOptions {
|
|
|
99
111
|
formatMode?: 'embed' | 'inject';
|
|
100
112
|
/**
|
|
101
113
|
* Also export `parse(data)`: validate, then return a copy of the input
|
|
102
|
-
* holding only the properties the schema declares
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
114
|
+
* holding only the properties the schema declares, with declared `default`
|
|
115
|
+
* values filled in for absent optional properties (object and array
|
|
116
|
+
* defaults are fresh per call). Off by default because it adds to the
|
|
117
|
+
* emitted module's size. Emitted only where the copy is provably exact:
|
|
118
|
+
* `$ref` and `patternProperties` decline; in-place applicators (`allOf`,
|
|
119
|
+
* `anyOf`, `oneOf`, `if`/`then`/`else`, and `unevaluatedProperties: false`)
|
|
120
|
+
* are admitted when every property name they mention is already declared
|
|
121
|
+
* in the node's own `properties`. A required property with a default, or
|
|
122
|
+
* a default its own schema rejects, also declines, because those are the
|
|
123
|
+
* two shapes where parse() and the runtime would disagree.
|
|
106
124
|
*/
|
|
107
125
|
parse?: boolean;
|
|
126
|
+
/**
|
|
127
|
+
* Also export `validateJSON(text)`: parse the JSON text, validate, and on
|
|
128
|
+
* failure attach a `dataFrame` ({ byteOffset, length, line, col, text })
|
|
129
|
+
* to every error by walking the original text once, so errors point at the
|
|
130
|
+
* right occurrence even when the same key appears in several sections.
|
|
131
|
+
* Off by default because the embedded position walker adds to the emitted
|
|
132
|
+
* module's size.
|
|
133
|
+
*/
|
|
134
|
+
positions?: boolean;
|
|
108
135
|
}
|
|
109
136
|
|
|
110
137
|
/** Bundle multiple schemas into one self-contained module (no ata-validator runtime). */
|
|
@@ -113,6 +140,16 @@ export function bundleStandalone(schemas: unknown[], options?: BundleStandaloneO
|
|
|
113
140
|
/** Like {@link bundleStandalone} but deduplicates shared bodies for smaller output. */
|
|
114
141
|
export function bundleCompact(schemas: unknown[], options?: BundleStandaloneOptions): string;
|
|
115
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Stable content hash of a schema, 16 hex characters, over a canonical JSON
|
|
145
|
+
* form (keys sorted at every level). Every module from
|
|
146
|
+
* {@link toStandaloneModule} exports its own `schemaHash`; comparing that
|
|
147
|
+
* against `schemaHash(currentSchema)` tells a build the module is stale.
|
|
148
|
+
* An integrity aid, not a security boundary.
|
|
149
|
+
*/
|
|
150
|
+
export function schemaHash(schema: unknown): string;
|
|
151
|
+
|
|
116
152
|
/** Emit a self-contained `validate`/`isValid` module string for a single
|
|
117
|
-
* schema, plus `parse` when {@link ToStandaloneModuleOptions.parse} is set
|
|
153
|
+
* schema, plus `parse` when {@link ToStandaloneModuleOptions.parse} is set
|
|
154
|
+
* and `validateJSON` when {@link ToStandaloneModuleOptions.positions} is. */
|
|
118
155
|
export function toStandaloneModule(schema: unknown, options?: ToStandaloneModuleOptions): string | null;
|
package/compat.d.ts
CHANGED
|
@@ -25,6 +25,14 @@ declare class Ata {
|
|
|
25
25
|
removeKeyword(keyword: string): this;
|
|
26
26
|
|
|
27
27
|
errorsText(errors?: Ata.ErrorObject[] | null, options?: { separator?: string; dataVar?: string }): string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Attach a `dataFrame` ({ byteOffset, length, line, col, text }) to each
|
|
31
|
+
* error by mapping its `instancePath` into the JSON text the data was
|
|
32
|
+
* parsed from. One walk of the text; correct when the same key appears in
|
|
33
|
+
* several sections. Not an Ajv API. Mutates and returns `errors`.
|
|
34
|
+
*/
|
|
35
|
+
static attachDataFrames(errors: Ata.ErrorObject[] | null | undefined, text: string | Buffer): Ata.ErrorObject[] | null | undefined;
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
declare namespace Ata {
|
package/compat.js
CHANGED
|
@@ -409,6 +409,26 @@ function metaValidator(id) {
|
|
|
409
409
|
return v;
|
|
410
410
|
}
|
|
411
411
|
|
|
412
|
+
// Maps each error's instancePath to its position in the JSON text the data
|
|
413
|
+
// was parsed from and attaches it as `dataFrame` ({ byteOffset, length,
|
|
414
|
+
// line, col, text }). One walk of the text, and correct when the same key
|
|
415
|
+
// appears in several sections, which a first-occurrence string search is
|
|
416
|
+
// not. Not an Ajv API: exported for callers that keep the source text
|
|
417
|
+
// around and want file positions on Ajv-shaped errors.
|
|
418
|
+
Ata.attachDataFrames = function attachDataFrames(errors, text) {
|
|
419
|
+
if (!errors || !errors.length || text == null) return errors;
|
|
420
|
+
const { buildDataPositionMap } = require('./lib/data-positions');
|
|
421
|
+
let map;
|
|
422
|
+
try { map = buildDataPositionMap(text); } catch { return errors; }
|
|
423
|
+
for (const e of errors) {
|
|
424
|
+
if (!e || e.dataFrame) continue;
|
|
425
|
+
const ptr = e.instancePath != null ? e.instancePath : (e.dataPath || '');
|
|
426
|
+
const p = map[ptr];
|
|
427
|
+
if (p) e.dataFrame = { byteOffset: p.byteOffset, length: p.length, line: p.line, col: p.col, text: p.text };
|
|
428
|
+
}
|
|
429
|
+
return errors;
|
|
430
|
+
};
|
|
431
|
+
|
|
412
432
|
module.exports = Ata;
|
|
413
433
|
module.exports.default = Ata;
|
|
414
434
|
module.exports.Ata = Ata;
|
package/index.d.ts
CHANGED
|
@@ -395,9 +395,20 @@ export interface ValidatorOptions {
|
|
|
395
395
|
keywords?: Record<string, KeywordDefinition | KeywordValidate>;
|
|
396
396
|
/**
|
|
397
397
|
* When true, validation errors include `parentSchema` (the schema object
|
|
398
|
-
* that produced the error)
|
|
398
|
+
* that produced the error), `schema` (the failing keyword's own value) and
|
|
399
|
+
* `data` (the value the error points at). Matches ajv's `verbose: true`.
|
|
399
400
|
*/
|
|
400
401
|
verbose?: boolean;
|
|
402
|
+
/**
|
|
403
|
+
* Authoring-time schema checks, run at construction on the schema as
|
|
404
|
+
* written: unknown keywords (with a spelling suggestion), keywords the
|
|
405
|
+
* node's own `type` makes inert, `required` names nothing can satisfy, and
|
|
406
|
+
* local `$ref`s that do not resolve. `true` throws with every finding;
|
|
407
|
+
* `'log'` warns through `logger` (or the console) and continues.
|
|
408
|
+
*/
|
|
409
|
+
strictSchema?: boolean | 'log';
|
|
410
|
+
/** Receives `strictSchema: 'log'` warnings; `false` silences them. */
|
|
411
|
+
logger?: { warn(...args: unknown[]): void } | false;
|
|
401
412
|
/**
|
|
402
413
|
* When true, validate() returns a shared frozen result on the first failure
|
|
403
414
|
* instead of collecting full error details. Smaller hot-path allocation.
|
package/lib/aot-build.js
CHANGED
|
@@ -270,10 +270,22 @@ function bundleCompact(schemas, opts) {
|
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
function toStandaloneModule(schema, opts) {
|
|
273
|
+
// The same authoring-time checks `ata compile --strict-schema` runs, for
|
|
274
|
+
// callers driving the build API directly: a typo in a schema headed for a
|
|
275
|
+
// compiled module is the quietest place a typo can live.
|
|
276
|
+
if (opts && opts.strictSchema) {
|
|
277
|
+
const { checkSchemaStrict } = require('./strict-check');
|
|
278
|
+
const problems = checkSchemaStrict(schema, {});
|
|
279
|
+
if (problems.length > 0) {
|
|
280
|
+
throw new Error(problems.map((x) => `strict mode: ${x.message} at ${x.path}`).join('\n'));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
273
283
|
const v = schema instanceof Validator ? schema : new Validator(schema, opts);
|
|
274
284
|
return aot.toStandaloneModule(v, opts);
|
|
275
285
|
}
|
|
276
286
|
|
|
287
|
+
const { schemaHash } = require('./schema-hash');
|
|
288
|
+
|
|
277
289
|
module.exports = {
|
|
278
290
|
build,
|
|
279
291
|
expandGlobs,
|
|
@@ -283,4 +295,5 @@ module.exports = {
|
|
|
283
295
|
bundleStandalone,
|
|
284
296
|
bundleCompact,
|
|
285
297
|
toStandaloneModule,
|
|
298
|
+
schemaHash,
|
|
286
299
|
};
|
package/lib/aot-impl.js
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
// bundleCompact(Validator, schemas, opts)
|
|
14
14
|
// loadBundle(Validator, mods, schemas, opts)
|
|
15
15
|
|
|
16
|
-
const { compileToJSCodegenWithErrors } = require('./js-compiler');
|
|
16
|
+
const { compileToJSCodegenWithErrors, compileToJSCodegen, unevalContributions } = require('./js-compiler');
|
|
17
|
+
const { buildDataPositionMap } = require('./data-positions');
|
|
18
|
+
const { schemaHash } = require('./schema-hash');
|
|
17
19
|
const SAFE_REGEX_SOURCE = require('./safe-regex-source');
|
|
18
20
|
|
|
19
21
|
// Embedded verbatim in standalone modules so the output file has no runtime
|
|
@@ -54,6 +56,35 @@ function safeRePrelude(...fns) {
|
|
|
54
56
|
return fns.some((f) => f && f._usesSafeRe) ? getSafeRegexEmbed() + '\n' : '';
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
// Serialize the closure variables a compiled function referenced (regexes,
|
|
60
|
+
// sets, lookup tables, preprocessor functions) into declarations for load
|
|
61
|
+
// scope. Regex lines are no longer inlined into _source, so every emitter
|
|
62
|
+
// must declare them exactly once outside the emitted function; inlining them
|
|
63
|
+
// in the body recompiled the pattern on every call.
|
|
64
|
+
function closureDeclLines(jsFn, declKW) {
|
|
65
|
+
const kw = declKW || 'const';
|
|
66
|
+
const lines = [];
|
|
67
|
+
if (!jsFn || !jsFn._closures || jsFn._closures.length === 0) return lines;
|
|
68
|
+
for (const { name, val } of jsFn._closures) {
|
|
69
|
+
if (Array.isArray(val)) { lines.push(`${kw} ${name} = ${JSON.stringify(val)};`); continue; }
|
|
70
|
+
if (val && val.__ataSafe) {
|
|
71
|
+
lines.push(`${kw} ${name} = __ataSafeRe(${JSON.stringify(val.source)});`);
|
|
72
|
+
} else if (val instanceof RegExp) {
|
|
73
|
+
const flags = val.flags;
|
|
74
|
+
lines.push(`${kw} ${name} = new RegExp(${JSON.stringify(val.source)}${flags ? ', ' + JSON.stringify(flags) : ''});`);
|
|
75
|
+
} else if (val instanceof Set) {
|
|
76
|
+
lines.push(`${kw} ${name} = new Set(${JSON.stringify([...val])});`);
|
|
77
|
+
} else if (typeof val === 'function') {
|
|
78
|
+
// new Function('_ppv', body) — extract body from toString()
|
|
79
|
+
const str = val.toString();
|
|
80
|
+
const m = str.match(/^function[^(]*\([^)]*\)\s*\{([\s\S]*)\}$/);
|
|
81
|
+
const body = m ? m[1].trim() : str;
|
|
82
|
+
lines.push(`${kw} ${name} = function(_ppv) { ${body} };`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return lines;
|
|
86
|
+
}
|
|
87
|
+
|
|
57
88
|
// A validator can enforce more than its schema says. `withKeywords` from
|
|
58
89
|
// @ata-project/keywords wraps an instance's entry points, and a wrapper
|
|
59
90
|
// declares that by setting `_externalChecks`. The emitters below build their
|
|
@@ -87,11 +118,12 @@ function toStandalone(validator) {
|
|
|
87
118
|
);
|
|
88
119
|
const errSrc = jsErrFn && jsErrFn._errSource ? jsErrFn._errSource : '';
|
|
89
120
|
|
|
121
|
+
const closureSrc = closureDeclLines(jsFn).join('\n');
|
|
90
122
|
return `// Auto-generated by ata-validator — do not edit
|
|
91
123
|
'use strict';
|
|
92
124
|
${_CP_LEN_SOURCE}
|
|
93
125
|
${safeRePrelude(jsFn, jsErrFn)}${preambleSrc}
|
|
94
|
-
const boolFn = function(d) {
|
|
126
|
+
${closureSrc ? closureSrc + '\n' : ''}const boolFn = function(d) {
|
|
95
127
|
${src}
|
|
96
128
|
};
|
|
97
129
|
const hybridFactory = function(R, E) {
|
|
@@ -167,21 +199,112 @@ function emitFormatDecls(closures, mode, declKW) {
|
|
|
167
199
|
// Returns null when the clone cannot be proven exact, and the module then
|
|
168
200
|
// simply has no parse(). Declining is recoverable; a sanitiser that quietly
|
|
169
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
|
+
|
|
170
270
|
function emitClone(node, access, depth) {
|
|
171
271
|
if (!node || typeof node !== 'object') return null;
|
|
172
272
|
if (depth > 12) return null;
|
|
173
273
|
// Only plain object nodes are cloneable, and only when the schema names
|
|
174
|
-
// every property it keeps. A
|
|
175
|
-
//
|
|
176
|
-
if
|
|
177
|
-
|
|
274
|
+
// every property it keeps. A $ref means the set of allowed keys is not
|
|
275
|
+
// this node's to decide. In-place applicators (allOf, anyOf, oneOf,
|
|
276
|
+
// if/then/else) are admitted under the same proof the unevaluated* error
|
|
277
|
+
// generators use: every property name they contribute must already be
|
|
278
|
+
// declared in this node's own `properties`, so they constrain values but
|
|
279
|
+
// never widen the key set. `not` never widens it. `unevaluatedProperties`
|
|
280
|
+
// is admitted only as `false` under that proof, where it is exactly
|
|
281
|
+
// `additionalProperties: false`.
|
|
282
|
+
if (node.$ref || node.patternProperties ||
|
|
178
283
|
node.additionalProperties === true ||
|
|
179
284
|
(node.additionalProperties && typeof node.additionalProperties === 'object')) {
|
|
180
285
|
return null;
|
|
181
286
|
}
|
|
287
|
+
if (node.unevaluatedProperties !== undefined && node.unevaluatedProperties !== false) return null;
|
|
182
288
|
if (node.type !== 'object' || !node.properties) return null;
|
|
183
289
|
const keys = Object.keys(node.properties);
|
|
184
290
|
if (keys.length === 0) return null;
|
|
291
|
+
if (node.allOf || node.anyOf || node.oneOf || node.if || node.then || node.else ||
|
|
292
|
+
node.unevaluatedProperties === false) {
|
|
293
|
+
if (node.$dynamicRef !== undefined || node.$recursiveRef !== undefined ||
|
|
294
|
+
node.dependentSchemas !== undefined || node.dependencies !== undefined) return null;
|
|
295
|
+
const names = new Set();
|
|
296
|
+
const state = { prefix: 0 };
|
|
297
|
+
for (const k of ['allOf', 'anyOf', 'oneOf']) {
|
|
298
|
+
if (node[k] !== undefined) {
|
|
299
|
+
if (!Array.isArray(node[k])) return null;
|
|
300
|
+
for (const b of node[k]) if (!unevalContributions(b, 'props', names, state, 0)) return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for (const k of ['if', 'then', 'else']) {
|
|
304
|
+
if (node[k] !== undefined && !unevalContributions(node[k], 'props', names, state, 0)) return null;
|
|
305
|
+
}
|
|
306
|
+
for (const n of names) if (!Object.prototype.hasOwnProperty.call(node.properties, n)) return null;
|
|
307
|
+
}
|
|
185
308
|
const required = new Set(Array.isArray(node.required) ? node.required : []);
|
|
186
309
|
|
|
187
310
|
// A property is copyable only when the emitter can name everything that
|
|
@@ -196,7 +319,22 @@ function emitClone(node, access, depth) {
|
|
|
196
319
|
for (const key of keys) {
|
|
197
320
|
const prop = node.properties[key];
|
|
198
321
|
if (!prop || typeof prop !== 'object') return null;
|
|
199
|
-
if (prop.$ref
|
|
322
|
+
if (prop.$ref) return null;
|
|
323
|
+
// A property that is required and carries a default is the one place the
|
|
324
|
+
// runtime and a raw-input validation disagree: the runtime fills the
|
|
325
|
+
// default before checking `required`, so `{}` validates there and would
|
|
326
|
+
// throw here. Declining keeps parse() exactly as strict as validate().
|
|
327
|
+
if (required.has(key) && prop.default !== undefined) return null;
|
|
328
|
+
// A default the property's own schema rejects would make parse() hand
|
|
329
|
+
// back a document validate() refuses: the runtime fills defaults before
|
|
330
|
+
// validating, so it catches the bad default, and a raw-input check here
|
|
331
|
+
// never sees it. Probe the default at emit time and decline when it does
|
|
332
|
+
// not hold, or when the subschema cannot be compiled to check it.
|
|
333
|
+
if (prop.default !== undefined) {
|
|
334
|
+
let probe = null;
|
|
335
|
+
try { probe = compileToJSCodegen(prop, null, null); } catch (_) { probe = null; }
|
|
336
|
+
if (!probe || !probe(prop.default)) return null;
|
|
337
|
+
}
|
|
200
338
|
const read = `${access}[${JSON.stringify(key)}]`;
|
|
201
339
|
let value;
|
|
202
340
|
const types = Array.isArray(prop.type) ? prop.type : [prop.type];
|
|
@@ -230,7 +368,7 @@ function emitClone(node, access, depth) {
|
|
|
230
368
|
return null;
|
|
231
369
|
}
|
|
232
370
|
if (required.has(key)) fixed.push(`${JSON.stringify(key)}: ${value}`);
|
|
233
|
-
else conditional.push({ key, value });
|
|
371
|
+
else conditional.push({ key, value, dflt: prop.default });
|
|
234
372
|
}
|
|
235
373
|
if (fixed.length === 0 && conditional.length === 0) return null;
|
|
236
374
|
|
|
@@ -240,7 +378,12 @@ function emitClone(node, access, depth) {
|
|
|
240
378
|
// gains a key the input did not have.
|
|
241
379
|
const tmp = `_c${depth}`;
|
|
242
380
|
const adds = conditional
|
|
243
|
-
.map(({ key, value }) =>
|
|
381
|
+
.map(({ key, value, dflt }) => {
|
|
382
|
+
const set = `if (${JSON.stringify(key)} in ${access}) ${tmp}[${JSON.stringify(key)}] = ${value};`;
|
|
383
|
+
// The default is emitted as a literal inside the call, so an object or
|
|
384
|
+
// array default is a fresh value on every parse, never shared state.
|
|
385
|
+
return dflt === undefined ? set : `${set} else ${tmp}[${JSON.stringify(key)}] = ${JSON.stringify(dflt)};`;
|
|
386
|
+
})
|
|
244
387
|
.join(' ');
|
|
245
388
|
return `(function(){ const ${tmp} = ${literal}; ${adds} return ${tmp}; })()`;
|
|
246
389
|
}
|
|
@@ -294,29 +437,8 @@ function toStandaloneModule(validator, opts) {
|
|
|
294
437
|
|
|
295
438
|
// Serialize closure vars referenced in _fn body: regex, sub-validators, sets.
|
|
296
439
|
let closureDecls = '';
|
|
297
|
-
|
|
298
|
-
const lines =
|
|
299
|
-
for (const { name, val } of jsFn._closures) {
|
|
300
|
-
if (Array.isArray(val)) {
|
|
301
|
-
lines.push(`const ${name} = ${JSON.stringify(val)};`);
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
if (val && val.__ataSafe) {
|
|
305
|
-
lines.push(`const ${name} = __ataSafeRe(${JSON.stringify(val.source)});`);
|
|
306
|
-
} else if (val instanceof RegExp) {
|
|
307
|
-
const flags = val.flags;
|
|
308
|
-
lines.push(`const ${name} = new RegExp(${JSON.stringify(val.source)}${flags ? ', ' + JSON.stringify(flags) : ''});`);
|
|
309
|
-
} else if (val instanceof Set) {
|
|
310
|
-
lines.push(`const ${name} = new Set(${JSON.stringify([...val])});`);
|
|
311
|
-
} else if (typeof val === 'function') {
|
|
312
|
-
// new Function('_ppv', body) — extract body from toString()
|
|
313
|
-
const str = val.toString();
|
|
314
|
-
// Matches: "function anonymous(_ppv\n) {\nbody\n}" or "function(_ppv){body}"
|
|
315
|
-
const m = str.match(/^function[^(]*\([^)]*\)\s*\{([\s\S]*)\}$/)
|
|
316
|
-
const body = m ? m[1].trim() : str;
|
|
317
|
-
lines.push(`const ${name} = function(_ppv) { ${body} };`);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
440
|
+
{
|
|
441
|
+
const lines = closureDeclLines(jsFn);
|
|
320
442
|
if (lines.length) closureDecls = lines.join('\n') + '\n';
|
|
321
443
|
}
|
|
322
444
|
|
|
@@ -368,11 +490,21 @@ function toStandaloneModule(validator, opts) {
|
|
|
368
490
|
// schema declares. Off by default: it costs bytes in every emitted module,
|
|
369
491
|
// and the reason to compile a schema ahead of time is usually to ship as
|
|
370
492
|
// little as possible. Ask for it with { parse: true }.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
493
|
+
// Local acyclic $refs are inlined first, so the schemas generators emit
|
|
494
|
+
// ($defs + $ref everywhere) still get a parse(). When the clone is still
|
|
495
|
+
// not provable, the decline is loud: the caller asked for parse and is not
|
|
496
|
+
// getting it, and discovering that by reading the export list cost a user
|
|
497
|
+
// an afternoon. Same channel as the error-detail decline above.
|
|
498
|
+
let cloneExpr = null;
|
|
499
|
+
if (opts && opts.parse) {
|
|
500
|
+
const baseSchema = typeof validator._schemaObj === 'object' ? validator._schemaObj : null;
|
|
501
|
+
cloneExpr = baseSchema ? emitClone(inlineRefsForClone(baseSchema), 'data', 0) : null;
|
|
502
|
+
if (!cloneExpr && typeof opts.onWarning === 'function') {
|
|
503
|
+
opts.onWarning(
|
|
504
|
+
'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, or an additionalProperties schema makes it someone else\'s decision. The module ships without a parse export; validate and strip with the runtime Validator instead.'
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
376
508
|
// Named _ataParse rather than parse: the emitted module also carries the
|
|
377
509
|
// safe-regex prelude, which has a module-scope parse() of its own for
|
|
378
510
|
// reading patterns. A second declaration of that name shadowed it and the
|
|
@@ -381,17 +513,60 @@ function toStandaloneModule(validator, opts) {
|
|
|
381
513
|
? `function _ataParse(data) {\n if (!_fn(data)) { const e = new Error('validation failed'); e.name = 'AtaValidationError'; throw e; }\n return ${cloneExpr};\n}\n`
|
|
382
514
|
: '';
|
|
383
515
|
|
|
516
|
+
// validateJSON(): parse the text, validate, and on failure attach a
|
|
517
|
+
// dataFrame (byte offset, line, col, source line) to every error by
|
|
518
|
+
// walking the original text once. Off by default for the same reason as
|
|
519
|
+
// parse(): it costs bytes in every emitted module. Ask for it with
|
|
520
|
+
// { positions: true }. The walker is the runtime's buildDataPositionMap,
|
|
521
|
+
// embedded verbatim via toString so the two cannot drift; that is why the
|
|
522
|
+
// function in lib/data-positions.js must stay self-contained.
|
|
523
|
+
const positionsCore = !(opts && opts.positions) ? '' : `const _ataPosMap = ${buildDataPositionMap.toString()};
|
|
524
|
+
function _ataFrame(p) { return { byteOffset: p.byteOffset, length: p.length, line: p.line, col: p.col, text: p.text }; }
|
|
525
|
+
function validateJSON(text) {
|
|
526
|
+
const s = String(text);
|
|
527
|
+
let data;
|
|
528
|
+
try { data = JSON.parse(s); } catch (e) {
|
|
529
|
+
const nl = s.indexOf(String.fromCharCode(10));
|
|
530
|
+
let frame = { byteOffset: 0, length: s.length, line: 1, col: 1, text: nl === -1 ? s : s.slice(0, nl) };
|
|
531
|
+
try { const m = _ataPosMap(s); if (m['']) frame = _ataFrame(m['']); } catch (_) {}
|
|
532
|
+
return { valid: false, errors: [{ code: 'ATA9001', message: 'invalid JSON document', keyword: '__parse__', path: '', instancePath: '', dataFrame: frame }] };
|
|
533
|
+
}
|
|
534
|
+
const r = validate(data);
|
|
535
|
+
if (r.valid || !r.errors || !r.errors.length) return r;
|
|
536
|
+
let map;
|
|
537
|
+
try { map = _ataPosMap(s); } catch (_) { return r; }
|
|
538
|
+
const errors = r.errors.map((e) => {
|
|
539
|
+
const ptr = e.instancePath != null ? e.instancePath : (e.path || '');
|
|
540
|
+
const p = map[ptr];
|
|
541
|
+
return p ? Object.assign({}, e, { dataFrame: _ataFrame(p) }) : e;
|
|
542
|
+
});
|
|
543
|
+
return { valid: false, errors };
|
|
544
|
+
}
|
|
545
|
+
`;
|
|
546
|
+
|
|
384
547
|
const baseNames = cloneExpr ? 'validate, isValid' : 'validate, isValid';
|
|
385
|
-
|
|
548
|
+
// The content hash of the schema this module was compiled from, over the
|
|
549
|
+
// schema as the caller wrote it (before dialect normalization), so a build
|
|
550
|
+
// can compare it against schemaHash(currentSchema) and know the module is
|
|
551
|
+
// stale without embedding its own fingerprint.
|
|
552
|
+
const hashSrc = schemaHash(validator._rawSchema !== undefined ? validator._rawSchema : validator._schemaObj);
|
|
553
|
+
const hashDecl = `const schemaHash = ${JSON.stringify(hashSrc)};\n`;
|
|
554
|
+
|
|
555
|
+
let names = fmt.exportsSetFormats ? baseNames + ', setFormats' : baseNames;
|
|
556
|
+
names += ', schemaHash';
|
|
557
|
+
if (positionsCore) names += ', validateJSON';
|
|
386
558
|
const parseAlias = cloneExpr ? ', _ataParse as parse' : '';
|
|
387
559
|
const parseProp = cloneExpr ? ', parse: _ataParse' : '';
|
|
388
560
|
const exports = format === 'esm'
|
|
389
561
|
? `export { ${names}${parseAlias} };\nexport default { ${names}${parseProp} };\n`
|
|
390
562
|
: `module.exports = { ${names}${parseProp} };\nmodule.exports.default = module.exports;\n`;
|
|
391
563
|
|
|
392
|
-
|
|
564
|
+
let degradedNote = (!abortEarly && !errCore)
|
|
393
565
|
? '// 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'
|
|
394
566
|
: '';
|
|
567
|
+
if (opts && opts.parse && !cloneExpr) {
|
|
568
|
+
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';
|
|
569
|
+
}
|
|
395
570
|
return `// Auto-generated by ata-validator — do not edit.
|
|
396
571
|
// Schema is embedded; runtime has zero dependency on ata-validator.
|
|
397
572
|
${degradedNote}'use strict';
|
|
@@ -411,7 +586,7 @@ ${closureDecls}${sharedBlock}${preambleDecls}${formatDecls}const _fn = function(
|
|
|
411
586
|
};
|
|
412
587
|
${errCore}function isValid(data) { return _fn(data); }
|
|
413
588
|
function validate(data) { ${validBody}; }
|
|
414
|
-
${parseCore}${exports}`;
|
|
589
|
+
${parseCore}${positionsCore}${hashDecl}${exports}`;
|
|
415
590
|
}
|
|
416
591
|
|
|
417
592
|
// Bundle multiple validators into a single JS file for fast startup.
|
|
@@ -517,6 +692,8 @@ function bundleStandalone(Validator, schemas, opts) {
|
|
|
517
692
|
? (jsFn._preambleGuard || '') + (kept.length ? kept.join('\n ') + '\n ' : '')
|
|
518
693
|
: (jsFn._preambleSource || '');
|
|
519
694
|
if (preambleSrc) preamble = preamble ? `${preamble}\n${preambleSrc}` : preambleSrc;
|
|
695
|
+
const closureSrc = closureDeclLines(jsFn, 'var').join('\n');
|
|
696
|
+
if (closureSrc) preamble = preamble ? `${preamble}\n${closureSrc}\n` : closureSrc + '\n';
|
|
520
697
|
if (opts && opts.verbose) {
|
|
521
698
|
// Embed the schema and a small resolver so errors carry parentSchema.
|
|
522
699
|
const schemaLit = JSON.stringify(v._schemaObj);
|
|
@@ -573,6 +750,7 @@ function bundleCompact(Validator, schemas, opts) {
|
|
|
573
750
|
guard: jsFn._preambleGuard || '',
|
|
574
751
|
parts: jsFn._preambleParts || null,
|
|
575
752
|
preamble: jsFn._preambleSource || '',
|
|
753
|
+
closures: closureDeclLines(jsFn, 'var').join('\n'),
|
|
576
754
|
hybridBody: jsFn._hybridSource,
|
|
577
755
|
err: jsErrFn && jsErrFn._errSource ? jsErrFn._errSource : null,
|
|
578
756
|
fmt: jsFn._formatClosures || null,
|
|
@@ -602,7 +780,11 @@ function bundleCompact(Validator, schemas, opts) {
|
|
|
602
780
|
const preamble = kept !== null
|
|
603
781
|
? e.guard + (kept.length ? kept.join('\n ') + '\n ' : '')
|
|
604
782
|
: e.preamble;
|
|
605
|
-
|
|
783
|
+
// Factory-scope code: guard state, hoisted branch helpers, and the
|
|
784
|
+
// closure declarations (regexes above all), matching where the
|
|
785
|
+
// in-process compiler places them. Keeping them per call recompiled
|
|
786
|
+
// every pattern on every validation.
|
|
787
|
+
e.factory = (preamble ? preamble + '\n' : '') + (e.closures ? e.closures + '\n' : '');
|
|
606
788
|
if (e.err) for (const h of sharedDecls) e.err = e.err.split(h).join('');
|
|
607
789
|
}
|
|
608
790
|
|
|
@@ -614,11 +796,15 @@ function bundleCompact(Validator, schemas, opts) {
|
|
|
614
796
|
|
|
615
797
|
const indices = entries.map((e) => {
|
|
616
798
|
if (!e) return [-1, -1];
|
|
617
|
-
|
|
799
|
+
// The dedupe key must include the factory code: two schemas can emit an
|
|
800
|
+
// identical body that references _re1 with different patterns, and the
|
|
801
|
+
// pattern now lives only in the factory-scope declaration.
|
|
802
|
+
const key = e.factory + '\u0000' + e.hybridBody;
|
|
803
|
+
let hi = bodyMap.get(key);
|
|
618
804
|
if (hi === undefined) {
|
|
619
805
|
hi = bodies.length;
|
|
620
|
-
bodies.push(e.
|
|
621
|
-
bodyMap.set(
|
|
806
|
+
bodies.push({ factory: e.factory, body: e.hybridBody });
|
|
807
|
+
bodyMap.set(key, hi);
|
|
622
808
|
}
|
|
623
809
|
let ei = -1;
|
|
624
810
|
if (e.err) {
|
|
@@ -661,7 +847,7 @@ function bundleCompact(Validator, schemas, opts) {
|
|
|
661
847
|
// Shared hybrid factories
|
|
662
848
|
out += `${declKW} H=[\n`;
|
|
663
849
|
out += bodies
|
|
664
|
-
.map((b) => `function(R,E){return function(d){${b}}}`)
|
|
850
|
+
.map((b) => `function(R,E){${b.factory}return function(d){${b.body}}}`)
|
|
665
851
|
.join(',\n');
|
|
666
852
|
out += '\n];\n';
|
|
667
853
|
|
package/lib/data-positions.js
CHANGED
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
* AND abortEarly is off. Zero cost on the valid path.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
// Self-contained on purpose: `toStandaloneModule({ positions: true })` embeds
|
|
10
|
+
// this function verbatim via Function.prototype.toString, so it may not close
|
|
11
|
+
// over anything in this module and may not assume Node globals. The inlined
|
|
12
|
+
// escapePtr below duplicates lib/source-positions.js for that reason.
|
|
11
13
|
function buildDataPositionMap (input) {
|
|
12
|
-
const
|
|
14
|
+
const escapePtr = (s) => s.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
15
|
+
const text = (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) ? input.toString('utf8') : String(input);
|
|
13
16
|
const map = Object.create(null);
|
|
14
17
|
const lines = text.split('\n');
|
|
15
18
|
const lineStart = new Array(lines.length + 1);
|
package/lib/js-compiler.js
CHANGED
|
@@ -1610,8 +1610,14 @@ function compileToJSCodegen(schema, schemaMap, userFormats) {
|
|
|
1610
1610
|
boolFn._hybridFactory = (R, E) => hybridFactory(...closureValues, R, E)
|
|
1611
1611
|
} catch {}
|
|
1612
1612
|
|
|
1613
|
-
// Store source for standalone compilation
|
|
1614
|
-
|
|
1613
|
+
// Store source for standalone compilation. Regex declarations are NOT
|
|
1614
|
+
// inlined into the body: they are carried by _closures and the emitters
|
|
1615
|
+
// declare them once at load scope. Inlining them here put a
|
|
1616
|
+
// `const _reN=__ataSafeRe(...)` inside the emitted function, which
|
|
1617
|
+
// shadowed the module-scope binding and recompiled the pattern on every
|
|
1618
|
+
// call, 25x on a schema with one pattern.
|
|
1619
|
+
const emitHelpers = ctx.helperCode.filter((c) => !/^const _re\d+=(?:__ataSafeRe|new RegExp)\(/.test(c))
|
|
1620
|
+
const helperStr = emitHelpers.length ? emitHelpers.join('\n ') + '\n ' : ''
|
|
1615
1621
|
boolFn._source = helperStr + body
|
|
1616
1622
|
boolFn._preambleSource = preambleStr
|
|
1617
1623
|
boolFn._preambleGuard = guardStr
|
|
@@ -2240,15 +2246,13 @@ function genCode(schema, v, lines, ctx, knownType) {
|
|
|
2240
2246
|
}
|
|
2241
2247
|
}
|
|
2242
2248
|
|
|
2243
|
-
// Build sub-schema
|
|
2249
|
+
// Build sub-schema checks inline so they share the parent helper scope.
|
|
2250
|
+
const subChecks = []
|
|
2244
2251
|
for (let i = 0; i < ppEntries.length; i++) {
|
|
2245
2252
|
const [, sub] = ppEntries[i]
|
|
2246
2253
|
const subLines = []
|
|
2247
2254
|
genCode(sub, `_ppv`, subLines, ctx)
|
|
2248
|
-
|
|
2249
|
-
const fnVar = `_ppf${pi}_${i}`
|
|
2250
|
-
ctx.closureVars.push(fnVar)
|
|
2251
|
-
ctx.closureVals.push(new Function('_ppv', fnBody))
|
|
2255
|
+
subChecks.push(subLines.join(';'))
|
|
2252
2256
|
}
|
|
2253
2257
|
|
|
2254
2258
|
const guard = isObj ? '' : `if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v}))`
|
|
@@ -2262,13 +2266,11 @@ function genCode(schema, v, lines, ctx, knownType) {
|
|
|
2262
2266
|
ctx._ppHandledAdditional = true
|
|
2263
2267
|
ctx._ppHandledPropertyNames = !!pn
|
|
2264
2268
|
const propKeys = Object.keys(schema.properties || {})
|
|
2265
|
-
let
|
|
2269
|
+
let apCheck = null
|
|
2266
2270
|
if (apSchema) {
|
|
2267
2271
|
const apLines = []
|
|
2268
2272
|
genCode(apSchema, '_apv', apLines, ctx)
|
|
2269
|
-
|
|
2270
|
-
ctx.closureVars.push(apFn)
|
|
2271
|
-
ctx.closureVals.push(new Function('_apv', apLines.length === 0 ? 'return true' : `${apLines.join(';')};return true`))
|
|
2273
|
+
apCheck = apLines.join(';')
|
|
2272
2274
|
}
|
|
2273
2275
|
lines.push(`${guard}{for(const ${kVar} in ${v}){`)
|
|
2274
2276
|
// propertyNames checks (merged into same loop)
|
|
@@ -2302,14 +2304,14 @@ function genCode(schema, v, lines, ctx, knownType) {
|
|
|
2302
2304
|
if (ppEntries.length > 0) {
|
|
2303
2305
|
lines.push(`let _pm${pi}=false`)
|
|
2304
2306
|
for (let i = 0; i < ppEntries.length; i++) {
|
|
2305
|
-
lines.push(`if(${matchers[i].check}){_pm${pi}=true;
|
|
2307
|
+
lines.push(`if(${matchers[i].check}){_pm${pi}=true;const _ppv=${v}[${kVar}];${subChecks[i]}}`)
|
|
2306
2308
|
}
|
|
2307
2309
|
}
|
|
2308
2310
|
// A key that is neither declared nor matched is additional. switch on
|
|
2309
2311
|
// the declared names (V8 compiles string cases to a jump table); no
|
|
2310
2312
|
// switch at all when nothing is declared, since a switch with no case
|
|
2311
2313
|
// clause is a syntax error.
|
|
2312
|
-
const additional =
|
|
2314
|
+
const additional = apCheck !== null ? `const _apv=${v}[${kVar}];${apCheck}` : `return false`
|
|
2313
2315
|
const notMatched = ppEntries.length > 0 ? `if(!_pm${pi}){${additional}}` : additional
|
|
2314
2316
|
if (propKeys.length) {
|
|
2315
2317
|
const switchCases = propKeys.map(k => `case ${JSON.stringify(k)}:`).join('')
|
|
@@ -2346,7 +2348,7 @@ function genCode(schema, v, lines, ctx, knownType) {
|
|
|
2346
2348
|
}
|
|
2347
2349
|
}
|
|
2348
2350
|
for (let i = 0; i < ppEntries.length; i++) {
|
|
2349
|
-
lines.push(`if(${matchers[i].check}
|
|
2351
|
+
lines.push(`if(${matchers[i].check}){const _ppv=${v}[${kVar}];${subChecks[i]}}`)
|
|
2350
2352
|
}
|
|
2351
2353
|
lines.push(`}}`)
|
|
2352
2354
|
}
|
|
@@ -3219,6 +3221,110 @@ function childPathDynExpr(parentExpr, indexExpr) {
|
|
|
3219
3221
|
return `${parentExpr}+'/'+${indexExpr}`
|
|
3220
3222
|
}
|
|
3221
3223
|
|
|
3224
|
+
// unevaluated* differs from additionalProperties/items only through
|
|
3225
|
+
// annotations contributed by in-place applicators at the same schema object.
|
|
3226
|
+
// When every applicator subschema is provably free of the annotating keywords
|
|
3227
|
+
// for that kind, the keyword is exactly its plain counterpart, and the error
|
|
3228
|
+
// generators can emit real detail for it instead of declining. `required`
|
|
3229
|
+
// inside a then-branch annotates nothing, which is precisely the shape a
|
|
3230
|
+
// config schema has; a then-branch carrying `properties` does, and stays
|
|
3231
|
+
// declined. `not` never keeps its annotations, so it is skipped. A reference
|
|
3232
|
+
// or dependency keyword makes the node ineligible outright: resolving what
|
|
3233
|
+
// they might contribute is not this function's job.
|
|
3234
|
+
// Collects what an in-place applicator subschema can contribute: property
|
|
3235
|
+
// names into `names`, and the deepest tuple prefix it may evaluate into
|
|
3236
|
+
// `state.prefix`. Returns false when the contribution cannot be bounded
|
|
3237
|
+
// (patternProperties, additionalProperties, a reference, item schemas, and so
|
|
3238
|
+
// on), which makes the node ineligible outright.
|
|
3239
|
+
function unevalContributions (sub, kind, names, state, depth) {
|
|
3240
|
+
if (depth > 24) return false
|
|
3241
|
+
if (sub === true || sub === false) return true
|
|
3242
|
+
if (typeof sub !== 'object' || sub === null) return false
|
|
3243
|
+
if (kind === 'props') {
|
|
3244
|
+
if (sub.patternProperties !== undefined || sub.additionalProperties !== undefined || sub.unevaluatedProperties !== undefined) return false
|
|
3245
|
+
if (sub.properties !== undefined) {
|
|
3246
|
+
if (typeof sub.properties !== 'object' || sub.properties === null) return false
|
|
3247
|
+
for (const k of Object.keys(sub.properties)) names.add(k)
|
|
3248
|
+
}
|
|
3249
|
+
} else {
|
|
3250
|
+
if (sub.items !== undefined || sub.additionalItems !== undefined || sub.contains !== undefined || sub.unevaluatedItems !== undefined) return false
|
|
3251
|
+
if (sub.prefixItems !== undefined) {
|
|
3252
|
+
if (!Array.isArray(sub.prefixItems)) return false
|
|
3253
|
+
if (sub.prefixItems.length > state.prefix) state.prefix = sub.prefixItems.length
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
if (sub.$ref !== undefined || sub.$dynamicRef !== undefined || sub.$recursiveRef !== undefined) return false
|
|
3257
|
+
if (sub.dependentSchemas !== undefined || sub.dependencies !== undefined) return false
|
|
3258
|
+
for (const k of ['allOf', 'anyOf', 'oneOf']) {
|
|
3259
|
+
if (sub[k] !== undefined) {
|
|
3260
|
+
if (!Array.isArray(sub[k])) return false
|
|
3261
|
+
for (const b of sub[k]) if (!unevalContributions(b, kind, names, state, depth + 1)) return false
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
for (const k of ['if', 'then', 'else']) {
|
|
3265
|
+
if (sub[k] !== undefined && !unevalContributions(sub[k], kind, names, state, depth + 1)) return false
|
|
3266
|
+
}
|
|
3267
|
+
return true
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
// A contribution that names only what the node's own adjacent keywords
|
|
3271
|
+
// already evaluate unconditionally changes nothing: a key in the node's own
|
|
3272
|
+
// `properties` is evaluated whether or not the branch that also names it
|
|
3273
|
+
// passes, and a branch prefix no longer than the node's own is covered
|
|
3274
|
+
// position for position. Anything beyond those bounds is conditional
|
|
3275
|
+
// evaluation the plain counterpart cannot express, and the node stays with
|
|
3276
|
+
// the interpreted engine.
|
|
3277
|
+
function unevalLocalOk (node, key) {
|
|
3278
|
+
if (node[key] !== false) return false
|
|
3279
|
+
if (node.$ref !== undefined || node.$dynamicRef !== undefined || node.$recursiveRef !== undefined) return false
|
|
3280
|
+
if (node.dependentSchemas !== undefined || node.dependencies !== undefined) return false
|
|
3281
|
+
const kind = key === 'unevaluatedProperties' ? 'props' : 'items'
|
|
3282
|
+
if (kind === 'props' && node.patternProperties !== undefined) return false
|
|
3283
|
+
if (kind === 'items' && node.contains !== undefined) return false
|
|
3284
|
+
const names = new Set()
|
|
3285
|
+
const state = { prefix: 0 }
|
|
3286
|
+
for (const k of ['allOf', 'anyOf', 'oneOf']) {
|
|
3287
|
+
if (node[k] !== undefined) {
|
|
3288
|
+
if (!Array.isArray(node[k])) return false
|
|
3289
|
+
for (const b of node[k]) if (!unevalContributions(b, kind, names, state, 0)) return false
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
for (const k of ['if', 'then', 'else']) {
|
|
3293
|
+
if (node[k] !== undefined && !unevalContributions(node[k], kind, names, state, 0)) return false
|
|
3294
|
+
}
|
|
3295
|
+
if (kind === 'props') {
|
|
3296
|
+
const own = node.properties && typeof node.properties === 'object' ? node.properties : {}
|
|
3297
|
+
for (const n of names) if (!Object.prototype.hasOwnProperty.call(own, n)) return false
|
|
3298
|
+
} else {
|
|
3299
|
+
const ownPrefix = Array.isArray(node.prefixItems) ? node.prefixItems.length
|
|
3300
|
+
: (Array.isArray(node.items) ? node.items.length : 0)
|
|
3301
|
+
if (node.items !== undefined && !Array.isArray(node.items)) { /* all evaluated; any prefix is covered */ }
|
|
3302
|
+
else if (state.prefix > ownPrefix) return false
|
|
3303
|
+
}
|
|
3304
|
+
return true
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
// Every occurrence in the document must qualify or the generator declines the
|
|
3308
|
+
// whole schema, exactly as the string scan it replaces did. The walk is
|
|
3309
|
+
// generic and may visit data positions (an enum value, say); judging those is
|
|
3310
|
+
// harmless, since a data node that fails the test only makes the answer more
|
|
3311
|
+
// conservative and the generators never read data positions as schemas.
|
|
3312
|
+
function unevalAllProvablyLocal (root) {
|
|
3313
|
+
const seen = new Set()
|
|
3314
|
+
const stack = [root]
|
|
3315
|
+
while (stack.length) {
|
|
3316
|
+
const n = stack.pop()
|
|
3317
|
+
if (n === null || typeof n !== 'object' || seen.has(n)) continue
|
|
3318
|
+
seen.add(n)
|
|
3319
|
+
if (!Array.isArray(n)) {
|
|
3320
|
+
if (n.unevaluatedProperties !== undefined && !unevalLocalOk(n, 'unevaluatedProperties')) return false
|
|
3321
|
+
if (n.unevaluatedItems !== undefined && !unevalLocalOk(n, 'unevaluatedItems')) return false
|
|
3322
|
+
}
|
|
3323
|
+
for (const k of Array.isArray(n) ? n : Object.values(n)) stack.push(k)
|
|
3324
|
+
}
|
|
3325
|
+
return true
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3222
3328
|
// Detect simple prefix patterns like "^x-", "^_", "^prefix" and generate fast charCodeAt checks
|
|
3223
3329
|
// Returns a JS expression string or null if pattern is too complex
|
|
3224
3330
|
function fastPrefixCheck(pattern, keyVar) {
|
|
@@ -3267,10 +3373,12 @@ function genCharCodeSwitch(keys, v) {
|
|
|
3267
3373
|
// Returns a function: (data, allErrors) => { valid, errors }
|
|
3268
3374
|
// Valid path is still fast — only error path does extra work.
|
|
3269
3375
|
function compileToJSCodegenWithErrors(schema, schemaMap, userFormats, sourceOpts) {
|
|
3270
|
-
//
|
|
3376
|
+
// unevaluated* is generated only where it is provably its plain counterpart
|
|
3377
|
+
// (see unevalLocalOk); any occurrence that is not keeps the whole schema on
|
|
3378
|
+
// the interpreted engine's error path, exactly as before.
|
|
3271
3379
|
if (typeof schema === 'object' && schema !== null) {
|
|
3272
3380
|
const s = JSON.stringify(schema)
|
|
3273
|
-
if (s.includes('unevaluatedProperties') || s.includes('unevaluatedItems')) return null
|
|
3381
|
+
if ((s.includes('unevaluatedProperties') || s.includes('unevaluatedItems')) && !unevalAllProvablyLocal(schema)) return null
|
|
3274
3382
|
}
|
|
3275
3383
|
if (typeof schema === 'boolean') {
|
|
3276
3384
|
return schema
|
|
@@ -3409,7 +3517,11 @@ function compileToJSCodegenWithErrors(schema, schemaMap, userFormats, sourceOpts
|
|
|
3409
3517
|
})
|
|
3410
3518
|
}
|
|
3411
3519
|
return fn
|
|
3412
|
-
} catch {
|
|
3520
|
+
} catch (e) {
|
|
3521
|
+
// Declining here is safe (the interpreter takes over) but silent, which
|
|
3522
|
+
// is how every past regression in this file hid. The env hook makes the
|
|
3523
|
+
// decline visible without changing behavior.
|
|
3524
|
+
if (process.env.ATA_DEBUG_ERRGEN) console.error('error codegen declined:', e.message)
|
|
3413
3525
|
return null
|
|
3414
3526
|
}
|
|
3415
3527
|
}
|
|
@@ -3774,6 +3886,52 @@ function genCodeE(schema, v, pathExpr, lines, ctx, schemaPrefix) {
|
|
|
3774
3886
|
lines.push(isObj ? `{${inner}}` : `if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v})){${inner}}`)
|
|
3775
3887
|
}
|
|
3776
3888
|
|
|
3889
|
+
// unevaluatedProperties: false, on a node where it is provably its plain
|
|
3890
|
+
// counterpart (the bail above guaranteed that for every occurrence). With
|
|
3891
|
+
// additionalProperties present everything is evaluated and there is nothing
|
|
3892
|
+
// to emit. The error keeps the keyword's own identity: the interpreted
|
|
3893
|
+
// engine reports these as unevaluatedProperties with the key named, and the
|
|
3894
|
+
// two engines are pinned to identical output.
|
|
3895
|
+
if (schema.unevaluatedProperties === false && schema.additionalProperties === undefined) {
|
|
3896
|
+
const allowedU = Object.keys(schema.properties || {}).map(k => `${JSON.stringify(k)}`).join(',')
|
|
3897
|
+
const ui = ctx.varCounter++
|
|
3898
|
+
const upSp = `${schemaPrefix}/unevaluatedProperties`
|
|
3899
|
+
const upLit = buildErrorLiteral({ keyword: 'unevaluatedProperties', schemaPath: upSp, sourceMap: ctx.sourceMap })
|
|
3900
|
+
const patChecksU = []
|
|
3901
|
+
for (const pat of Object.keys(schema.patternProperties || {})) {
|
|
3902
|
+
const pattern = JSON.stringify(pat)
|
|
3903
|
+
if (!ctx.regExpMap.has(pattern)) {
|
|
3904
|
+
const ri = ctx.varCounter++
|
|
3905
|
+
ctx.regExpMap.set(pattern, ri)
|
|
3906
|
+
if (patternIsSafe(pat)) {
|
|
3907
|
+
ctx.helperCode.push(`const _re${ri}=__ataSafeRe(${pattern})`)
|
|
3908
|
+
ctx.usesSafeRe = true
|
|
3909
|
+
} else {
|
|
3910
|
+
ctx.helperCode.push(`const _re${ri}=new RegExp(${pattern})`)
|
|
3911
|
+
}
|
|
3912
|
+
}
|
|
3913
|
+
patChecksU.push(`_re${ctx.regExpMap.get(pattern)}.test(_k${ui}[_i])`)
|
|
3914
|
+
}
|
|
3915
|
+
const isUneval = patChecksU.length ? `!_a${ui}.has(_k${ui}[_i])&&!(${patChecksU.join('||')})` : `!_a${ui}.has(_k${ui}[_i])`
|
|
3916
|
+
const innerU = `const _k${ui}=Object.keys(${v});const _a${ui}=new Set([${allowedU}]);for(let _i=0;_i<_k${ui}.length;_i++){if(${isUneval}){_e.push({code:'${upLit.codeStr}',keyword:'unevaluatedProperties',instancePath:${pathExpr||'""'},schemaPath:'${upSp}'${ordinalField(ctx, `${upSp}`)},params:{unevaluatedProperty:_k${ui}[_i]},message:'must NOT have unevaluated properties',docUrl:'${upLit.docUrl}'${upLit.frame}});if(!_all)return{valid:false,errors:_e}}}`
|
|
3917
|
+
lines.push(isObj ? `{${innerU}}` : `if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v})){${innerU}}`)
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
// unevaluatedItems: false under the same rule. The interpreted engine
|
|
3921
|
+
// reports one error for the array with the first unevaluated position as
|
|
3922
|
+
// the limit; with adjacent `items` everything is evaluated. The draft-07
|
|
3923
|
+
// array form of items acts as the prefix.
|
|
3924
|
+
if (schema.unevaluatedItems === false) {
|
|
3925
|
+
const itemsIsPrefix = Array.isArray(schema.items)
|
|
3926
|
+
const allEval = schema.items !== undefined && !itemsIsPrefix
|
|
3927
|
+
if (!allEval) {
|
|
3928
|
+
const plen = itemsIsPrefix ? schema.items.length : (Array.isArray(schema.prefixItems) ? schema.prefixItems.length : 0)
|
|
3929
|
+
const uiSp = `${schemaPrefix}/unevaluatedItems`
|
|
3930
|
+
const uiLit = buildErrorLiteral({ keyword: 'unevaluatedItems', schemaPath: uiSp, sourceMap: ctx.sourceMap })
|
|
3931
|
+
lines.push(`if(Array.isArray(${v})&&${v}.length>${plen}){_e.push({code:'${uiLit.codeStr}',keyword:'unevaluatedItems',instancePath:${pathExpr||'""'},schemaPath:'${uiSp}'${ordinalField(ctx, `${uiSp}`)},params:{limit:${plen}},message:'must NOT have more than ${plen} items',docUrl:'${uiLit.docUrl}'${uiLit.frame}});if(!_all)return{valid:false,errors:_e}}`)
|
|
3932
|
+
}
|
|
3933
|
+
}
|
|
3934
|
+
|
|
3777
3935
|
// dependentRequired
|
|
3778
3936
|
if (schema.dependentRequired) {
|
|
3779
3937
|
const drSp = `${schemaPrefix}/dependentRequired`
|
|
@@ -4004,10 +4162,10 @@ function genCodeE(schema, v, pathExpr, lines, ctx, schemaPrefix) {
|
|
|
4004
4162
|
// Avoids double-pass (jsFn → false → errFn runs same checks again).
|
|
4005
4163
|
// Uses type-aware optimizations: after type check passes, skip guards.
|
|
4006
4164
|
function compileToJSCombined(schema, VALID_RESULT, schemaMap, userFormats) {
|
|
4007
|
-
//
|
|
4165
|
+
// Same provably-local rule as the error generator above.
|
|
4008
4166
|
if (typeof schema === 'object' && schema !== null) {
|
|
4009
4167
|
const s = JSON.stringify(schema)
|
|
4010
|
-
if (s.includes('unevaluatedProperties') || s.includes('unevaluatedItems')) return null
|
|
4168
|
+
if ((s.includes('unevaluatedProperties') || s.includes('unevaluatedItems')) && !unevalAllProvablyLocal(schema)) return null
|
|
4011
4169
|
// Bail on self-referencing schemas — combined codegen doesn't support recursion
|
|
4012
4170
|
if (s.includes('"$ref":"#"')) return null
|
|
4013
4171
|
// Defs on a $ref cycle compile as named functions in the boolean and
|
|
@@ -4403,6 +4561,39 @@ function genCodeC(schema, v, pathExpr, lines, ctx, schemaPrefix) {
|
|
|
4403
4561
|
}
|
|
4404
4562
|
}
|
|
4405
4563
|
|
|
4564
|
+
// unevaluatedProperties: false where it is provably additionalProperties
|
|
4565
|
+
// (the entry bail guaranteed that), keeping the keyword's own identity so
|
|
4566
|
+
// the two engines report the same error.
|
|
4567
|
+
if (schema.unevaluatedProperties === false && schema.additionalProperties === undefined && !schema.patternProperties) {
|
|
4568
|
+
const propKeysU = Object.keys(schema.properties || {})
|
|
4569
|
+
const ui = ctx.varCounter++
|
|
4570
|
+
const failU = fail('unevaluatedProperties', 'unevaluatedProperties', `{unevaluatedProperty:_k${ui}[_i]}`, "'must NOT have unevaluated properties'")
|
|
4571
|
+
let innerU
|
|
4572
|
+
if (propKeysU.length === 0) {
|
|
4573
|
+
innerU = `const _k${ui}=Object.keys(${v});for(let _i=0;_i<_k${ui}.length;_i++){${failU}}`
|
|
4574
|
+
} else if (propKeysU.length <= 8) {
|
|
4575
|
+
const checksU = propKeysU.map(k => `_k${ui}[_i]!==${JSON.stringify(k)}`).join('&&')
|
|
4576
|
+
innerU = `const _k${ui}=Object.keys(${v});for(let _i=0;_i<_k${ui}.length;_i++)if(${checksU}){${failU}}`
|
|
4577
|
+
} else {
|
|
4578
|
+
const allowedU = propKeysU.map(k => JSON.stringify(k)).join(',')
|
|
4579
|
+
innerU = `const _k${ui}=Object.keys(${v});const _a${ui}=new Set([${allowedU}]);for(let _i=0;_i<_k${ui}.length;_i++)if(!_a${ui}.has(_k${ui}[_i])){${failU}}`
|
|
4580
|
+
}
|
|
4581
|
+
lines.push(isObj ? `{${innerU}}` : `if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v})){${innerU}}`)
|
|
4582
|
+
}
|
|
4583
|
+
|
|
4584
|
+
// unevaluatedItems: false under the same rule: one error for the array,
|
|
4585
|
+
// the first uncovered position as the limit. Adjacent schema-form `items`
|
|
4586
|
+
// evaluates everything; the draft-07 array form acts as the prefix.
|
|
4587
|
+
if (schema.unevaluatedItems === false) {
|
|
4588
|
+
const itemsIsPrefixC = Array.isArray(schema.items)
|
|
4589
|
+
const allEvalC = schema.items !== undefined && !itemsIsPrefixC
|
|
4590
|
+
if (!allEvalC) {
|
|
4591
|
+
const plenC = itemsIsPrefixC ? schema.items.length : (Array.isArray(schema.prefixItems) ? schema.prefixItems.length : 0)
|
|
4592
|
+
const failI = fail('unevaluatedItems', 'unevaluatedItems', `{limit:${plenC}}`, `'must NOT have more than ${plenC} items'`)
|
|
4593
|
+
lines.push(`if(Array.isArray(${v})&&${v}.length>${plenC}){${failI}}`)
|
|
4594
|
+
}
|
|
4595
|
+
}
|
|
4596
|
+
|
|
4406
4597
|
// dependentRequired
|
|
4407
4598
|
if (schema.dependentRequired) {
|
|
4408
4599
|
for (const [key, deps] of Object.entries(schema.dependentRequired)) {
|
|
@@ -4807,4 +4998,4 @@ function _collectEval(schema, result, defs, schemaMap, refStack, isRoot) {
|
|
|
4807
4998
|
// not → contributes nothing (spec: annotations from not are discarded)
|
|
4808
4999
|
}
|
|
4809
5000
|
|
|
4810
|
-
module.exports = { compileToJS, compileToJSCodegen, compileToJSCodegenWithErrors, compileToJSCombined, collectEvaluated, AJV_MESSAGES }
|
|
5001
|
+
module.exports = { compileToJS, compileToJSCodegen, compileToJSCodegenWithErrors, compileToJSCombined, collectEvaluated, unevalContributions, AJV_MESSAGES }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A stable content hash for a schema, so a compiled artifact can say which
|
|
4
|
+
// schema it came from and a build can detect staleness by comparing hashes
|
|
5
|
+
// instead of embedding its own. The hash is over a canonical JSON form
|
|
6
|
+
// (object keys sorted at every level), so the same schema hashes the same
|
|
7
|
+
// however its keys were ordered, and it is FNV-1a with two independent
|
|
8
|
+
// bases, 16 hex characters. This is an integrity aid, not a security
|
|
9
|
+
// boundary: it detects drift, it does not authenticate anything.
|
|
10
|
+
|
|
11
|
+
function canonical (value) {
|
|
12
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
13
|
+
if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
|
|
14
|
+
const keys = Object.keys(value).sort();
|
|
15
|
+
const parts = [];
|
|
16
|
+
for (const k of keys) {
|
|
17
|
+
if (value[k] === undefined) continue;
|
|
18
|
+
parts.push(JSON.stringify(k) + ':' + canonical(value[k]));
|
|
19
|
+
}
|
|
20
|
+
return '{' + parts.join(',') + '}';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fnv1a (str, seed) {
|
|
24
|
+
let h = seed >>> 0;
|
|
25
|
+
for (let i = 0; i < str.length; i++) {
|
|
26
|
+
h ^= str.charCodeAt(i);
|
|
27
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
28
|
+
}
|
|
29
|
+
return h >>> 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function schemaHash (schema) {
|
|
33
|
+
const text = canonical(schema);
|
|
34
|
+
const a = fnv1a(text, 0x811c9dc5);
|
|
35
|
+
const b = fnv1a(text, 0xcbf29ce4);
|
|
36
|
+
return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { schemaHash };
|
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.26.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",
|
|
@@ -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_scanner_differential.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",
|
|
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_scanner_differential.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_file_positions.js && node tests/test_schema_hash.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.26.0",
|
|
126
|
+
"@ata-validator/native-darwin-x64": "1.26.0",
|
|
127
|
+
"@ata-validator/native-linux-arm64-gnu": "1.26.0",
|
|
128
|
+
"@ata-validator/native-linux-arm64-musl": "1.26.0",
|
|
129
|
+
"@ata-validator/native-linux-x64-gnu": "1.26.0",
|
|
130
|
+
"@ata-validator/native-linux-x64-musl": "1.26.0",
|
|
131
|
+
"@ata-validator/native-win32-x64": "1.26.0"
|
|
132
132
|
},
|
|
133
133
|
"peerDependencies": {
|
|
134
134
|
"yaml": "^2.0.0"
|