ata-validator 1.23.0 → 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,20 @@
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
+
5
19
  ## 1.23.0 - 2026-09-16
6
20
 
7
21
  ### Added
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/index.js CHANGED
@@ -933,6 +933,8 @@ class Validator {
933
933
  // to attach dataFrame entries to each enriched error.
934
934
  this._posCache = null; // created by _pos() on first use, only the JSON text path needs it
935
935
  this._lastRawInput = null;
936
+ // undefined: not built yet. null: this schema has no scanner.
937
+ this._scanner = undefined;
936
938
 
937
939
  // Public methods start as memoized accessors on the prototype; nothing is
938
940
  // allocated per instance until one is first read. See _defineLazyMethod
@@ -1426,6 +1428,23 @@ class Validator {
1426
1428
  return verdictFromText(jsonStr);
1427
1429
  }
1428
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.
1429
1448
  // validateAndParse: parse the JSON, then validate. Pure JS (JSON.parse +
1430
1449
  // validate) so it works with or without the native addon and in browsers.
1431
1450
  {
@@ -1799,6 +1818,83 @@ class Validator {
1799
1818
  const { bufferNeedsSlowPath, installSlowBufferApis } = require('./lib/buffer-gate.js');
1800
1819
  if (bufferNeedsSlowPath(schemaObj, this._schemaMap, this._keywords)) installSlowBufferApis(this);
1801
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
+
1802
1898
 
1803
1899
  // Save to identity cache for ultra-fast reuse with same schema object.
1804
1900
  // Only an instance built without options may answer a later
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) {
package/lib/aot-impl.js CHANGED
@@ -269,6 +269,16 @@ function toStandaloneModule(validator, opts) {
269
269
  const errSrc = jsErrFn && jsErrFn._errSource ? jsErrFn._errSource : '';
270
270
  if (errSrc) {
271
271
  errCore = `const errFn = function(d, _all) {\n ${errSrc}\n};\n`;
272
+ } else if (opts && typeof opts.onWarning === 'function') {
273
+ // The caller asked for error detail and is not getting it: the error
274
+ // generator declined this schema (unevaluated* and a few other shapes),
275
+ // and a standalone module has no interpreted engine to fall back to the
276
+ // way the runtime validator does. The module still ships, with the
277
+ // verdict exact, but every failure reports the single ATA9000 stub.
278
+ // Silence here cost a user a debugging session; hence the channel.
279
+ opts.onWarning(
280
+ 'error detail could not be generated for this schema; the module reports failures as the single ATA9000 abort-early error. The verdict is unaffected. For detailed errors, validate failing documents with the runtime Validator.'
281
+ );
272
282
  }
273
283
  }
274
284
 
@@ -379,9 +389,12 @@ function toStandaloneModule(validator, opts) {
379
389
  ? `export { ${names}${parseAlias} };\nexport default { ${names}${parseProp} };\n`
380
390
  : `module.exports = { ${names}${parseProp} };\nmodule.exports.default = module.exports;\n`;
381
391
 
392
+ const degradedNote = (!abortEarly && !errCore)
393
+ ? '// 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
+ : '';
382
395
  return `// Auto-generated by ata-validator — do not edit.
383
396
  // Schema is embedded; runtime has zero dependency on ata-validator.
384
- 'use strict';
397
+ ${degradedNote}'use strict';
385
398
  ${_CP_LEN_SOURCE}
386
399
  ${safeRePrelude(jsFn, jsErrFn)}${schemaSourceConst}const VALID = Object.freeze({ valid: true, errors: Object.freeze([]) });
387
400
  const ABORT = Object.freeze({