ata-validator 1.19.0 → 1.21.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/index.js +24 -4
- package/lib/aot-impl.js +116 -21
- package/lib/branch-collapse.js +72 -47
- package/lib/describe-schema.js +176 -0
- package/lib/interpreter.js +40 -11
- package/lib/js-compiler.js +53 -24
- package/lib/output-format.js +140 -0
- package/lib/plan-compiler.js +61 -15
- package/lib/retry-message.js +52 -0
- package/lib/version.js +1 -1
- package/package.json +9 -9
package/index.js
CHANGED
|
@@ -1461,9 +1461,18 @@ class Validator {
|
|
|
1461
1461
|
});
|
|
1462
1462
|
this._engine = 'interpreter';
|
|
1463
1463
|
if (!preprocess) this._fastVerdict = (d) => interp.isValid(d);
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1464
|
+
// abortEarly is a documented contract, not a property of whichever engine
|
|
1465
|
+
// answered: it promises the frozen ATA9000 stub instead of a detailed
|
|
1466
|
+
// error, so code that branches on it has to behave the same with and
|
|
1467
|
+
// without code generation. Taking the verdict path here also skips
|
|
1468
|
+
// building the errors the caller said it did not want.
|
|
1469
|
+
const run = options.abortEarly
|
|
1470
|
+
? (preprocess
|
|
1471
|
+
? (data) => { preprocess(data); return interp.isValid(data) ? VALID_RESULT : ABORT_EARLY_RESULT; }
|
|
1472
|
+
: (data) => (interp.isValid(data) ? VALID_RESULT : ABORT_EARLY_RESULT))
|
|
1473
|
+
: (preprocess
|
|
1474
|
+
? (data) => { preprocess(data); return interp.validate(data); }
|
|
1475
|
+
: (data) => interp.validate(data));
|
|
1467
1476
|
this.validate = run;
|
|
1468
1477
|
this.isValidObject = this._fastVerdict
|
|
1469
1478
|
? this._fastVerdict
|
|
@@ -2039,6 +2048,9 @@ function compile(schema, opts) {
|
|
|
2039
2048
|
const { toTypeScript } = require("./lib/ts-gen");
|
|
2040
2049
|
const { renderPretty } = require("./lib/render-pretty");
|
|
2041
2050
|
const { renderCompact } = require("./lib/render-compact");
|
|
2051
|
+
const { toOutput } = require("./lib/output-format");
|
|
2052
|
+
const { toRetryMessage } = require("./lib/retry-message");
|
|
2053
|
+
const { describeSchema } = require("./lib/describe-schema");
|
|
2042
2054
|
const { renderJSON } = require("./lib/render-json");
|
|
2043
2055
|
const { suggestFor } = require("./lib/suggestions");
|
|
2044
2056
|
const { reprValue } = require("./lib/enrich-error");
|
|
@@ -2212,7 +2224,12 @@ _defineLazyMethod('isValidObject', (self) => (data) => {
|
|
|
2212
2224
|
return r;
|
|
2213
2225
|
};
|
|
2214
2226
|
} else {
|
|
2215
|
-
|
|
2227
|
+
// `new Function` is a property of the realm, not of the schema: under a
|
|
2228
|
+
// strict CSP or `--disallow-code-generation-from-strings` this throws
|
|
2229
|
+
// rather than declining, and the EvalError reached the caller of a verdict
|
|
2230
|
+
// method. The full compile can answer without code generation, so fall
|
|
2231
|
+
// through to it. The tier-0 branch above already guarded its own call.
|
|
2232
|
+
try { self._ensureCodegen(); } catch { /* no codegen in this realm */ }
|
|
2216
2233
|
// Codegen can bail on shapes it cannot represent; the full compile
|
|
2217
2234
|
// binds the native path or the unsupported thrower instead of
|
|
2218
2235
|
// leaving this stub to re-dispatch to itself.
|
|
@@ -2263,6 +2280,9 @@ module.exports = {
|
|
|
2263
2280
|
defineSchema,
|
|
2264
2281
|
renderPretty,
|
|
2265
2282
|
renderCompact,
|
|
2283
|
+
toOutput,
|
|
2284
|
+
toRetryMessage,
|
|
2285
|
+
describeSchema,
|
|
2266
2286
|
renderJSON,
|
|
2267
2287
|
attachSuggestions, // internal: used by the renderers; not public API
|
|
2268
2288
|
};
|
package/lib/aot-impl.js
CHANGED
|
@@ -54,13 +54,26 @@ function safeRePrelude(...fns) {
|
|
|
54
54
|
return fns.some((f) => f && f._usesSafeRe) ? getSafeRegexEmbed() + '\n' : '';
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// A validator can enforce more than its schema says. `withKeywords` from
|
|
58
|
+
// @ata-project/keywords wraps an instance's entry points, and a wrapper
|
|
59
|
+
// declares that by setting `_externalChecks`. The emitters below build their
|
|
60
|
+
// module from the compiled schema alone, so a wrapped instance would emit a
|
|
61
|
+
// module that accepts documents the validator itself rejects. Refuse instead.
|
|
62
|
+
// Declining to compile is recoverable. A module that wrongly accepts is not.
|
|
63
|
+
function assertEmittable(validator, fnName) {
|
|
64
|
+
if (validator._usesKeywords) {
|
|
65
|
+
throw new Error(fnName + ': a schema that uses custom keywords cannot be compiled into a standalone module (option "keywords")');
|
|
66
|
+
}
|
|
67
|
+
if (validator._externalChecks) {
|
|
68
|
+
throw new Error(fnName + ': this validator enforces checks that are not in its schema, so a standalone module would be weaker than the validator it came from. Those checks come from a wrapper such as withKeywords() from @ata-project/keywords, and JSON Schema has no spelling for them. Compile the unwrapped validator if the extra checks are not needed.');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
// --- Standalone pre-compilation ---
|
|
58
73
|
// Generate a JS module string that can be written to a file.
|
|
59
74
|
// On next startup, load with Validator.fromStandalone() -- zero compile time.
|
|
60
75
|
function toStandalone(validator) {
|
|
61
|
-
|
|
62
|
-
throw new Error('toStandalone: a schema that uses custom keywords cannot be compiled into a standalone module (option "keywords")');
|
|
63
|
-
}
|
|
76
|
+
assertEmittable(validator, 'toStandalone');
|
|
64
77
|
validator._ensureCompiled();
|
|
65
78
|
const jsFn = validator._jsFn;
|
|
66
79
|
if (!jsFn || !jsFn._source) return null;
|
|
@@ -233,9 +246,7 @@ function emitClone(node, access, depth) {
|
|
|
233
246
|
}
|
|
234
247
|
|
|
235
248
|
function toStandaloneModule(validator, opts) {
|
|
236
|
-
|
|
237
|
-
throw new Error('toStandaloneModule: a schema that uses custom keywords cannot be compiled into a standalone module (option "keywords")');
|
|
238
|
-
}
|
|
249
|
+
assertEmittable(validator, 'toStandaloneModule');
|
|
239
250
|
validator._ensureCompiled();
|
|
240
251
|
const jsFn = validator._jsFn;
|
|
241
252
|
if (!jsFn || !jsFn._source) return null;
|
|
@@ -299,10 +310,39 @@ function toStandaloneModule(validator, opts) {
|
|
|
299
310
|
if (lines.length) closureDecls = lines.join('\n') + '\n';
|
|
300
311
|
}
|
|
301
312
|
|
|
313
|
+
// A few helpers are self-contained and named the same wherever they appear,
|
|
314
|
+
// the email check being the big one at about 2.7 KB. The boolean function
|
|
315
|
+
// and the error function each hoisted their own copy, so every module that
|
|
316
|
+
// used a format carried it twice. They are declared once at module scope
|
|
317
|
+
// here and removed from both bodies. Everything else in the preamble stays
|
|
318
|
+
// where it is: emitConstant and the generated branch checks name things per
|
|
319
|
+
// compilation, and two of them at module scope would collide.
|
|
320
|
+
const sharedDecls = [];
|
|
321
|
+
{
|
|
322
|
+
const seen = new Set();
|
|
323
|
+
for (const fn of [jsFn, jsErrFn]) {
|
|
324
|
+
if (!fn || !fn._sharedHelpers) continue;
|
|
325
|
+
for (const h of fn._sharedHelpers) if (!seen.has(h)) { seen.add(h); sharedDecls.push(h); }
|
|
326
|
+
}
|
|
327
|
+
if (sharedDecls.length && errCore) {
|
|
328
|
+
for (const h of sharedDecls) errCore = errCore.split(h).join('');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const sharedBlock = sharedDecls.length ? sharedDecls.join('\n') + '\n' : '';
|
|
332
|
+
|
|
302
333
|
// Hoisted oneOf/anyOf branch checks live in the boolean fn's preamble (the
|
|
303
334
|
// runtime emits them before the function). The standalone module must declare
|
|
304
335
|
// them at module scope too, or _fn references undefined names (e.g. _af1_b0).
|
|
305
|
-
const
|
|
336
|
+
const sharedSet = new Set(sharedDecls);
|
|
337
|
+
const preambleParts = jsFn._preambleParts
|
|
338
|
+
? jsFn._preambleParts.filter((part) => !sharedSet.has(part))
|
|
339
|
+
: null;
|
|
340
|
+
const preambleBody = preambleParts !== null
|
|
341
|
+
? (preambleParts.length ? preambleParts.join('\n ') + '\n ' : '')
|
|
342
|
+
: (jsFn._preambleSource || '');
|
|
343
|
+
const preambleDecls = preambleBody || (jsFn._preambleGuard || '')
|
|
344
|
+
? (jsFn._preambleParts ? (jsFn._preambleGuard || '') + preambleBody : preambleBody) + '\n'
|
|
345
|
+
: '';
|
|
306
346
|
|
|
307
347
|
// User-supplied format functions are referenced as _uf_<name> by both the
|
|
308
348
|
// boolean (_fn) and error (errFn) bodies. Embed them via Function#toString
|
|
@@ -353,7 +393,7 @@ const ABORT = Object.freeze({
|
|
|
353
393
|
path: '',
|
|
354
394
|
})]),
|
|
355
395
|
});
|
|
356
|
-
${closureDecls}${preambleDecls}${formatDecls}const _fn = function(d) {
|
|
396
|
+
${closureDecls}${sharedBlock}${preambleDecls}${formatDecls}const _fn = function(d) {
|
|
357
397
|
${src}
|
|
358
398
|
};
|
|
359
399
|
${errCore}function isValid(data) { return _fn(data); }
|
|
@@ -404,11 +444,14 @@ function bundleStandalone(Validator, schemas, opts) {
|
|
|
404
444
|
const R = 'Object.freeze({valid:true,errors:Object.freeze([])})';
|
|
405
445
|
let bundleUsesSafeRe = false;
|
|
406
446
|
let bundleInjects = false;
|
|
407
|
-
|
|
447
|
+
// Pass one compiles; pass two emits. The helpers that several schemas hoist
|
|
448
|
+
// are the same text declaring the same name, so the bundle keeps one copy at
|
|
449
|
+
// module scope, and pass two needs to know the set before it writes anything.
|
|
450
|
+
const compiledEntries = schemas.map((schema) => {
|
|
408
451
|
const v = new Validator(schema, bundleOpts);
|
|
409
452
|
v._ensureCompiled();
|
|
410
453
|
const jsFn = v._jsFn;
|
|
411
|
-
if (!jsFn || !jsFn._hybridSource) return
|
|
454
|
+
if (!jsFn || !jsFn._hybridSource) return null;
|
|
412
455
|
// The schema the validator compiled, not the one the caller passed. They
|
|
413
456
|
// differ whenever anything prepared the schema: draft-07 normalization,
|
|
414
457
|
// `format` removed under assertFormat: false, keywords outside the
|
|
@@ -420,10 +463,30 @@ function bundleStandalone(Validator, schemas, opts) {
|
|
|
420
463
|
v._userFormats,
|
|
421
464
|
);
|
|
422
465
|
if (jsFn._usesSafeRe || (jsErrFn && jsErrFn._usesSafeRe)) bundleUsesSafeRe = true;
|
|
423
|
-
|
|
466
|
+
return { v, jsFn, jsErrFn };
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const sharedDecls = [];
|
|
470
|
+
{
|
|
471
|
+
const seen = new Set();
|
|
472
|
+
for (const e of compiledEntries) {
|
|
473
|
+
if (!e) continue;
|
|
474
|
+
for (const fn of [e.jsFn, e.jsErrFn]) {
|
|
475
|
+
if (!fn || !fn._sharedHelpers) continue;
|
|
476
|
+
for (const h of fn._sharedHelpers) if (!seen.has(h)) { seen.add(h); sharedDecls.push(h); }
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const sharedSet = new Set(sharedDecls);
|
|
481
|
+
|
|
482
|
+
const fns = compiledEntries.map((e) => {
|
|
483
|
+
if (!e) return 'null';
|
|
484
|
+
const { v, jsFn, jsErrFn } = e;
|
|
485
|
+
let errBody =
|
|
424
486
|
jsErrFn && jsErrFn._errSource
|
|
425
487
|
? jsErrFn._errSource
|
|
426
488
|
: "return{valid:false,errors:[{code:'error',path:'',message:'validation failed'}]}";
|
|
489
|
+
for (const h of sharedDecls) errBody = errBody.split(h).join('');
|
|
427
490
|
// Custom format closures: embedded, or bound to the bundle-level
|
|
428
491
|
// registry when opts.formats is 'inject'.
|
|
429
492
|
let preamble = '';
|
|
@@ -433,11 +496,14 @@ function bundleStandalone(Validator, schemas, opts) {
|
|
|
433
496
|
if (f.exportsSetFormats) bundleInjects = true;
|
|
434
497
|
}
|
|
435
498
|
// Include hoisted anyOf/oneOf branch helpers (e.g. `_af1_b0`) so the
|
|
436
|
-
// bundle output is self-contained
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
499
|
+
// bundle output is self-contained, minus anything lifted to module scope.
|
|
500
|
+
const kept = jsFn._preambleParts
|
|
501
|
+
? jsFn._preambleParts.filter((part) => !sharedSet.has(part))
|
|
502
|
+
: null;
|
|
503
|
+
const preambleSrc = kept !== null
|
|
504
|
+
? (jsFn._preambleGuard || '') + (kept.length ? kept.join('\n ') + '\n ' : '')
|
|
505
|
+
: (jsFn._preambleSource || '');
|
|
506
|
+
if (preambleSrc) preamble = preamble ? `${preamble}\n${preambleSrc}` : preambleSrc;
|
|
441
507
|
if (opts && opts.verbose) {
|
|
442
508
|
// Embed the schema and a small resolver so errors carry parentSchema.
|
|
443
509
|
const schemaLit = JSON.stringify(v._schemaObj);
|
|
@@ -446,7 +512,8 @@ function bundleStandalone(Validator, schemas, opts) {
|
|
|
446
512
|
return `(function(R){${preamble}var E=function(d){var _all=true;${errBody}};return function(d){${jsFn._hybridSource}}})(R)`;
|
|
447
513
|
});
|
|
448
514
|
const arr = `[${fns.join(',')}]`;
|
|
449
|
-
const safeEmbed = bundleUsesSafeRe ? getSafeRegexEmbed() + '\n' : ''
|
|
515
|
+
const safeEmbed = (bundleUsesSafeRe ? getSafeRegexEmbed() + '\n' : '') +
|
|
516
|
+
(sharedDecls.length ? sharedDecls.join('\n') + '\n' : '');
|
|
450
517
|
const registry = bundleInjects
|
|
451
518
|
? `var __formats=Object.create(null);\nfunction setFormats(map){for(var k in map)__formats[k]=map[k]}\n`
|
|
452
519
|
: '';
|
|
@@ -489,16 +556,43 @@ function bundleCompact(Validator, schemas, opts) {
|
|
|
489
556
|
// Hoisted anyOf/oneOf branch helpers (e.g. `_af1_b0`) must travel with the
|
|
490
557
|
// hybrid body or it references undefined names. Prepending keeps dedup honest:
|
|
491
558
|
// schemas with different branch sets no longer collide on body alone.
|
|
492
|
-
const hybrid = jsFn._preambleSource
|
|
493
|
-
? `${jsFn._preambleSource}\n${jsFn._hybridSource}`
|
|
494
|
-
: jsFn._hybridSource;
|
|
495
559
|
return {
|
|
496
|
-
|
|
560
|
+
guard: jsFn._preambleGuard || '',
|
|
561
|
+
parts: jsFn._preambleParts || null,
|
|
562
|
+
preamble: jsFn._preambleSource || '',
|
|
563
|
+
hybridBody: jsFn._hybridSource,
|
|
497
564
|
err: jsErrFn && jsErrFn._errSource ? jsErrFn._errSource : null,
|
|
498
565
|
fmt: jsFn._formatClosures || null,
|
|
566
|
+
shared: [
|
|
567
|
+
...(jsFn._sharedHelpers || []),
|
|
568
|
+
...((jsErrFn && jsErrFn._sharedHelpers) || []),
|
|
569
|
+
],
|
|
499
570
|
};
|
|
500
571
|
});
|
|
501
572
|
|
|
573
|
+
// A helper hoisted by several schemas is the same text declaring the same
|
|
574
|
+
// name, so the bundle holds one copy at module scope rather than one per
|
|
575
|
+
// schema. The rest of each preamble stays with its schema: those names are
|
|
576
|
+
// per compilation and would collide.
|
|
577
|
+
const sharedDecls = [];
|
|
578
|
+
{
|
|
579
|
+
const seen = new Set();
|
|
580
|
+
for (const e of entries) {
|
|
581
|
+
if (!e) continue;
|
|
582
|
+
for (const h of e.shared) if (!seen.has(h)) { seen.add(h); sharedDecls.push(h); }
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const sharedSet = new Set(sharedDecls);
|
|
586
|
+
for (const e of entries) {
|
|
587
|
+
if (!e) continue;
|
|
588
|
+
const kept = e.parts ? e.parts.filter((part) => !sharedSet.has(part)) : null;
|
|
589
|
+
const preamble = kept !== null
|
|
590
|
+
? e.guard + (kept.length ? kept.join('\n ') + '\n ' : '')
|
|
591
|
+
: e.preamble;
|
|
592
|
+
e.hybrid = preamble ? `${preamble}\n${e.hybridBody}` : e.hybridBody;
|
|
593
|
+
if (e.err) for (const h of sharedDecls) e.err = e.err.split(h).join('');
|
|
594
|
+
}
|
|
595
|
+
|
|
502
596
|
// Deduplicate function bodies — many schemas produce identical or near-identical code
|
|
503
597
|
const bodyMap = new Map(); // body → index
|
|
504
598
|
const bodies = [];
|
|
@@ -531,6 +625,7 @@ function bundleCompact(Validator, schemas, opts) {
|
|
|
531
625
|
? '// Auto-generated by ata-validator — do not edit\n'
|
|
532
626
|
: "'use strict';\n";
|
|
533
627
|
if (bundleUsesSafeRe) out += getSafeRegexEmbed() + '\n';
|
|
628
|
+
if (sharedDecls.length) out += sharedDecls.join('\n') + '\n';
|
|
534
629
|
const declKW = isEsm ? 'const' : 'var';
|
|
535
630
|
out += `${declKW} R=Object.freeze({valid:true,errors:Object.freeze([])});\n`;
|
|
536
631
|
|
package/lib/branch-collapse.js
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Branch collapse: when no branch of an anyOf or oneOf matches, listing every
|
|
4
|
+
// branch's errors tells a reader that nothing fit without telling them which
|
|
5
|
+
// branch they meant. This scores the branches, reports the one that came
|
|
6
|
+
// closest, and keeps its errors under `branchErrors` for the pretty renderer.
|
|
7
|
+
//
|
|
8
|
+
// There is one implementation, and it is `__ataCollapse` below. A standalone
|
|
9
|
+
// module imports nothing, so the generated code cannot require this file; it
|
|
10
|
+
// carries a copy emitted by `embedSource()` from these very functions instead
|
|
11
|
+
// of a second hand-written one. That is why the internals are written in the
|
|
12
|
+
// positional form the generated code calls, and why `__ataCollapse` may close
|
|
13
|
+
// over nothing but `__ataScore` and the severity table: whatever it reaches
|
|
14
|
+
// has to be in the embed too.
|
|
15
|
+
|
|
16
|
+
// How much a failing keyword says about intent. A branch that fails on `type`
|
|
17
|
+
// was probably not the branch the author meant; one that fails on `minLength`
|
|
18
|
+
// probably was, and is the better thing to show. The error count dominates, so
|
|
19
|
+
// a branch with one complaint always beats a branch with three.
|
|
20
|
+
const __ATA_SEVERITY = {
|
|
4
21
|
type: 10,
|
|
5
22
|
const: 8,
|
|
6
23
|
enum: 8,
|
|
@@ -15,61 +32,69 @@ const SEVERITY = {
|
|
|
15
32
|
unevaluatedProperties: 2,
|
|
16
33
|
unevaluatedItems: 2,
|
|
17
34
|
};
|
|
18
|
-
const DEFAULT_SEVERITY = 4;
|
|
19
35
|
|
|
20
|
-
function
|
|
21
|
-
if (!
|
|
22
|
-
let
|
|
23
|
-
for (const e of
|
|
24
|
-
return
|
|
36
|
+
function __ataScore (errs) {
|
|
37
|
+
if (!errs || !errs.length) return 0;
|
|
38
|
+
let s = 0;
|
|
39
|
+
for (const e of errs) s += __ATA_SEVERITY[e.keyword] || 4;
|
|
40
|
+
return errs.length * 100 + s; // primary: count, secondary: severity
|
|
25
41
|
}
|
|
26
42
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
*/
|
|
37
|
-
function collapseBranches ({ keyword, branchResults, parentPath, parentSchemaPath }) {
|
|
38
|
-
const passing = branchResults.filter(b => b.valid);
|
|
39
|
-
if (keyword === 'oneOf') {
|
|
40
|
-
if (passing.length === 1) return null;
|
|
41
|
-
if (passing.length > 1) {
|
|
43
|
+
// `br` is one entry per branch in declaration order: {valid, errors, title}.
|
|
44
|
+
// Returns the single error to report, or null when the keyword is satisfied.
|
|
45
|
+
function __ataCollapse (kw, br, pp, sp, o) {
|
|
46
|
+
const pass = br.filter((b) => b.valid);
|
|
47
|
+
if (kw === 'oneOf') {
|
|
48
|
+
if (pass.length === 1) return null;
|
|
49
|
+
if (pass.length > 1) {
|
|
50
|
+
const passIdx = [];
|
|
51
|
+
for (let i = 0; i < br.length; i++) if (br[i].valid) passIdx.push(i);
|
|
42
52
|
return {
|
|
43
|
-
code: 'ATA4002',
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
code: 'ATA4002',
|
|
54
|
+
keyword: 'oneOf',
|
|
55
|
+
instancePath: pp || '',
|
|
56
|
+
path: pp || '',
|
|
57
|
+
schemaPath: sp,
|
|
58
|
+
_o: o,
|
|
59
|
+
message: 'value matched ' + pass.length + ' of ' + br.length + ' oneOf variants, expected exactly one',
|
|
60
|
+
params: { passingSchemas: passIdx },
|
|
47
61
|
};
|
|
48
62
|
}
|
|
49
|
-
|
|
50
|
-
return
|
|
63
|
+
} else if (pass.length >= 1) {
|
|
64
|
+
return null;
|
|
51
65
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
function buildBranchError (code, keyword, branchResults, parentPath, parentSchemaPath) {
|
|
58
|
-
let bestIdx = 0;
|
|
59
|
-
let bestScore = Infinity;
|
|
60
|
-
for (let i = 0; i < branchResults.length; i++) {
|
|
61
|
-
const s = scoreBranch(branchResults[i].errors);
|
|
62
|
-
if (s < bestScore) { bestScore = s; bestIdx = i; }
|
|
66
|
+
let bi = 0;
|
|
67
|
+
let bs = Infinity;
|
|
68
|
+
for (let i = 0; i < br.length; i++) {
|
|
69
|
+
const s = __ataScore(br[i].errors);
|
|
70
|
+
if (s < bs) { bs = s; bi = i; }
|
|
63
71
|
}
|
|
64
|
-
const best =
|
|
65
|
-
const variantName = best.title || `variant ${bestIdx + 1}`;
|
|
72
|
+
const best = br[bi];
|
|
66
73
|
return {
|
|
67
|
-
code
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
code: kw === 'oneOf' ? 'ATA4001' : 'ATA4003',
|
|
75
|
+
keyword: kw,
|
|
76
|
+
instancePath: pp || '',
|
|
77
|
+
path: pp || '',
|
|
78
|
+
schemaPath: sp,
|
|
79
|
+
_o: o,
|
|
80
|
+
message: 'value matched 0 of ' + br.length + ' ' + kw + ' variants',
|
|
81
|
+
params: { variants: br.length, closest: bi, closestName: best.title || ('variant ' + (bi + 1)) },
|
|
82
|
+
branchErrors: best.errors,
|
|
72
83
|
};
|
|
73
84
|
}
|
|
74
85
|
|
|
75
|
-
|
|
86
|
+
// The named-argument form the interpreter and the tests call.
|
|
87
|
+
function collapseBranches ({ keyword, branchResults, parentPath, parentSchemaPath, ordinal }) {
|
|
88
|
+
return __ataCollapse(keyword, branchResults, parentPath, parentSchemaPath, ordinal === undefined ? null : ordinal);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The three declarations as source, for the generated code to carry. Derived
|
|
92
|
+
// from the functions above rather than written out again, so a fix to one
|
|
93
|
+
// cannot miss the other. `tests/test_branch_collapse.js` runs both.
|
|
94
|
+
function embedSource () {
|
|
95
|
+
return 'const __ATA_SEVERITY=' + JSON.stringify(__ATA_SEVERITY) + ';' +
|
|
96
|
+
'const __ataScore=' + __ataScore.toString() + ';' +
|
|
97
|
+
'const __ataCollapse=' + __ataCollapse.toString() + ';';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { collapseBranches, scoreBranch: __ataScore, SEVERITY: __ATA_SEVERITY, embedSource };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Schema -> the description to put in a model's prompt.
|
|
4
|
+
//
|
|
5
|
+
// Everyone hand-writes a prose description of the shape they want and keeps it
|
|
6
|
+
// next to a schema that enforces something slightly different. The two drift,
|
|
7
|
+
// quietly, because nothing checks one against the other. This derives the
|
|
8
|
+
// first from the second.
|
|
9
|
+
//
|
|
10
|
+
// It is not a style preference. Measured on one model, first attempt only, 30
|
|
11
|
+
// documents, no retry:
|
|
12
|
+
//
|
|
13
|
+
// nothing but the task 0 of 30 valid
|
|
14
|
+
// a field list written by hand 0 of 30
|
|
15
|
+
// a careful description written by hand 0 of 30
|
|
16
|
+
// this 23 of 30
|
|
17
|
+
//
|
|
18
|
+
// The careful hand-written one failed on exactly two fields, in all 30 cases:
|
|
19
|
+
// the two whose values are an internal vocabulary. A person writes "the
|
|
20
|
+
// settlement status, uppercase with underscores" because a person describes
|
|
21
|
+
// fields. Those values cannot be described, only listed, and a generator lists
|
|
22
|
+
// them.
|
|
23
|
+
//
|
|
24
|
+
// Scope follows lib/ts-gen.js, which walks the same shapes: properties and
|
|
25
|
+
// required, arrays, enum and const, oneOf/anyOf/allOf, and $ref into local
|
|
26
|
+
// $defs. Anything else is described as `any` rather than guessed at.
|
|
27
|
+
|
|
28
|
+
const MAX_DEPTH = 12;
|
|
29
|
+
|
|
30
|
+
function lit (v) {
|
|
31
|
+
try { return JSON.stringify(v); } catch (_) { return String(v); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveRef (schema, defs) {
|
|
35
|
+
const m = typeof schema.$ref === 'string' && schema.$ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
|
|
36
|
+
if (m && defs && defs[m[1]]) return defs[m[1]];
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The constraints worth stating to a model, in the order a reader wants them:
|
|
41
|
+
// what it is, then which values, then how big.
|
|
42
|
+
function constraintsOf (s) {
|
|
43
|
+
const out = [];
|
|
44
|
+
if (Array.isArray(s.enum)) out.push('one of ' + s.enum.map(lit).join(', '));
|
|
45
|
+
else if (s.const !== undefined) out.push('exactly ' + lit(s.const));
|
|
46
|
+
else if (s.type) out.push(Array.isArray(s.type) ? s.type.join(' or ') : s.type);
|
|
47
|
+
|
|
48
|
+
if (typeof s.format === 'string') out.push(s.format + ' format');
|
|
49
|
+
if (typeof s.pattern === 'string') out.push('matching ' + s.pattern);
|
|
50
|
+
|
|
51
|
+
const range = (min, max, unit) => {
|
|
52
|
+
if (min !== undefined && max !== undefined) out.push(`${min} to ${max}${unit}`);
|
|
53
|
+
else if (min !== undefined) out.push(`at least ${min}${unit}`);
|
|
54
|
+
else if (max !== undefined) out.push(`at most ${max}${unit}`);
|
|
55
|
+
};
|
|
56
|
+
range(s.minLength, s.maxLength, ' characters');
|
|
57
|
+
range(s.minimum, s.maximum, '');
|
|
58
|
+
range(s.minItems, s.maxItems, ' items');
|
|
59
|
+
if (s.exclusiveMinimum !== undefined) out.push('greater than ' + s.exclusiveMinimum);
|
|
60
|
+
if (s.exclusiveMaximum !== undefined) out.push('less than ' + s.exclusiveMaximum);
|
|
61
|
+
if (typeof s.multipleOf === 'number') out.push(multipleOfPhrase(s.multipleOf));
|
|
62
|
+
if (s.uniqueItems === true) out.push('all items different');
|
|
63
|
+
if (typeof s.description === 'string' && s.description) out.push(s.description);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// `multipleOf: 0.01` is how a schema says "money". A model acts on "rounded to
|
|
68
|
+
// 2 decimal places" and does not reliably act on "a multiple of 0.01": in the
|
|
69
|
+
// measurement behind this file, that one phrase was most of the gap between
|
|
70
|
+
// this output and a careful description written by a person.
|
|
71
|
+
function multipleOfPhrase (m) {
|
|
72
|
+
if (m > 0 && m < 1) {
|
|
73
|
+
const places = Math.round(Math.log10(1 / m));
|
|
74
|
+
if (Math.abs(Math.pow(10, -places) - m) < Number.EPSILON * 8) {
|
|
75
|
+
return `rounded to ${places} decimal place${places === 1 ? '' : 's'}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return 'a multiple of ' + m;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isObjectSchema (s) {
|
|
82
|
+
return s && typeof s === 'object' && s.properties && typeof s.properties === 'object';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function describeObjectBody (schema, depth, defs, lines) {
|
|
86
|
+
const pad = ' '.repeat(depth);
|
|
87
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
88
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
89
|
+
describeNode(sub, key + (required.has(key) ? '' : ' (optional)'), depth, defs, lines);
|
|
90
|
+
}
|
|
91
|
+
for (const key of required) {
|
|
92
|
+
if (!(key in schema.properties)) lines.push(`${pad}${key}: required, any`);
|
|
93
|
+
}
|
|
94
|
+
if (schema.additionalProperties === false) lines.push(`${pad}no other fields`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function describeNode (schema, label, depth, defs, lines) {
|
|
98
|
+
const pad = ' '.repeat(depth);
|
|
99
|
+
if (schema === true || schema === undefined) { lines.push(`${pad}${label}: any`); return; }
|
|
100
|
+
if (schema === false) { lines.push(`${pad}${label}: nothing is allowed here`); return; }
|
|
101
|
+
if (typeof schema !== 'object' || schema === null || depth > MAX_DEPTH) {
|
|
102
|
+
lines.push(`${pad}${label}: any`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const target = schema.$ref ? resolveRef(schema, defs) : null;
|
|
107
|
+
if (target) { describeNode(target, label, depth, defs, lines); return; }
|
|
108
|
+
|
|
109
|
+
// allOf is the intersection, so its parts describe the same value.
|
|
110
|
+
if (Array.isArray(schema.allOf) && schema.allOf.length) {
|
|
111
|
+
const merged = Object.assign({}, schema);
|
|
112
|
+
delete merged.allOf;
|
|
113
|
+
for (const part of schema.allOf) {
|
|
114
|
+
const resolved = part && part.$ref ? resolveRef(part, defs) || part : part;
|
|
115
|
+
if (resolved && typeof resolved === 'object') Object.assign(merged, resolved, {
|
|
116
|
+
properties: Object.assign({}, merged.properties, resolved.properties),
|
|
117
|
+
required: [].concat(merged.required || [], resolved.required || []),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (!merged.properties) delete merged.properties;
|
|
121
|
+
if (!merged.required || !merged.required.length) delete merged.required;
|
|
122
|
+
describeNode(merged, label, depth, defs, lines);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const alternatives = schema.oneOf || schema.anyOf;
|
|
127
|
+
if (Array.isArray(alternatives) && alternatives.length) {
|
|
128
|
+
lines.push(`${pad}${label}: one of the following shapes`);
|
|
129
|
+
alternatives.forEach((alt, i) => describeNode(alt, `option ${i + 1}`, depth + 1, defs, lines));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (isObjectSchema(schema)) {
|
|
134
|
+
const own = constraintsOf(schema).filter((c) => c !== 'object');
|
|
135
|
+
lines.push(`${pad}${label}: object${own.length ? ` (${own.join(', ')})` : ''}`);
|
|
136
|
+
describeObjectBody(schema, depth + 1, defs, lines);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const rawItems = schema.items;
|
|
141
|
+
if (schema.type === 'array' && rawItems && typeof rawItems === 'object') {
|
|
142
|
+
const items = rawItems.$ref ? resolveRef(rawItems, defs) || rawItems : rawItems;
|
|
143
|
+
const own = constraintsOf(schema).filter((c) => c !== 'array');
|
|
144
|
+
const bounds = own.length ? ` (${own.join(', ')})` : '';
|
|
145
|
+
// An item that is itself an object gets its fields listed underneath. One
|
|
146
|
+
// that is a scalar reads better on the same line than as a nested entry
|
|
147
|
+
// called "item".
|
|
148
|
+
if (isObjectSchema(items)) {
|
|
149
|
+
lines.push(`${pad}${label}: array${bounds}, each item is an object:`);
|
|
150
|
+
describeObjectBody(items, depth + 1, defs, lines);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (items.oneOf || items.anyOf || items.allOf) {
|
|
154
|
+
lines.push(`${pad}${label}: array${bounds}, each item is:`);
|
|
155
|
+
describeNode(items, 'item', depth + 1, defs, lines);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const inner = constraintsOf(items).join(', ') || 'any';
|
|
159
|
+
lines.push(`${pad}${label}: array${bounds} of ${inner}`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
lines.push(`${pad}${label}: ${constraintsOf(schema).join(', ') || 'any'}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// describeSchema(schema, opts) -> string
|
|
167
|
+
// opts.name what to call the top level (default 'output')
|
|
168
|
+
function describeSchema (schema, opts) {
|
|
169
|
+
const name = (opts && opts.name) || 'output';
|
|
170
|
+
const defs = (schema && (schema.$defs || schema.definitions)) || null;
|
|
171
|
+
const lines = [];
|
|
172
|
+
describeNode(schema, name, 0, defs, lines);
|
|
173
|
+
return lines.join('\n');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { describeSchema };
|