ata-validator 1.22.1 → 1.24.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 CHANGED
@@ -2,6 +2,36 @@
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.24.0 - 2026-09-16
6
+
7
+ ### Added
8
+
9
+ - A schema-directed scanner: `isValidJSON()` answers the verdict from the JSON text without building the document. Parsing is about three quarters of the cost of a request that only needs yes or no, and a rejection now stops at the byte that caused it. The scanner compiles per schema, on the codegen path only, after 64 calls (generating one costs about 20 µs, so a caller that checks one document never pays), and it declines anything outside its supported core: `type`, `properties`, `required`, `additionalProperties`, `items`, `prefixItems`, the length, size and range keywords, `pattern`, `format`, `const`, `enum`, local `$ref` (inlined, acyclic, no base-changing keywords below the root), `allOf` (merged, with the `additionalProperties` cross-branch rule written out in full), and `unevaluated*` where it is provably a synonym for `additionalProperties`/`items`. Whatever it cannot answer it declines at compile time or bails from at runtime, and the parse path takes over unchanged. `tests/test_scanner_differential.js` holds the scanner and `validate()` to the same verdict on the same text: 413,130 comparisons over the official suite, a malformed-JSON corpus and generated corruptions, zero disagreements, and the count must stay at zero.
10
+ - Cross-process medians against the same call forced through `JSON.parse`: accepted documents 1.19x to 1.43x by size; rejection at the first element 37x at 4 KB and 385x at 41 KB.
11
+ - Generated config schemas are first-class: up to 4096 properties per node, and past 48 names the dispatch is a rolling hash accumulated during the key scan, one lookup in a Map passed outside the source, one `startsWith` to confirm, bodies shared per distinct subschema, and an in-order fast path for machine-written JSON that resynchronises across omitted optional keys. A 26.6 KB config with 1300 declared properties: 136.7 µs by parse-and-validate, 43.8 µs by scan.
12
+ - `isValidJSON` also remembers the last (text, verdict) pair, since the verdict is a pure function of the text: a drift monitor re-reading an unchanged file answers in 0.44 µs by native string compare. Withheld when user formats or custom keywords are present, since user functions may not be pure.
13
+ - Strict mode covers all four cases from issue #44: unknown keywords (with a spelling suggestion), unresolvable local `$ref`s, keywords the node's own `type` makes inert (`minimum` on a string can never fire), and `required` names nothing can satisfy (`additionalProperties: false` with the name absent from `properties`). `ata compile --strict-schema` and `build({ strictSchema: true })` run the same checks at build time and refuse to emit a module from a schema that fails them.
14
+
15
+ ### Fixed
16
+
17
+ - An AOT module that cannot carry error detail says so. `toStandaloneModule` with error detail requested builds the error function from the error generator, which declines some schemas, `unevaluated*` among them; the runtime validator falls back to the interpreted engine there, but a standalone module has nothing to fall back to, so it shipped with an exact verdict and a single ATA9000 stub for every failure, silently. The module still ships, since the verdict is exact and validating the rare failing document with the runtime `Validator` is a legitimate pattern, but the degradation is loud in every layer: `onWarning` on the emitter, a NOTE in the module header, a `warnings` list in the `build()` report, a printed warning from `ata compile`, and a refused build under `--strict`. Found by a gateway team that lost their per-field startup errors to it.
18
+
19
+ ## 1.23.0 - 2026-09-16
20
+
21
+ ### Added
22
+
23
+ - `strictSchema: true | 'log'`, and the compat shim's `strict`/`strictSchema` now enforce it: the authoring-time checks for the mistakes that fail open. An unknown keyword throws at construction with its path and the nearest known spelling, `strict mode: unknown keyword "maxLenght" (did you mean "maxLength"?) at #/properties/a/maxLenght`, and a local `$ref` that does not resolve in its own document throws instead of compiling a validator that rejects everything. `'log'` warns through the logger and continues. Off by default. The known-keyword list is derived from the vendored meta-schema documents rather than maintained by hand, plus the keywords ata implements beyond them; `x-` prefixed names and keywords registered through the `keywords` option pass. Property names, `enum` and `const` values, `default` and `examples` are data, not keywords, and the walk knows the difference, including the draft-07 `dependencies` form. `strictTypes`, `strictTuples` and `strictRequired` remain accepted and ignored, and `compat.d.ts` now says which is which. Reported as issue #44 by a migration that fuzzed 1,364 mutations against both validators, found zero divergence, and then shipped a typo.
24
+
25
+ ### Changed
26
+
27
+ - The error and combined generators compile on the first rejection instead of at startup. Every schema compiled three functions eagerly: the verdict, the combined validate-and-collect form, and the error generator, which on a cold first call cost 8.2, 10.4 and 7.8 ms on a 120-property config schema, most of it V8 compiling each generator's own code the first time it is entered. A caller that never reads an error never needs the last two, and `validate()` on a passing document does not either, so they are built on the first rejection. Compile plus first verdict on that schema: 30.7 to 10.8 ms, interleaved runs of committed builds. The warm path measured the same on all ten cases checked. The shared compile cache keeps `undefined` meaning not built yet and `null` meaning the compiler declined, in both places that seed it, and `tests/test_compile_cache_order.js` caught the one seam where the two seeders disagreed during this change.
28
+ - The static `unevaluatedProperties` tier stopped checking membership with a first-character switch. Keys sharing a first character, `ENV_0` through `ENV_999` say, all land in one case whose body chains every name, per key of the document: the `additionalProperties` quadratic in a different coat. Above 128 declared names the generated code reads the hoisted lookup that fixed the first one; below that the switch stays. A 1300-property config document with both of its objects closed: 87.7 to 24.5 µs, and the keyword now costs what `additionalProperties` costs, which is the membership test itself. Only the boolean generator has this code; the error and combined generators decline `unevaluated*` and the interpreted engine produces those errors.
29
+ - The compat shim derives each error's pointer segments and kinds once, into a key map shared by the reference-order sort and the shaping loop. The sort comparator used to re-split and re-classify both errors' pointers on every one of its comparisons, and the loop derived the same segments again per error, with the `if` wrapper's flush doing it a third time. A seven-error rejection through `ata-validator/compat` with `allErrors`: 5.79 to 3.27 µs. The shim's own overhead over `Validator.validate` fell from 4.4 µs to 1.9. The compat surface is pinned by `test_compat`, 106 error-format cases, 36 parity cases and the error-shape differential, all green before and after.
30
+
31
+ ### Fixed
32
+
33
+ - The API table said `verbose` attaches only `parentSchema`; it attaches `parentSchema`, `schema` and `data`, and the migration guide now lists which `params` key each keyword carries. The performance notes recorded "length-bucketed key comparison for `additionalProperties: false`" as measured and dropped on the grounds that each comparison is a pointer compare; that was right about one comparison and wrong about the total, and the note now carries the measurements that replaced it.
34
+
5
35
  ## 1.22.1 - 2026-09-16
