ata-validator 1.50.0 → 1.51.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/aot.d.ts CHANGED
@@ -56,7 +56,17 @@ export function compactDecline(validator: Validator<unknown>): string | null;
56
56
  * runtime Validator does with `richErrors: false`. Null where
57
57
  * toCompactModule would be null. `ata build --mode schema` writes these.
58
58
  */
59
- export function toSchemaModule(validator: Validator<unknown>, opts: { runtime: string; format?: 'esm' | 'cjs' }): string | null;
59
+ export function toSchemaModule(validator: Validator<unknown>, opts: { runtime: string; registry?: string; format?: 'esm' | 'cjs' }): string | null;
60
+
61
+ /**
62
+ * Source of the registry module schema modules import when their schemas
63
+ * reference each other by `$id`: the schemas, without descriptive keywords,
64
+ * as one array. Pass its import specifier as `registry` to toSchemaModule,
65
+ * for a validator built with those schemas in its `schemas` option; the module
66
+ * then names its schema by `$id` and carries no copy of it. `ata build --mode
67
+ * schema` does this for inputs that reference each other.
68
+ */
69
+ export function registrySource(schemas: ReadonlyArray<object | Validator<unknown>>, format?: 'esm' | 'cjs'): string;
60
70
 
61
71
  /** Source of the runtime compact modules (and, with `schemaMode`, schema
62
72
  * modules) import. With `schemas`, the validators or schemas whose modules
package/aot.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import mod from './lib/aot-impl.js';
2
- export const { toStandalone, toStandaloneModule, toCompactModule, toSchemaModule, compactDecline, errorRuntimeSource, bundle, bundleStandalone, bundleCompact, loadBundle } = mod;
2
+ export const { toStandalone, toStandaloneModule, toCompactModule, toSchemaModule, registrySource, compactDecline, errorRuntimeSource, bundle, bundleStandalone, bundleCompact, loadBundle } = mod;
3
3
  export default mod;
package/bin/ata.js CHANGED
@@ -51,6 +51,9 @@ Build options:
51
51
  --mode <mode> code (default) | schema. schema writes each module as its
52
52
  schema and takes the verdict and errors from one runtime
53
53
  file: far smaller and quicker to load, slower per check
54
+ --base-uri <uri> The URI an input without $id is known by: <uri> plus its
55
+ file name, for schemas that reference a shared file by URL
56
+ or relative path
54
57
  --check Check (don't write); exit 1 if any output is stale
55
58
  --cache-file <path> Cache file for incremental builds (default: cache disabled)
56
59
  --max-size <bytes> Fail build if any compiled module exceeds this gzipped size
@@ -102,6 +105,7 @@ function parseArgs(argv) {
102
105
  if (a === '--one-pass') { out.opts.onePass = true; continue; }
103
106
  if (a === '--errors') { out.opts.errors = argv[++i]; continue; }
104
107
  if (a === '--mode') { out.opts.mode = argv[++i]; continue; }
108
+ if (a === '--base-uri') { out.opts.baseUri = argv[++i]; continue; }
105
109
  if (a === '--check') { out.opts.check = true; continue; }
106
110
  if (a === '--strict') { out.opts.strict = true; continue; }
107
111
  if (a === '--strict-schema') { out.opts.strictSchema = true; continue; }
@@ -306,6 +310,7 @@ function cmdBuild(args) {
306
310
  onePass: args.opts.onePass === true ? true : undefined,
307
311
  errors: args.opts.errors,
308
312
  mode: args.opts.mode,
313
+ baseUri: args.opts.baseUri,
309
314
  check: !!args.opts.check,
310
315
  maxSize: args.opts.maxSize,
311
316
  strict: !!args.opts.strict,
@@ -326,6 +331,22 @@ function cmdBuild(args) {
326
331
  }
327
332
  if (report.runtime) {
328
333
  process.stdout.write(`ata: error runtime -> ${report.runtime.output} (${report.runtime.bytes.toLocaleString()} bytes, shared by the modules above)\n`);
334
+ if (report.runtime.registry) process.stdout.write(`ata: schema registry -> ${report.runtime.registry.output} (${report.runtime.registry.bytes.toLocaleString()} bytes, the schemas the modules above reference)\n`);
335
+ }
336
+ // A module the error generator declined still has an exact verdict, but
337
+ // every failure is the one ATA9000 error. Said once per module, short,
338
+ // and once with what to do about it.
339
+ let noDetail = 0;
340
+ for (const w of report.warnings || []) {
341
+ if (w.kind === 'error-detail') {
342
+ noDetail++;
343
+ process.stderr.write(`ata: warning ${w.input}: no error detail, failures report the single ATA9000 error (the verdict is exact)\n`);
344
+ } else {
345
+ process.stderr.write(`ata: warning ${w.input}: ${w.warning}\n`);
346
+ }
347
+ }
348
+ if (noDetail > 0 && args.opts.mode !== 'schema') {
349
+ process.stderr.write(`ata: ${noDetail} module${noDetail === 1 ? '' : 's'} without error detail; with --mode schema a module takes its errors from the interpreted engine instead\n`);
329
350
  }
330
351
  for (const c of report.cached) {
331
352
  process.stdout.write(`ata: cached ${c.input}\n`);
package/lib/aot-build.js CHANGED
@@ -109,6 +109,99 @@ function importSpecifier(fromFile, toFile) {
109
109
  const RUNTIME_PLACEHOLDER = '__ATA_ERROR_RUNTIME__';
110
110
  const ERRORS_MODES = new Set(['auto', 'inline', 'shared']);
111
111
 
112
+ // The registry without the definitions no module reaches. A shared
113
+ // definitions file (Kubernetes ships one with hundreds) is mostly what a
114
+ // given set of schemas never uses. Only `definitions` and `$defs` entries are
115
+ // dropped, and only from a document every reference into which is a plain
116
+ // pointer into them; a document reached as a whole, through an anchor, or
117
+ // holding an embedded $id is kept as it is. The result is checked: every
118
+ // root's references must still resolve against it, or the whole registry is
119
+ // kept.
120
+ function pruneRegistry(roots, byId) {
121
+ const used = new Map();
122
+ const keepWhole = new Set();
123
+ const seen = new Set();
124
+ const queue = [];
125
+ const hasInnerId = new Map();
126
+ const innerId = (doc) => {
127
+ let hit = hasInnerId.get(doc);
128
+ if (hit !== undefined) return hit;
129
+ hit = false;
130
+ const stack = [doc];
131
+ const visited = new Set();
132
+ while (stack.length && !hit) {
133
+ const n = stack.pop();
134
+ if (n === null || typeof n !== 'object' || visited.has(n)) continue;
135
+ visited.add(n);
136
+ if (n !== doc && !Array.isArray(n) && typeof n.$id === 'string') hit = true;
137
+ for (const k of Object.keys(n)) stack.push(n[k]);
138
+ }
139
+ hasInnerId.set(doc, hit);
140
+ return hit;
141
+ };
142
+ const docOf = new Map();
143
+ for (const [id, doc] of byId) docOf.set(id.replace(/#$/, ''), doc);
144
+ const reach = (doc, base, node) => {
145
+ if (node === null || typeof node !== 'object' || seen.has(node)) return;
146
+ seen.add(node);
147
+ queue.push([doc, base, node]);
148
+ };
149
+ const defsOf = (doc) => (doc.$defs && typeof doc.$defs === 'object' ? '$defs' : doc.definitions && typeof doc.definitions === 'object' ? 'definitions' : null);
150
+ for (const root of roots) reach(root, typeof root.$id === 'string' ? root.$id : null, root);
151
+ while (queue.length) {
152
+ const [doc, base, node] = queue.pop();
153
+ if (Array.isArray(node)) { for (const x of node) reach(doc, base, x); continue; }
154
+ for (const k of Object.keys(node)) {
155
+ const v = node[k];
156
+ if ((k === '$ref' || k === '$dynamicRef' || k === '$recursiveRef') && typeof v === 'string') {
157
+ let abs;
158
+ try { abs = base ? new URL(v, base).href : v.charCodeAt(0) === 35 ? null : new URL(v).href; } catch { abs = null; }
159
+ const local = v.charCodeAt(0) === 35;
160
+ const hash = abs === null ? -1 : abs.indexOf('#');
161
+ const uri = abs === null ? null : hash < 0 ? abs : abs.slice(0, hash);
162
+ const target = local ? doc : uri !== null ? docOf.get(uri) : undefined;
163
+ if (target === undefined) continue;
164
+ const frag = local ? v.slice(1) : hash < 0 ? '' : abs.slice(hash + 1);
165
+ const tbase = local ? base : uri;
166
+ const m = /^\/(\$defs|definitions)\/([^/]+)/.exec(frag);
167
+ const which = defsOf(target);
168
+ if (k !== '$ref' || m === null || m[1] !== which) {
169
+ keepWhole.add(target);
170
+ reach(target, tbase, target);
171
+ continue;
172
+ }
173
+ let name = m[2];
174
+ try { name = decodeURIComponent(name); } catch {}
175
+ name = name.replace(/~1/g, '/').replace(/~0/g, '~');
176
+ let set = used.get(target);
177
+ if (set === undefined) { set = new Set(); used.set(target, set); }
178
+ set.add(name);
179
+ // The rest of the document around its definitions is used too.
180
+ for (const key of Object.keys(target)) if (key !== which) reach(target, tbase, target[key]);
181
+ reach(target, tbase, target[which][name]);
182
+ } else if (v !== null && typeof v === 'object') {
183
+ reach(doc, base, v);
184
+ }
185
+ }
186
+ }
187
+ const out = new Map();
188
+ for (const [id, doc] of byId) {
189
+ const which = defsOf(doc);
190
+ if (which === null || keepWhole.has(doc) || innerId(doc) || roots.includes(doc)) { out.set(id, doc); continue; }
191
+ const names = used.get(doc) || new Set();
192
+ const defs = {};
193
+ for (const name of Object.keys(doc[which])) if (names.has(name)) Object.defineProperty(defs, name, { value: doc[which][name], enumerable: true, writable: true, configurable: true });
194
+ out.set(id, { ...doc, [which]: defs });
195
+ }
196
+ for (const root of roots) {
197
+ if (!aot._refsResolveWithin(root, out)) return byId;
198
+ }
199
+ // The registry documents' own references too, as the runtime may walk any
200
+ // part of a definition it keeps.
201
+ for (const doc of out.values()) if (!aot._refsResolveWithin(doc, out)) return byId;
202
+ return out;
203
+ }
204
+
112
205
  async function build(opts) {
113
206
  const globs = opts.globs || [];
114
207
  if (globs.length === 0) throw new Error('build: at least one glob required');
@@ -138,6 +231,28 @@ async function build(opts) {
138
231
  const warnings = [];
139
232
  const entries = [];
140
233
 
234
+ // Schemas that reference each other: a schema whose references do not all
235
+ // land inside it gets the other inputs registered beside it, each under its
236
+ // $id, or, with `baseUri`, an input without one under the base URI and its
237
+ // file name. In schema mode its module then resolves against one registry
238
+ // module the build writes (registrySource); otherwise it is a
239
+ // self-contained module carrying what it references. A schema that
240
+ // resolves within itself is built exactly as it would be alone.
241
+ if (opts.baseUri !== undefined) {
242
+ try { new URL(opts.baseUri); } catch { throw new Error(`build: baseUri must be an absolute URI (got "${opts.baseUri}")`); }
243
+ }
244
+ let siblings = [];
245
+ for (const input of inputs) {
246
+ try {
247
+ const sch = parseSchemaFile(input);
248
+ if (sch === null || typeof sch !== 'object' || Array.isArray(sch)) continue;
249
+ if (typeof sch.$id === 'string' && sch.$id !== '') siblings.push({ input, schema: sch });
250
+ else if (opts.baseUri !== undefined) siblings.push({ input, schema: { $id: new URL(path.basename(input), opts.baseUri).href, ...sch } });
251
+ } catch {}
252
+ }
253
+ if (siblings.length === 0) siblings = null;
254
+ const REGISTRY_PLACEHOLDER = '__ATA_SCHEMA_REGISTRY__';
255
+
141
256
  const compileOne = (input, raw) => {
142
257
  const schema = parseSchemaFile(input);
143
258
  // The build is where an authoring mistake is cheapest to stop: a typo
@@ -151,7 +266,13 @@ async function build(opts) {
151
266
  return { error: problems.map((x) => `strict mode: ${x.message} at ${x.path}`).join('; ') };
152
267
  }
153
268
  }
154
- const v = new Validator(schema);
269
+ const needs = siblings !== null && schema !== null && typeof schema === 'object' && !Array.isArray(schema) && !aot._refsResolveWithin(schema);
270
+ const self = needs ? siblings.find((x) => x.input === input) : undefined;
271
+ const others = needs ? siblings.filter((x) => x.input !== input && (self === undefined || x.schema.$id !== self.schema.$id)).map((x) => x.schema) : null;
272
+ // Known under the base URI too, so its own relative references resolve
273
+ // against it.
274
+ const own = self !== undefined ? self.schema : schema;
275
+ const v = others !== null && others.length > 0 ? new Validator(own, { schemas: others }) : new Validator(schema);
155
276
  let sourceMap = null;
156
277
  if (source) {
157
278
  // Only attempt position map for JSON (the scanner is JSON-only).
@@ -174,14 +295,37 @@ async function build(opts) {
174
295
  onWarning: (w, meta) => inputWarnings.push({ message: w, kind: (meta && meta.kind) || 'general' }),
175
296
  });
176
297
  const compact = schemaMode && src
177
- ? aot.toSchemaModule(v, { format, runtime: RUNTIME_PLACEHOLDER })
298
+ ? aot.toSchemaModule(v, others !== null && others.length > 0 ? { format, runtime: RUNTIME_PLACEHOLDER, registry: REGISTRY_PLACEHOLDER } : { format, runtime: RUNTIME_PLACEHOLDER })
178
299
  : sharable && src ? aot.toCompactModule(v, { format, formatMode: opts.formatMode, errorRuntime: RUNTIME_PLACEHOLDER }) : null;
179
300
  // The runtime parts this module's schema needs (see runtimeFeatures).
180
- const features = compact ? require('./error-runtime').runtimeFeatures([require('./schema-strip').stripAnnotations(v._schemaObj)]) : null;
301
+ // A registry module's schema reaches the others: what it needs from the
302
+ // registry, by $id, is every schema registered beside it. A key that is
303
+ // not its schema's $id cannot be found by the runtime, which keys the
304
+ // registry by $id: such a module is written self-contained.
305
+ let registryEntries = null;
306
+ let compactOut = compact;
307
+ if (compactOut && others !== null && others.length > 0) {
308
+ // A schema without an $id is not in the registry: its module carries it.
309
+ registryEntries = typeof v._schemaObj.$id === 'string' ? [v._schemaObj] : [];
310
+ for (const [id, sub] of v._schemaMap) {
311
+ if (!sub || typeof sub !== 'object' || sub.$id !== id) { compactOut = null; registryEntries = null; break; }
312
+ registryEntries.push(sub);
313
+ }
314
+ }
315
+ const { stripAnnotations: _strip } = require('./schema-strip');
316
+ const features = compactOut ? require('./error-runtime').runtimeFeatures((registryEntries || [v._schemaObj]).map(_strip)) : null;
181
317
  // A module that imports the pattern engine needs it in the runtime,
182
318
  // whatever the schema scan said.
183
- if (features && compact.includes('_ataCompileSafe')) features['safe-regex'] = true;
184
- return { schema, src, compact, features, inputWarnings };
319
+ if (features && compactOut.includes('_ataCompileSafe')) features['safe-regex'] = true;
320
+ // Why there is no module, when the reason is a reference: which one, and
321
+ // what would resolve it.
322
+ let unresolved = null;
323
+ if (!src) {
324
+ const miss = {};
325
+ const registry = v._schemaMap && v._schemaMap.size > 0 ? v._schemaMap : null;
326
+ if (!aot._refsResolveWithin(v._schemaObj, registry, miss) && typeof miss.ref === 'string') unresolved = miss.ref;
327
+ }
328
+ return { schema, src, compact: compactOut, features, registryEntries, root: v._schemaObj, usesSiblings: others !== null, unresolved, inputWarnings };
185
329
  };
186
330
 
187
331
  for (const input of inputs) {
@@ -196,6 +340,9 @@ async function build(opts) {
196
340
  cacheEntry.output === output &&
197
341
  !!cacheEntry.sharable === sharable &&
198
342
  !!cacheEntry.schemaMode === schemaMode &&
343
+ // A module built against the other inputs depends on them too, and
344
+ // the registry is put together from such modules: always rebuilt.
345
+ !cacheEntry.usesSiblings &&
199
346
  fs.existsSync(output) &&
200
347
  cacheEntry.outputHash === hashContent(fs.readFileSync(output))
201
348
  );
@@ -219,13 +366,15 @@ async function build(opts) {
219
366
  for (const x of r.inputWarnings) warnings.push({ input, warning: x.message, kind: x.kind });
220
367
  }
221
368
  if (!r.src) {
222
- const reason = 'schema is not AOT-compatible (toStandaloneModule returned null)';
369
+ const reason = r.unresolved !== null
370
+ ? `$ref "${r.unresolved}" does not resolve: no input has that $id. Add the schema it names as an input${opts.baseUri === undefined ? ', and if that file has no $id, give it one with --base-uri' : ''}`
371
+ : 'schema is not AOT-compatible (toStandaloneModule returned null)';
223
372
  if (opts.strict) failed.push({ input, error: reason });
224
373
  else skipped.push({ input, reason });
225
374
  continue;
226
375
  }
227
376
  entries.push({
228
- input, output, raw, inputHash, schema: r.schema, src: r.src, compact: r.compact, features: r.features,
377
+ input, output, raw, inputHash, schema: r.schema, src: r.src, compact: r.compact, features: r.features, registryEntries: r.registryEntries, root: r.root, usesSiblings: r.usesSiblings,
229
378
  fullGz: zlib.gzipSync(r.src).length,
230
379
  compactGz: r.compact ? zlib.gzipSync(r.compact).length : null,
231
380
  });
@@ -265,6 +414,19 @@ async function build(opts) {
265
414
  for (const e of entries) {
266
415
  e.mode = runtime !== null && candidates.includes(e) ? (schemaMode ? 'schema' : 'compact') : 'inline';
267
416
  }
417
+ // The registry the schema modules resolve against: every schema any of them
418
+ // needs, once, by $id.
419
+ let registryPath = null;
420
+ let registrySrc = null;
421
+ if (runtime !== null) {
422
+ const byId = new Map();
423
+ for (const e of entries) if (e.mode !== 'inline' && e.registryEntries) for (const sub of e.registryEntries) if (!byId.has(sub.$id)) byId.set(sub.$id, sub);
424
+ if (byId.size > 0) {
425
+ registryPath = path.join(path.dirname(runtime.path), 'ata-registry' + (format === 'cjs' ? '.cjs' : '.mjs'));
426
+ const roots = entries.filter((e) => e.mode !== 'inline' && e.registryEntries).map((e) => e.root);
427
+ registrySrc = aot.registrySource([...pruneRegistry(roots, byId).values()], format);
428
+ }
429
+ }
268
430
 
269
431
  for (const e of entries) {
270
432
  const { input, output } = e;
@@ -282,9 +444,10 @@ async function build(opts) {
282
444
  e.schema = r.schema; e.src = r.src; e.compact = r.compact; e.features = r.features;
283
445
  if (e.mode !== 'inline' && !r.compact) e.mode = 'inline';
284
446
  }
285
- const src = e.mode !== 'inline'
447
+ let src = e.mode !== 'inline'
286
448
  ? e.compact.split(JSON.stringify(RUNTIME_PLACEHOLDER)).join(JSON.stringify(importSpecifier(output, runtime.path)))
287
449
  : e.src;
450
+ if (e.mode !== 'inline' && e.registryEntries) src = src.split(JSON.stringify(REGISTRY_PLACEHOLDER)).join(JSON.stringify(importSpecifier(output, registryPath)));
288
451
  fs.mkdirSync(path.dirname(output), { recursive: true });
289
452
  fs.writeFileSync(output, src);
290
453
  const outBytes = Buffer.byteLength(src, 'utf8');
@@ -321,6 +484,7 @@ async function build(opts) {
321
484
  fullGz: e.fullGz,
322
485
  compactGz: e.compactGz,
323
486
  features: e.features || undefined,
487
+ usesSiblings: e.usesSiblings || undefined,
324
488
  };
325
489
  } catch (err) {
326
490
  failed.push({ input, error: err.message });
@@ -335,11 +499,21 @@ async function build(opts) {
335
499
  fs.writeFileSync(runtime.path, runtime.src);
336
500
  }
337
501
  runtimeReport = { output: runtime.path, bytes: Buffer.byteLength(runtime.src, 'utf8'), gzipBytes: runtime.gz };
502
+ if (registrySrc !== null) {
503
+ const cur = fs.existsSync(registryPath) ? fs.readFileSync(registryPath, 'utf8') : null;
504
+ if (cur !== registrySrc) fs.writeFileSync(registryPath, registrySrc);
505
+ runtimeReport.registry = { output: registryPath, bytes: Buffer.byteLength(registrySrc, 'utf8'), gzipBytes: zlib.gzipSync(registrySrc).length };
506
+ }
338
507
  }
339
508
 
340
509
  writeCache(opts.cacheFile, newCache);
341
510
 
342
- return { compiled, cached, skipped, failed, warnings, runtime: runtimeReport };
511
+ // The self-contained source is built for every input, so its error-detail
512
+ // decline is recorded even where the module written is a compact or schema
513
+ // module, whose errors come from the runtime. Only the written one counts.
514
+ const fromRuntime = new Set(entries.filter((e) => e.mode !== 'inline').map((e) => e.input));
515
+ const kept = warnings.filter((w) => !(w.kind === 'error-detail' && fromRuntime.has(w.input)));
516
+ return { compiled, cached, skipped, failed, warnings: kept, runtime: runtimeReport };
343
517
  }
344
518
 
345
519
  async function watch(opts, onReport) {
package/lib/aot-impl.js CHANGED
@@ -687,13 +687,14 @@ function hasRefKey(n, seen) {
687
687
  return false;
688
688
  }
689
689
 
690
- function refsResolveWithin(schema) {
690
+ // miss: an object that gets `ref`, the first reference that does not resolve.
691
+ function refsResolveWithin(schema, registry, miss) {
691
692
  // No reference anywhere (a key of that name inside a value counts too):
692
693
  // nothing to resolve, and no index to build.
693
- if (!hasRefKey(schema, new Set())) return true;
694
+ if (!registry && !hasRefKey(schema, new Set())) return true;
694
695
  const { indexSchemas, resolveRef } = require('./interpreter-core')._refInternals;
695
696
  let state;
696
- try { state = indexSchemas(schema, null); } catch { return false; }
697
+ try { state = indexSchemas(schema, registry || null); } catch { return false; }
697
698
  for (const [node, base] of state.nodeBase) {
698
699
  if (node === null || typeof node !== 'object') continue;
699
700
  for (const k of ['$ref', '$dynamicRef']) {
@@ -701,15 +702,19 @@ function refsResolveWithin(schema) {
701
702
  if (r === undefined) continue;
702
703
  if (typeof r !== 'string') return false;
703
704
  let hit;
704
- try { hit = resolveRef(r, base, state); } catch { return false; }
705
- if (hit.node === undefined) return false;
705
+ try { hit = resolveRef(r, base, state); } catch { if (miss) miss.ref = r; return false; }
706
+ if (hit.node === undefined) { if (miss) miss.ref = r; return false; }
706
707
  }
707
708
  }
708
709
  return true;
709
710
  }
710
711
 
711
712
  const COMPACT_OPTIONS = new Set(['engine', 'strictSchema', 'logger', 'source']);
712
- function compactDecline(validator) {
713
+ // registryMode: for a schema module that resolves its references against the
714
+ // build's registry (toSchemaModule with `registry`): schemas registered beside
715
+ // the root are allowed, and every reference has to land in the schema or in
716
+ // one of them.
717
+ function compactDecline(validator, registryMode) {
713
718
  // Options are allowed by name, not refused by name: one this list does not
714
719
  // know (assertFormat: false, baseURI, a future one) may change what the
715
720
  // verdict means, and the runtime would not know to follow it.
@@ -718,19 +723,24 @@ function compactDecline(validator) {
718
723
  validator._ensureCompiled();
719
724
  const o = validator._options || {};
720
725
  for (const k of Object.keys(o)) {
721
- if (o[k] === undefined || COMPACT_OPTIONS.has(k)) continue;
726
+ if (o[k] === undefined || COMPACT_OPTIONS.has(k) || (registryMode === true && k === 'schemas')) continue;
722
727
  return 'option ' + k;
723
728
  }
724
729
  if (validator._usesKeywords || validator._externalChecks) return 'custom keywords';
725
730
  if (validator._userFormats && Object.keys(validator._userFormats).length) return 'custom formats';
726
- if (validator._schemaMap && validator._schemaMap.size > 0) return 'schemas registered beside the root';
731
+ if (registryMode !== true && validator._schemaMap && validator._schemaMap.size > 0) return 'schemas registered beside the root';
727
732
  if (validator._schemaObj === null || typeof validator._schemaObj !== 'object') return 'boolean schema';
728
733
  // The runtime has the embedded schema and nothing else: a reference that
729
734
  // lands outside it (a meta-schema the runtime Validator resolves from its
730
735
  // vendored copies, a schema registered later) would come back unresolved,
731
736
  // and the errors with it. The check runs on the schema as embedded.
732
737
  const { stripAnnotations } = require('./schema-strip');
733
- if (!refsResolveWithin(stripAnnotations(validator._schemaObj))) return 'a reference outside the schema';
738
+ let registry = null;
739
+ if (registryMode === true && validator._schemaMap && validator._schemaMap.size > 0) {
740
+ registry = new Map();
741
+ for (const [id, sub] of validator._schemaMap) registry.set(id, stripAnnotations(sub));
742
+ }
743
+ if (!refsResolveWithin(stripAnnotations(validator._schemaObj), registry)) return registry ? 'a reference outside the schema and the registry' : 'a reference outside the schema';
734
744
  return null;
735
745
  }
736
746
 
@@ -755,7 +765,12 @@ function toCompactModule(validator, opts) {
755
765
  function toSchemaModule(validator, opts) {
756
766
  assertEmittable(validator, 'toSchemaModule');
757
767
  if (!opts || typeof opts.runtime !== 'string') throw new TypeError('toSchemaModule: opts.runtime (the import specifier of the schema runtime) is required');
758
- if (compactDecline(validator) !== null) return null;
768
+ // opts.registry: the import specifier of the build's registry module (the
769
+ // array of schemas, written by errorRegistrySource), for schemas that
770
+ // reference each other by $id. The module then names its schema by $id and
771
+ // carries no copy of it.
772
+ const registry = typeof opts.registry === 'string' ? opts.registry : null;
773
+ if (compactDecline(validator, registry !== null) !== null) return null;
759
774
  const format = opts.format || 'esm';
760
775
  const { stripAnnotations } = require('./schema-strip');
761
776
  const { isV1Dialect } = require('./dialect');
@@ -770,12 +785,15 @@ function toSchemaModule(validator, opts) {
770
785
  if ('$anchor' in n || '$dynamicAnchor' in n || (!root && '$id' in n)) return false;
771
786
  return Object.keys(n).every((k) => flatWalk(n[k], false));
772
787
  };
773
- const rtOpts = [isV1Dialect(sch) ? 'v1: true' : '', flatWalk(stripped, true) ? 'flat: true' : ''].filter(Boolean).join(', ');
788
+ const ownId = registry !== null && typeof sch.$id === 'string' ? sch.$id : null;
789
+ const rtOpts = [isV1Dialect(sch) ? 'v1: true' : '', registry === null && flatWalk(stripped, true) ? 'flat: true' : '', registry !== null ? 'registry: _ataRegistry' : '', ownId !== null ? 'id: ' + JSON.stringify(ownId) : ''].filter(Boolean).join(', ');
774
790
  const hashSrc = hashOf(validator);
775
791
  const spec = JSON.stringify(opts.runtime);
776
- const imp = format === 'esm'
792
+ const regSpec = registry !== null ? JSON.stringify(registry) : null;
793
+ const imp = (format === 'esm'
777
794
  ? `import { createValidator as _ataCreateValidator } from ${spec};\n`
778
- : `const { createValidator: _ataCreateValidator } = require(${spec});\n`;
795
+ : `const { createValidator: _ataCreateValidator } = require(${spec});\n`)
796
+ + (regSpec === null ? '' : format === 'esm' ? `import _ataRegistry from ${regSpec};\n` : `const _ataRegistry = require(${regSpec});\n`);
779
797
  const exp = format === 'esm'
780
798
  ? 'export { validate, isValid, schemaHash, ataVersion };\nexport default { validate, isValid, schemaHash, ataVersion };\n'
781
799
  : 'module.exports = { validate, isValid, schemaHash, ataVersion };\nmodule.exports.default = module.exports;\n';
@@ -786,7 +804,7 @@ function toSchemaModule(validator, opts) {
786
804
  ${imp}const VALID = Object.freeze({ valid: true, errors: Object.freeze([]) });
787
805
  const ABORT = Object.freeze({ valid: false, errors: Object.freeze([Object.freeze({ code: 'ATA9000', message: 'validation failed', keyword: '__abort_early__', path: '' })]) });
788
806
  let _ataV = null;
789
- function _ataGet() { return _ataV !== null ? _ataV : (_ataV = _ataCreateValidator(JSON.parse(${text})${rtOpts ? ', { ' + rtOpts + ' }' : ''})); }
807
+ function _ataGet() { return _ataV !== null ? _ataV : (_ataV = _ataCreateValidator(${ownId !== null ? 'null' : 'JSON.parse(' + text + ')'}${rtOpts ? ', { ' + rtOpts + ' }' : ''})); }
790
808
  function isValid(data) { return _ataGet().isValid(data); }
791
809
  function validate(data) { const v = _ataGet(); if (v.isValid(data)) return VALID; const e = v.errors(data); return e === null ? ABORT : { valid: false, errors: e }; }
792
810
  const schemaHash = ${JSON.stringify(hashSrc)};
@@ -794,6 +812,18 @@ const ataVersion = ${JSON.stringify(ATA_VERSION)};
794
812
  ${exp}`;
795
813
  }
796
814
 
815
+ // The registry module schema modules import when their schemas reference each
816
+ // other: every schema of the build, without its descriptive keywords, as one
817
+ // array the runtime turns into its id-to-schema table.
818
+ function registrySource(schemas, format) {
819
+ const { stripAnnotations } = require('./schema-strip');
820
+ const text = JSON.stringify(JSON.stringify(schemas.map((x) => stripAnnotations(x && x._schemaObj !== undefined ? x._schemaObj : x)))).replace(/</g, '\\u003c');
821
+ const head = `// Auto-generated by ata-validator ${ATA_VERSION}, do not edit.\n// The schemas of one build, which its schema modules resolve references against.\n'use strict';\n`;
822
+ return format === 'cjs'
823
+ ? head + `module.exports = JSON.parse(${text});\n`
824
+ : head + `export default JSON.parse(${text});\n`;
825
+ }
826
+
797
827
  // Bundle multiple validators into a single JS file for fast startup.
798
828
  // Usage:
799
829
  // const bundle = Validator.bundle([schema1, schema2, ...]);
@@ -1110,6 +1140,7 @@ module.exports = {
1110
1140
  toStandaloneModule: htmlSafeResult(toStandaloneModule),
1111
1141
  toCompactModule: htmlSafeResult(toCompactModule),
1112
1142
  toSchemaModule: htmlSafeResult(toSchemaModule),
1143
+ registrySource: htmlSafeResult(registrySource),
1113
1144
  compactDecline,
1114
1145
  // `schemas`: the validators (or schemas) whose compact modules will import
1115
1146
  // this runtime; parts none of them needs are left out. Omitted, the
@@ -1130,3 +1161,5 @@ module.exports = {
1130
1161
  bundleCompact: htmlSafeResult(bundleCompact),
1131
1162
  loadBundle,
1132
1163
  };
1164
+ // For the build only (lib/aot-build.js), not part of ata-validator/aot.
1165
+ Object.defineProperty(module.exports, '_refsResolveWithin', { value: refsResolveWithin, enumerable: false });
@@ -57,17 +57,53 @@ function rankFor(rootSchema, schemaPath) {
57
57
  return rank;
58
58
  }
59
59
 
60
+ // The documents a root's references reach beyond itself, id to schema, from
61
+ // the validators that own the root (the `schemas` option, addSchema) or a
62
+ // schema module's registry. Several validators of one root schema register
63
+ // their maps into one; an id that two of them give different schemas makes the
64
+ // root resolve nothing outside itself, as it did before any was registered.
65
+ const _documents = new WeakMap();
66
+ const CONFLICT = new Map();
67
+ function registerDocuments(rootSchema, map) {
68
+ if (rootSchema === null || typeof rootSchema !== 'object' || !map || map.size === 0) return;
69
+ const had = _documents.get(rootSchema);
70
+ if (had === CONFLICT) return;
71
+ if (had === undefined) { _documents.set(rootSchema, new Map(map)); _rankCache.delete(rootSchema); return; }
72
+ let added = false;
73
+ for (const [id, doc] of map) {
74
+ const cur = had.get(id);
75
+ if (cur === undefined) { had.set(id, doc); added = true; } else if (cur !== doc) { _documents.set(rootSchema, CONFLICT); _rankCache.delete(rootSchema); return; }
76
+ }
77
+ if (added) _rankCache.delete(rootSchema);
78
+ }
79
+
80
+ function withoutEmptyFragment(uri) {
81
+ return uri.endsWith('#') ? uri.slice(0, -1) : uri;
82
+ }
83
+
84
+ function resolveUri(ref, base) {
85
+ try { return base ? new URL(ref, base).href : new URL(ref).href; } catch { return null; }
86
+ }
87
+
60
88
  function computeRank(rootSchema, schemaPath) {
61
89
  const rank = [];
62
90
  let node = rootSchema;
63
91
  let start = 1;
64
92
  let hops = 0;
93
+ const docs = _documents.get(rootSchema);
94
+ // The document the walk is in and the base URI references resolve against,
95
+ // for following a reference into another document.
96
+ let doc = rootSchema;
97
+ // A root without an $id can still name another document by absolute URI.
98
+ const follow = docs !== undefined && docs !== CONFLICT;
99
+ let base = follow && typeof rootSchema.$id === 'string' ? resolveUri(rootSchema.$id, null) : null;
65
100
  while (start <= schemaPath.length) {
66
101
  let end = schemaPath.indexOf('/', start);
67
102
  if (end < 0) end = schemaPath.length;
68
103
  if (end === start) { start = end + 1; continue; }
69
104
  const seg = unescapePointerSegment(schemaPath.slice(start, end));
70
105
  if (node == null || typeof node !== 'object') break;
106
+ if (follow && node !== doc && !Array.isArray(node) && typeof node.$id === 'string' && node.$id[0] !== '#') base = resolveUri(node.$id, base);
71
107
  if (Array.isArray(node)) {
72
108
  const idx = Number(seg);
73
109
  if (!Number.isInteger(idx) || idx < 0 || idx >= node.length) break;
@@ -80,7 +116,11 @@ function computeRank(rootSchema, schemaPath) {
80
116
  // naming it. Ranking the rest inside the target, as if the target sat
81
117
  // under the `$ref` key, orders errors behind a reference by the
82
118
  // target's declaration order, as they are ordered inline.
83
- const target = hops < 64 ? localTarget(rootSchema, node) : null;
119
+ let target = hops < 64 ? localTarget(doc, node) : null;
120
+ if (target === null && hops < 64 && follow) {
121
+ const remote = remoteTarget(docs, base, node);
122
+ if (remote !== null) { target = remote.node; doc = remote.doc; base = remote.base; }
123
+ }
84
124
  if (target === null) break;
85
125
  hops++;
86
126
  rank.push(keyIndex(node, '$ref'));
@@ -95,6 +135,28 @@ function computeRank(rootSchema, schemaPath) {
95
135
  return rank;
96
136
  }
97
137
 
138
+ // The schema a node's `$ref` names in a registered document: { node, doc,
139
+ // base }, or null when it names none (an anchor, an unknown document).
140
+ function remoteTarget(docs, base, node) {
141
+ const ref = node.$ref;
142
+ if (typeof ref !== 'string' || ref.charCodeAt(0) === 35) return null;
143
+ const abs = resolveUri(ref, base);
144
+ if (abs === null) return null;
145
+ const hash = abs.indexOf('#');
146
+ const uri = hash < 0 ? abs : abs.slice(0, hash);
147
+ let target = docs.get(uri);
148
+ if (target === undefined) target = docs.get(uri + '#');
149
+ if (target === undefined) {
150
+ for (const [id, s] of docs) if (withoutEmptyFragment(resolveUri(id, null) || '') === uri) { target = s; break; }
151
+ }
152
+ if (target === null || typeof target !== 'object') return null;
153
+ const frag = hash < 0 ? '' : abs.slice(hash + 1);
154
+ if (frag === '') return { node: target, doc: target, base: uri };
155
+ if (frag.charCodeAt(0) !== 47) return null;
156
+ const t = localTarget(target, { $ref: '#' + frag });
157
+ return t === null ? null : { node: t, doc: target, base: uri };
158
+ }
159
+
98
160
  // The schema a node's `$ref` names, when it is a pointer into this document.
99
161
  function localTarget(rootSchema, node) {
100
162
  const ref = node.$ref;
@@ -191,4 +253,4 @@ function ordinalFor(rootSchema, schemaPath) {
191
253
  const _missCache = new WeakMap();
192
254
  const MISS_CACHE_LIMIT = 4096;
193
255
 
194
- module.exports = { rankFor, ordinalFor, unescapePointerSegment };
256
+ module.exports = { rankFor, ordinalFor, unescapePointerSegment, registerDocuments };
@@ -10,8 +10,32 @@
10
10
  const { createInterpreter } = require('./interpreter');
11
11
  const { errorsOf, interpreterOptions } = require('./error-runtime-entry');
12
12
 
13
+ // A registry is the array of schemas a build writes beside schema modules
14
+ // whose schemas reference each other by `$id` (ata-registry.mjs). It becomes
15
+ // one Map, id to schema, shared by every module of the build.
16
+ const _maps = new WeakMap();
17
+ function registryMap(registry) {
18
+ let map = _maps.get(registry);
19
+ if (map === undefined) {
20
+ map = new Map();
21
+ for (const s of registry) map.set(s.$id, s);
22
+ _maps.set(registry, map);
23
+ }
24
+ return map;
25
+ }
26
+
27
+ // options.registry and options.id: the module's schema is the registry entry
28
+ // with that `$id`, and its references resolve against the registry.
13
29
  function createValidator(schema, codeOf, options) {
14
- const interp = createInterpreter(schema, interpreterOptions(options));
30
+ const opts = interpreterOptions(options);
31
+ if (options && options.registry) {
32
+ const map = registryMap(options.registry);
33
+ if (schema === null || schema === undefined) schema = map.get(options.id);
34
+ opts.schemaMap = map;
35
+ opts.flat = false;
36
+ require('./schema-order').registerDocuments(schema, map);
37
+ }
38
+ const interp = createInterpreter(schema, opts);
15
39
  return {
16
40
  isValid: (data) => interp.isValid(data),
17
41
  errors: (data) => errorsOf(interp, schema, codeOf, data),
@@ -864,6 +864,9 @@ class Validator {
864
864
  if (this._initialized) return;
865
865
  this._ensureVocabularies();
866
866
  this._initialized = true;
867
+ // Errors behind a reference into a registered document are ordered as
868
+ // they would be inline: the ordering follows the reference there.
869
+ if (this._schemaMap && this._schemaMap.size > 0) require('./schema-order').registerDocuments(this._schemaObj, this._schemaMap);
867
870
 
868
871
  const schemaObj = this._schemaObj;
869
872
  const options = this._options;
package/lib/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  //
8
8
  // Kept in lockstep with package.json by `tests/test_version_sync.js`.
9
9
 
10
- module.exports = '1.50.0';
10
+ module.exports = '1.51.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ata-validator",
3
- "version": "1.50.0",
3
+ "version": "1.51.0",
4
4
  "description": "JSON Schema validator that compiles to plain JavaScript, still runs where code generation is blocked, and can be compiled away at build time. Passes the full official test suite.",
5
5
  "type": "commonjs",
6
6
  "main": "index.js",
@@ -172,13 +172,13 @@
172
172
  "LICENSE"
173
173
  ],
174
174
  "optionalDependencies": {
175
- "@ata-validator/native-darwin-arm64": "1.50.0",
176
- "@ata-validator/native-darwin-x64": "1.50.0",
177
- "@ata-validator/native-linux-arm64-gnu": "1.50.0",
178
- "@ata-validator/native-linux-arm64-musl": "1.50.0",
179
- "@ata-validator/native-linux-x64-gnu": "1.50.0",
180
- "@ata-validator/native-linux-x64-musl": "1.50.0",
181
- "@ata-validator/native-win32-x64": "1.50.0"
175
+ "@ata-validator/native-darwin-arm64": "1.51.0",
176
+ "@ata-validator/native-darwin-x64": "1.51.0",
177
+ "@ata-validator/native-linux-arm64-gnu": "1.51.0",
178
+ "@ata-validator/native-linux-arm64-musl": "1.51.0",
179
+ "@ata-validator/native-linux-x64-gnu": "1.51.0",
180
+ "@ata-validator/native-linux-x64-musl": "1.51.0",
181
+ "@ata-validator/native-win32-x64": "1.51.0"
182
182
  },
183
183
  "peerDependencies": {
184
184
  "yaml": "^2.0.0"