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 +14 -0
- package/bin/ata.js +27 -1
- package/index.js +96 -0
- package/lib/aot-build.js +24 -2
- package/lib/aot-impl.js +14 -1
- package/lib/scan-compiler.js +1007 -0
- package/lib/scan-runtime.js +158 -0
- package/lib/strict-check.js +53 -0
- package/lib/version.js +1 -1
- package/package.json +9 -9
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
|
|
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({
|