6
36
 
7
37
  ### Fixed
package/bin/ata.js CHANGED
@@ -38,6 +38,10 @@ Build options:
38
38
  --cache-file <path> Cache file for incremental builds (default: cache disabled)
39
39
  --max-size <bytes> Fail build if any compiled module exceeds this gzipped size
40
40
  --strict Treat any AOT-incompatible schema as a build error (default: skip + warn)
41
+ --strict-schema Fail the build on schema authoring mistakes: unknown
42
+ keywords (with a spelling suggestion), keywords the
43
+ node's type makes inert, unresolvable local $refs,
44
+ and required names nothing can satisfy
41
45
  --watch Re-emit on schema change (Ctrl-C to exit)
42
46
  --no-types Skip .d.mts/.d.cts emission alongside compiled modules
43
47
  --source Embed schema source map (default in development)
@@ -80,6 +84,7 @@ function parseArgs(argv) {
80
84
  if (a === '--abort-early') { out.opts.abortEarly = true; continue; }
81
85
  if (a === '--check') { out.opts.check = true; continue; }
82
86
  if (a === '--strict') { out.opts.strict = true; continue; }
87
+ if (a === '--strict-schema') { out.opts.strictSchema = true; continue; }
83
88
  if (a === '--out-dir') { out.opts.outDir = argv[++i]; continue; }
84
89
  if (a === '--suffix') { out.opts.suffix = argv[++i]; continue; }
85
90
  if (a === '--cache-file') { out.opts.cacheFile = argv[++i]; continue; }
@@ -180,6 +185,19 @@ function cmdCompile(args) {
180
185
  process.exit(1);
181
186
  }
182
187
 
188
+ // The build is where an authoring mistake is cheapest to stop: a typo like
189
+ // maxLenght compiles into a module that simply lacks the rule, and nobody
190
+ // revisits a compiled module. Findings fail the compile, with the path and
191
+ // the suggested spelling.
192
+ if (args.opts.strictSchema) {
193
+ const { checkSchemaStrict } = require('../lib/strict-check');
194
+ const problems = checkSchemaStrict(schema, {});
195
+ if (problems.length > 0) {
196
+ for (const x of problems) reportCompileError(input, `strict mode: ${x.message} at ${x.path}`);
197
+ process.exit(1);
198
+ }
199
+ }
200
+
183
201
  const { Validator } = require('..');
184
202
  const aot = require('../lib/aot');
185
203
  let v;
@@ -200,7 +218,15 @@ function cmdCompile(args) {
200
218
  }
201
219
  }
202
220
  const schemaFile = path.relative(process.cwd(), input) || input;
203
- const src = aot.toStandaloneModule(v, { format, abortEarly, source, sourceMap, schemaFile });
221
+ const compileWarnings = [];
222
+ const src = aot.toStandaloneModule(v, { format, abortEarly, source, sourceMap, schemaFile, onWarning: (w) => compileWarnings.push(w) });
223
+ if (compileWarnings.length > 0) {
224
+ if (args.opts.strict) {
225
+ for (const w of compileWarnings) reportCompileError(input, w);
226
+ process.exit(1);
227
+ }
228
+ for (const w of compileWarnings) process.stderr.write(`ata: warning: ${input}: ${w}\n`);
229
+ }
204
230
  if (!src) {
205
231
  reportCompileError(input, 'schema is too complex for standalone compilation');
206
232
  process.exit(1);
package/compat.d.ts CHANGED
@@ -78,10 +78,15 @@ declare namespace Ata {
78
78
  formats?: Record<string, Format>;
79
79
  keywords?: KeywordDefinition[];
80
80
  schemas?: object[] | Record<string, object>;
81
+ /** Enforced: unknown keywords and dangling local $refs throw at compile ('log' warns instead). */
81
82
  strict?: boolean | 'log';
83
+ /** Same checks as `strict`; the specific option wins over the umbrella one. */
82
84
  strictSchema?: boolean | 'log';
85
+ /** Accepted for API compatibility; not enforced. */
83
86
  strictTypes?: boolean | 'log';
87
+ /** Accepted for API compatibility; not enforced. */
84
88
  strictTuples?: boolean | 'log';
89
+ /** Accepted for API compatibility; not enforced. */
85
90
  strictRequired?: boolean | 'log';
86
91
  allowUnionTypes?: boolean;
87
92
  logger?: { log(...args: unknown[]): void; warn(...args: unknown[]): void; error(...args: unknown[]): void } | false;
package/compat.js CHANGED
@@ -15,8 +15,10 @@ const { resolvePointer } = require('./lib/pointer.js');
15
15
  // - `$data` references;
16
16
  // - keywords defined only through `code` (a code generator hook);
17
17
  // - the reference formats plugin, whose formats are built in here.
18
- // Strict-mode schema checks (`strict`, `strictTypes`, ...) are accepted and
19
- // ignored: an unknown keyword is an annotation, as the specification says.
18
+ // Strict-mode schema checks: `strict` and `strictSchema` are enforced for the
19
+ // checks that fail open, an unknown keyword (`maxLenght` compiles and the
20
+ // constraint is simply absent) and a dangling local `$ref`. `strictTypes`,
21
+ // `strictTuples` and `strictRequired` are accepted and ignored.
20
22
 
21
23
  const { Validator } = require('./index');
22
24
  const { METASCHEMAS } = require('./lib/metaschemas');
@@ -99,6 +101,13 @@ class Ata {
99
101
  removeAdditional: !!o.removeAdditional,
100
102
  verbose: !!o.verbose,
101
103
  };
104
+ // `strict` and `strictSchema`: the unknown-keyword and dangling-local-$ref
105
+ // checks are enforced; `strictTypes`, `strictTuples` and `strictRequired`
106
+ // are still accepted and ignored. The specific keyword option wins over
107
+ // the umbrella one, which is how the reference class reads them.
108
+ const strictness = o.strictSchema !== undefined ? o.strictSchema : o.strict;
109
+ if (strictness === true || strictness === 'log') out.strictSchema = strictness;
110
+ if (o.logger !== undefined) out.logger = o.logger;
102
111
  if (o.validateFormats === false) out.assertFormat = false;
103
112
  const formatNames = Object.keys(this._formats);
104
113
  if (formatNames.length > 0) out.formats = { ...this._formats };
package/index.js CHANGED
@@ -885,6 +885,29 @@ class Validator {
885
885
  // produced the error). Matches ajv's `verbose: true` behavior.
886
886
  this._verbose = !!options.verbose;
887
887
 
888
+ // strictSchema: authoring-time checks, off by default. A mistyped keyword
889
+ // is the one schema mistake that fails open: to every dialect `maxLenght`
890
+ // is an annotation, so the constraint the author meant is simply absent
891
+ // and previously invalid data validates. `true` throws here, at
892
+ // construction, with every finding; `'log'` reports through
893
+ // `options.logger` or the console and continues. The check runs on the
894
+ // schema as written, before any normalization touches it.
895
+ if (options.strictSchema === true || options.strictSchema === 'log') {
896
+ const { checkSchemaStrict } = require('./lib/strict-check');
897
+ const problems = checkSchemaStrict(schema, { userKeywords: options.keywords || null });
898
+ if (problems.length > 0) {
899
+ const text = problems.map((x) => `strict mode: ${x.message} at ${x.path}`).join('\n');
900
+ if (options.strictSchema === true) {
901
+ throw new Error(text);
902
+ }
903
+ const logger = options.logger;
904
+ if (logger !== false) {
905
+ const warn = logger && typeof logger.warn === 'function' ? logger.warn.bind(logger) : console.warn;
906
+ warn(text);
907
+ }
908
+ }
909
+ }
910
+
888
911
  // richErrors: default true. Only the literal `false` opts back into the
889
912
  // v0.14 error shape (no code/expected/received/docUrl, no aliases).
890
913
  this._richErrors = options && options.richErrors === false ? false : true;
@@ -910,6 +933,8 @@ class Validator {
910
933
  // to attach dataFrame entries to each enriched error.
911
934
  this._posCache = null; // created by _pos() on first use, only the JSON text path needs it
912
935
  this._lastRawInput = null;
936
+ // undefined: not built yet. null: this schema has no scanner.
937
+ this._scanner = undefined;
913
938
 
914
939
  // Public methods start as memoized accessors on the prototype; nothing is
915
940
  // allocated per instance until one is first read. See _defineLazyMethod
@@ -1014,27 +1039,40 @@ class Validator {
1014
1039
  // because nothing has tried to build them yet. Both halves of that
1015
1040
  // distinction are null, and reading the second as the first costs this
1016
1041
  // schema its generated error function for the life of the process.
1017
- } else if (cached && cached.full && !_forceNapi) {
1042
+ } else if (cached && cached.jsFn !== undefined && !_forceNapi) {
1043
+ // `full` says the error and combined functions exist too. An entry
1044
+ // without it still carries a verdict function worth reusing; the pair is
1045
+ // built by _buildDeferred below if something asks for an error, and the
1046
+ // entry is upgraded then. `undefined` in `combined`/`errFn` means not
1047
+ // built yet; `null` means the compiler declined. Those two must never
1048
+ // blur: reading the first as the second is the bug this cache had once
1049
+ // already, and it silently cost schemas their generated error function.
1018
1050
  jsFn = cached.jsFn;
1019
1051
  jsCombinedFn = cached.combined;
1020
1052
  jsErrFn = cached.errFn;
1021
1053
  _isCodegen = !!cached.isCodegen;
1054
+ this._engine = _isCodegen ? 'codegen' : jsFn ? 'closure' : null;
1022
1055
  } else if (!_forceNapi) {
1023
1056
  const uf = this._userFormats;
1024
1057
  const _cgFn = compileToJSCodegen(schemaObj, sm, uf);
1025
1058
  jsFn = _cgFn || compileToJS(schemaObj, null, sm);
1026
- jsCombinedFn = compileToJSCombined(schemaObj, VALID_RESULT, sm, uf);
1027
- jsErrFn = compileToJSCodegenWithErrors(schemaObj, sm, uf);
1059
+ // Only the verdict is compiled here. The error and combined generators
1060
+ // are the other two thirds of a cold first call (8.2, 10.4 and 7.8 ms on
1061
+ // a 120-property config schema, most of it V8 compiling each generator
1062
+ // the first time it is entered), and a caller that never reads an error
1063
+ // never needs them. _buildDeferred compiles them on the first rejection.
1064
+ jsCombinedFn = undefined;
1065
+ jsErrFn = undefined;
1028
1066
  _isCodegen = !!_cgFn;
1029
1067
  this._engine = _cgFn ? 'codegen' : jsFn ? 'closure' : null;
1030
1068
  if (!uf) {
1031
- _compileCache.set(mapKey, { jsFn, combined: jsCombinedFn, errFn: jsErrFn, isCodegen: _isCodegen, full: true });
1069
+ _compileCache.set(mapKey, { jsFn, combined: undefined, errFn: undefined, isCodegen: _isCodegen, full: false });
1032
1070
  }
1033
1071
  } else {
1034
1072
  jsFn = null; jsCombinedFn = null; jsErrFn = null;
1035
1073
  }
1036
1074
  this._jsFn = jsFn;
1037
- if (this._engine === undefined) this._engine = (cached && cached.full) ? (cached.isCodegen ? 'codegen' : jsFn ? 'closure' : null) : null;
1075
+ if (this._engine === undefined) this._engine = null;
1038
1076
 
1039
1077
  // Data mutators -- try codegen first (12x faster), fallback to closure arrays.
1040
1078
  // Follow cross-refs so coercion/defaults/removeAdditional see the referenced
@@ -1080,14 +1118,26 @@ class Validator {
1080
1118
  )));
1081
1119
  const useSimdjsonForLarge = !hasArrayTraversal;
1082
1120
 
1083
- if (jsFn) {
1084
- let safeErrFn = null;
1085
- if (jsErrFn) {
1086
- try {
1087
- jsErrFn({}, true);
1088
- safeErrFn = (d) => jsErrFn(d, true);
1089
- } catch {}
1121
+ // Builds the two generators the compile step left out, once, and upgrades
1122
+ // the shared cache entry. `undefined` means not built yet; `null` means the
1123
+ // compiler declined. Conflating those is what once cost every schema its
1124
+ // generated error function for the life of the process, so they stay apart.
1125
+ const _buildDeferred = () => {
1126
+ if (jsCombinedFn !== undefined && jsErrFn !== undefined) return;
1127
+ const uf2 = this._userFormats;
1128
+ if (jsCombinedFn === undefined) jsCombinedFn = compileToJSCombined(schemaObj, VALID_RESULT, sm, uf2) || null;
1129
+ if (jsErrFn === undefined) jsErrFn = compileToJSCodegenWithErrors(schemaObj, sm, uf2) || null;
1130
+ if (!uf2) {
1131
+ const entry = _compileCache.get(mapKey);
1132
+ if (entry && entry.jsFn === jsFn) {
1133
+ entry.combined = jsCombinedFn;
1134
+ entry.errFn = jsErrFn;
1135
+ entry.full = true;
1136
+ }
1090
1137
  }
1138
+ };
1139
+
1140
+ if (jsFn) {
1091
1141
  // errFn: use JS codegen if safe, else native fallback (only when native
1092
1142
  // is available). Environments without the native addon — Cloudflare
1093
1143
  // Workers, browser, Bun without N-API — get a JS-only fallback so the
@@ -1126,19 +1176,38 @@ class Validator {
1126
1176
  // reports those schemas correctly, so failing data is re-validated
1127
1177
  // there. This used to be a placeholder error with no keyword and no
1128
1178
  // path, which hid whatever had actually failed.
1129
- const errFn =
1130
- safeErrFn ||
1131
- (hasUnevaluated || !native
1132
- ? jsOnlyFallback
1133
- : hasDynRef
1134
- ? (d) => {
1135
- this._ensureNative();
1136
- return this._compiled.validateJSON(JSON.stringify(d));
1137
- }
1138
- : (d) => {
1139
- this._ensureNative();
1140
- return this._compiled.validate(d);
1141
- });
1179
+ // Resolved on the first rejection rather than at compile time, because
1180
+ // building the generator behind it is two thirds of what a first call
1181
+ // costs and a caller that never reads an error never needs it. The probe
1182
+ // moves here with it: it calls the generated function, so it cannot run
1183
+ // before the function exists.
1184
+ let _errOnlyImpl = null;
1185
+ const errOnly = (d) => {
1186
+ if (_errOnlyImpl === null) {
1187
+ _buildDeferred();
1188
+ let safe = null;
1189
+ if (jsErrFn) {
1190
+ try {
1191
+ jsErrFn({}, true);
1192
+ safe = (x) => jsErrFn(x, true);
1193
+ } catch {}
1194
+ }
1195
+ _errOnlyImpl =
1196
+ safe ||
1197
+ (hasUnevaluated || !native
1198
+ ? jsOnlyFallback
1199
+ : hasDynRef
1200
+ ? (x) => {
1201
+ this._ensureNative();
1202
+ return this._compiled.validateJSON(JSON.stringify(x));
1203
+ }
1204
+ : (x) => {
1205
+ this._ensureNative();
1206
+ return this._compiled.validate(x);
1207
+ });
1208
+ }
1209
+ return _errOnlyImpl(d);
1210
+ };
1142
1211
 
1143
1212
  // Best path: combined validator (single pass, validates + collects errors)
1144
1213
  // Valid data: returns VALID_RESULT, no allocation
@@ -1148,24 +1217,41 @@ class Validator {
1148
1217
  // Test combined at compile time -- some schemas (e.g. if/then/else)
1149
1218
  // produce broken combined code that crashes on certain inputs.
1150
1219
  // We probe with diverse data; if any throws, fall back to hybrid.
1151
- let safeCombinedFn = null;
1152
- if (jsCombinedFn) {
1153
- try {
1154
- const probe = {};
1155
- // Populate probe with one key per known property to trigger nested paths
1156
- if (schemaObj && schemaObj.properties) {
1157
- for (const k of Object.keys(schemaObj.properties)) probe[k] = "";
1158
- }
1159
- if (schemaObj && schemaObj.if && schemaObj.if.properties) {
1160
- for (const k of Object.keys(schemaObj.if.properties)) probe[k] = "";
1161
- }
1162
- jsCombinedFn(probe);
1163
- jsCombinedFn({});
1164
- jsCombinedFn(null);
1165
- jsCombinedFn(0);
1166
- safeCombinedFn = jsCombinedFn;
1167
- } catch {}
1168
- }
1220
+ let _combinedProbed = false;
1221
+ let _safeCombined = null;
1222
+ const combinedIfSafe = () => {
1223
+ if (_combinedProbed) return _safeCombined;
1224
+ _combinedProbed = true;
1225
+ _buildDeferred();
1226
+ if (jsCombinedFn) {
1227
+ try {
1228
+ const probe = {};
1229
+ // Populate probe with one key per known property to trigger nested paths
1230
+ if (schemaObj && schemaObj.properties) {
1231
+ for (const k of Object.keys(schemaObj.properties)) probe[k] = "";
1232
+ }
1233
+ if (schemaObj && schemaObj.if && schemaObj.if.properties) {
1234
+ for (const k of Object.keys(schemaObj.if.properties)) probe[k] = "";
1235
+ }
1236
+ jsCombinedFn(probe);
1237
+ jsCombinedFn({});
1238
+ jsCombinedFn(null);
1239
+ jsCombinedFn(0);
1240
+ _safeCombined = jsCombinedFn;
1241
+ } catch {}
1242
+ }
1243
+ return _safeCombined;
1244
+ };
1245
+
1246
+ // What the hybrid path hands to its error slot: the combined function
1247
+ // when it is usable, since it validates and collects in one pass, and
1248
+ // the error generator otherwise. Same order the eager code chose, just
1249
+ // chosen on the first rejection.
1250
+ let _errPreferredImpl = null;
1251
+ const errPreferCombined = (d) => {
1252
+ if (_errPreferredImpl === null) _errPreferredImpl = combinedIfSafe() || errOnly;
1253
+ return _errPreferredImpl(d);
1254
+ };
1169
1255
 
1170
1256
  // The boolean engine is the verdict authority for these paths; the
1171
1257
  // final lazy wrapper uses it to skip error construction entirely.
@@ -1182,7 +1268,7 @@ class Validator {
1182
1268
  : (data) => (_fn(data) ? VALID_RESULT : ABORT_EARLY_RESULT);
1183
1269
  } else if (hasDynRef && _isCodegen && jsFn) {
1184
1270
  // $dynamicRef with JS codegen: direct path, no wrapper layers
1185
- const _fn = jsFn, _efn = safeErrFn || errFn, _R = VALID_RESULT;
1271
+ const _fn = jsFn, _efn = errOnly, _R = VALID_RESULT;
1186
1272
  this.validate = preprocess
1187
1273
  ? (data) => { preprocess(data); return _fn(data) ? _R : _efn(data); }
1188
1274
  : (data) => _fn(data) ? _R : _efn(data);
@@ -1207,31 +1293,29 @@ class Validator {
1207
1293
  } else if (jsFn && jsFn._hybridFactory) {
1208
1294
  // Zero-wrapper: hybridFactory bakes VALID_RESULT + errFn into a single function
1209
1295
  // No arrow function wrapper, no ternary, one function call
1210
- const hybridFn = jsFn._hybridFactory(VALID_RESULT, safeCombinedFn || errFn);
1296
+ // The factory bakes the error function in as an argument and never
1297
+ // calls it for a document that passes, so a resolver here costs the
1298
+ // accepted path nothing and keeps the compile off the first call.
1299
+ const hybridFn = jsFn._hybridFactory(VALID_RESULT, errPreferCombined);
1211
1300
  this.validate = preprocess
1212
1301
  ? (data) => { preprocess(data); return hybridFn(data); }
1213
1302
  : hybridFn;
1214
- } else if (safeCombinedFn) {
1215
- this.validate = preprocess
1216
- ? (data) => { preprocess(data); return safeCombinedFn(data); }
1217
- : safeCombinedFn;
1218
1303
  } else {
1219
- const hybridFn = jsFn && jsFn._hybridFactory
1220
- ? jsFn._hybridFactory(VALID_RESULT, errFn)
1221
- : null;
1222
- this.validate = hybridFn
1223
- ? preprocess
1224
- ? (data) => {
1225
- preprocess(data);
1226
- return hybridFn(data);
1227
- }
1228
- : hybridFn
1229
- : preprocess
1304
+ // No hybrid factory, so the assembly needs the function itself rather
1305
+ // than a reference it can call later: build it now.
1306
+ const safeCombinedFn = combinedIfSafe();
1307
+ if (safeCombinedFn) {
1308
+ this.validate = preprocess
1309
+ ? (data) => { preprocess(data); return safeCombinedFn(data); }
1310
+ : safeCombinedFn;
1311
+ } else {
1312
+ this.validate = preprocess
1230
1313
  ? (data) => {
1231
1314
  preprocess(data);
1232
- return jsFn(data) ? VALID_RESULT : errFn(data);
1315
+ return jsFn(data) ? VALID_RESULT : errOnly(data);
1233
1316
  }
1234
- : (data) => (jsFn(data) ? VALID_RESULT : errFn(data));
1317
+ : (data) => (jsFn(data) ? VALID_RESULT : errOnly(data));
1318
+ }
1235
1319
  }
1236
1320
  // Verbose mode: populate parentSchema, schema and data on each error, the
1237
1321
  // three fields the default error shape carries under the same option.
@@ -1274,12 +1358,15 @@ class Validator {
1274
1358
  this.isValidObject = preprocess
1275
1359
  ? (data) => { preprocess(data); return jsFn(data) }
1276
1360
  : jsFn;
1361
+ // Same preference as the object path: the combined function first, since
1362
+ // it validates and collects in one pass, and the error generator behind
1363
+ // it. `errPreferCombined` is that order, resolved on the first rejection
1364
+ // instead of at compile time.
1277
1365
  const hybridFn = jsFn._hybridFactory
1278
- ? jsFn._hybridFactory(VALID_RESULT, errFn)
1366
+ ? jsFn._hybridFactory(VALID_RESULT, errPreferCombined)
1279
1367
  : null;
1280
- const jsonValidateInner = safeCombinedFn
1281
- || hybridFn
1282
- || ((obj) => (jsFn(obj) ? VALID_RESULT : errFn(obj)));
1368
+ const jsonValidateInner = hybridFn
1369
+ || ((obj) => (jsFn(obj) ? VALID_RESULT : errPreferCombined(obj)));
1283
1370
  // Parsed text takes the same preprocess pass as a parsed object, so
1284
1371
  // validate(obj) and validateJSON(text) answer the same for the same
1285
1372
  // document. Without it, coercion, removal and defaults applied on one
@@ -1341,6 +1428,23 @@ class Validator {
1341
1428
  return verdictFromText(jsonStr);
1342
1429
  }
1343
1430
  : verdictFromText;
1431
+
1432
+ // A schema-directed scanner answers the verdict from the JSON text
1433
+ // without building the document. Parsing is around three quarters of the
1434
+ // cost of a real request, and a caller that only wants yes or no should
1435
+ // not pay it; a rejection can also stop at the byte that caused it
1436
+ // instead of parsing the rest of a document that is already refused.
1437
+ //
1438
+ // It is wired only where the verdict IS the answer. On a path that has
1439
+ // to produce errors, scanning an invalid document is work thrown away,
1440
+ // so those keep parsing. `abortEarly` has no errors to produce, so it
1441
+ // counts as a verdict path.
1442
+ //
1443
+ // Not wired when a preprocess pass is configured: coercion, removal and
1444
+ // defaults rewrite the document before it is judged, and the scanner
1445
+ // reads what arrived. The compiler declines any schema it cannot answer
1446
+ // and a compiled scanner returns BAIL for a document shape it cannot
1447
+ // answer, and then the parse path below takes over unchanged.
1344
1448
  // validateAndParse: parse the JSON, then validate. Pure JS (JSON.parse +
1345
1449
  // validate) so it works with or without the native addon and in browsers.
1346
1450
  {
@@ -1714,6 +1818,83 @@ class Validator {
1714
1818
  const { bufferNeedsSlowPath, installSlowBufferApis } = require('./lib/buffer-gate.js');
1715
1819
  if (bufferNeedsSlowPath(schemaObj, this._schemaMap, this._keywords)) installSlowBufferApis(this);
1716
1820
  }
1821
+ // Installed after the buffer gate on purpose: the gate replaces
1822
+ // isValidJSON for schemas whose shapes the native walker gets wrong,
1823
+ // unevaluatedProperties among them, and the scanner wiring has to wrap
1824
+ // whatever answers last or a gated schema silently loses its scanner.
1825
+ if (this._jsFn && !this._preprocess) {
1826
+ const self = this;
1827
+ // Generating a scanner costs about 20 microseconds, measured, and it
1828
+ // saves from around 85 nanoseconds on a small accepted document to
1829
+ // several microseconds on a rejected one. Building it on the first
1830
+ // call would therefore be a straight loss for a caller that checks one
1831
+ // document and exits, so it is built once a caller has asked often
1832
+ // enough that it is plainly doing this in a loop. A server passes the
1833
+ // line during warm-up and never sees it.
1834
+ const SCAN_AFTER = 64;
1835
+ let calls = 0;
1836
+ // undefined: not built. null: this schema has no scanner. Passing true
1837
+ // builds it now, which is how the differential test reaches it.
1838
+ this._ensureScanner = (now) => {
1839
+ if (self._scanner === undefined) {
1840
+ if (!now && ++calls < SCAN_AFTER) return undefined;
1841
+ const built = require('./lib/scan-compiler').compileScanner(schemaObj, { userFormats: self._userFormats });
1842
+ self._scanner = built ? built.scan : null;
1843
+ }
1844
+ return self._scanner;
1845
+ };
1846
+ const byParsing = this.isValidJSON;
1847
+ // The verdict is a pure function of the text, and the caller a
1848
+ // gateway or a drift monitor keeps asking about is usually the same
1849
+ // text: a config file re-read on a timer, a heartbeat body. One
1850
+ // remembered (text, verdict) pair answers that case with a native
1851
+ // string compare, which is a memcmp, instead of a scan. Withheld when
1852
+ // user formats or custom keywords are present, since those are user
1853
+ // functions and nothing guarantees they are pure.
1854
+ const memoizable = !self._userFormats && !self._usesKeywords;
1855
+ let _memoText = null;
1856
+ let _memoVerdict = false;
1857
+ this.isValidJSON = (jsonStr) => {
1858
+ const scan = self._ensureScanner();
1859
+ if (scan === undefined) return byParsing(jsonStr);
1860
+ if (scan === null) { self.isValidJSON = byParsing; return byParsing(jsonStr); }
1861
+ self.isValidJSON = memoizable
1862
+ ? (text) => {
1863
+ if (typeof text !== 'string') return byParsing(text);
1864
+ if (text === _memoText) return _memoVerdict;
1865
+ const r = scan(text);
1866
+ const verdict = r === -1 ? byParsing(text) : r === 1;
1867
+ _memoText = text;
1868
+ _memoVerdict = verdict;
1869
+ return verdict;
1870
+ }
1871
+ : (text) => {
1872
+ if (typeof text !== 'string') return byParsing(text);
1873
+ const r = scan(text);
1874
+ if (r === -1) return byParsing(text);
1875
+ return r === 1;
1876
+ };
1877
+ return self.isValidJSON(jsonStr);
1878
+ };
1879
+ if (options.abortEarly) {
1880
+ const validateByParsing = this.validateJSON;
1881
+ this.validateJSON = (jsonStr) => {
1882
+ const scan = self._ensureScanner();
1883
+ if (scan === undefined) return validateByParsing(jsonStr);
1884
+ if (scan === null) { self.validateJSON = validateByParsing; return validateByParsing(jsonStr); }
1885
+ self.validateJSON = (text) => {
1886
+ if (typeof text === 'string') {
1887
+ const r = scan(text);
1888
+ if (r === 1) return VALID_RESULT;
1889
+ if (r === 0) return ABORT_EARLY_RESULT;
1890
+ }
1891
+ return validateByParsing(text);
1892
+ };
1893
+ return self.validateJSON(jsonStr);
1894
+ };
1895
+ }
1896
+ }
1897
+
1717
1898
 
1718
1899
  // Save to identity cache for ultra-fast reuse with same schema object.
1719
1900
  // Only an instance built without options may answer a later
@@ -1799,15 +1980,20 @@ class Validator {
1799
1980
  return;
1800
1981
  }
1801
1982
  const uf = this._userFormats;
1802
- const jsFn = compileToJSCodegen(this._schemaObj, sm, uf) || compileToJS(this._schemaObj, null, sm);
1983
+ const _cg = compileToJSCodegen(this._schemaObj, sm, uf);
1984
+ const jsFn = _cg || compileToJS(this._schemaObj, null, sm);
1803
1985
  this._jsFn = jsFn;
1804
1986
  if (jsFn) {
1805
1987
  this.isValidObject = jsFn;
1806
1988
  // A partial entry: the verdict function is real, the other two are not
1807
- // built yet rather than declined. `full: false` says so, so the next
1808
- // caller that needs errors compiles them instead of inheriting nulls.
1989
+ // built yet rather than declined. `undefined` is the not-built marker
1990
+ // the full compile's _buildDeferred looks for; `null` would read as
1991
+ // "the compiler declined" and cost the schema its error function, which
1992
+ // is the bug this cache had once already. `isCodegen` rides along so a
1993
+ // validator that later reuses this entry reports the same engine it
1994
+ // would have compiled to.
1809
1995
  if (!uf) {
1810
- if (!cached) _compileCache.set(mapKey, { jsFn, combined: null, errFn: null, full: false });
1996
+ if (!cached) _compileCache.set(mapKey, { jsFn, combined: undefined, errFn: undefined, isCodegen: !!_cg, full: false });
1811
1997
  else cached.jsFn = jsFn;
1812
1998
  }
1813
1999
  }
package/lib/aot-build.js CHANGED
@@ -94,6 +94,7 @@ async function build(opts) {
94
94
  const cached = [];
95
95
  const skipped = [];
96
96
  const failed = [];
97
+ const warnings = [];
97
98
 
98
99
  for (const input of inputs) {
99
100
  try {
@@ -126,6 +127,18 @@ async function build(opts) {
126
127
  continue;
127
128
  }
128
129
  const schema = parseSchemaFile(input);
130
+ // The build is where an authoring mistake is cheapest to stop: a typo
131
+ // like maxLenght compiles into a validator that simply lacks the rule,
132
+ // and a compiled module fails even more quietly than a runtime one,
133
+ // since nobody ever revisits it. Findings fail the input's build.
134
+ if (opts.strictSchema) {
135
+ const { checkSchemaStrict } = require('./strict-check');
136
+ const problems = checkSchemaStrict(schema, {});
137
+ if (problems.length > 0) {
138
+ failed.push({ input, error: problems.map((x) => `strict mode: ${x.message} at ${x.path}`).join('; ') });
139
+ continue;
140
+ }
141
+ }
129
142
  const v = new Validator(schema);
130
143
  const source = resolveSourceDefault(opts);
131
144
  let sourceMap = null;
@@ -138,6 +151,7 @@ async function build(opts) {
138
151
  }
139
152
  }
140
153
  const schemaFile = path.relative(process.cwd(), input) || input;
154
+ const inputWarnings = [];
141
155
  const src = aot.toStandaloneModule(v, {
142
156
  format,
143
157
  abortEarly: !!opts.abortEarly,
@@ -145,7 +159,15 @@ async function build(opts) {
145
159
  sourceMap,
146
160
  schemaFile,
147
161
  formatMode: opts.formatMode,
162
+ onWarning: (w) => inputWarnings.push(w),
148
163
  });
164
+ if (inputWarnings.length > 0) {
165
+ if (opts.strict) {
166
+ failed.push({ input, error: inputWarnings.join('; ') });
167
+ continue;
168
+ }
169
+ for (const w of inputWarnings) warnings.push({ input, warning: w });
170
+ }
149
171
  if (!src) {
150
172
  const reason = 'schema is not AOT-compatible (toStandaloneModule returned null)';
151
173
  if (opts.strict) failed.push({ input, error: reason });
@@ -190,12 +212,12 @@ async function build(opts) {
190
212
 
191
213
  if (opts.check) {
192
214
  const staleCount = inputs.length - cached.length;
193
- return { compiled: [], cached, skipped, failed, staleCount };
215
+ return { compiled: [], cached, skipped, failed, warnings, staleCount };
194
216
  }
195
217
 
196
218
  writeCache(opts.cacheFile, newCache);
197
219
 
198
- return { compiled, cached, skipped, failed };
220
+ return { compiled, cached, skipped, failed, warnings };
199
221
  }
200
222
 
201
223
  async function watch(opts, onReport) {