ata-validator 1.48.0 → 1.50.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/lib/aot-build.js CHANGED
@@ -87,19 +87,102 @@ function hashContent(buf) {
87
87
  return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32);
88
88
  }
89
89
 
90
+ // Where the shared error runtime goes: the output directory when there is
91
+ // one, otherwise the deepest directory that holds every output.
92
+ function runtimePathFor(outputs, opts, format) {
93
+ const name = 'ata-errors' + (format === 'cjs' ? '.cjs' : '.mjs');
94
+ if (opts.outDir) return path.join(opts.outDir, name);
95
+ let dir = path.resolve(path.dirname(outputs[0]));
96
+ for (const o of outputs) {
97
+ const d = path.resolve(path.dirname(o));
98
+ while (dir !== d && !d.startsWith(dir + path.sep)) dir = path.dirname(dir);
99
+ }
100
+ return path.join(dir, name);
101
+ }
102
+
103
+ function importSpecifier(fromFile, toFile) {
104
+ let rel = path.relative(path.dirname(path.resolve(fromFile)), path.resolve(toFile)).split(path.sep).join('/');
105
+ if (!rel.startsWith('.')) rel = './' + rel;
106
+ return rel;
107
+ }
108
+
109
+ const RUNTIME_PLACEHOLDER = '__ATA_ERROR_RUNTIME__';
110
+ const ERRORS_MODES = new Set(['auto', 'inline', 'shared']);
111
+
90
112
  async function build(opts) {
91
113
  const globs = opts.globs || [];
92
114
  if (globs.length === 0) throw new Error('build: at least one glob required');
93
115
  const format = opts.format || 'esm';
116
+ const errorsMode = opts.errors || 'auto';
117
+ if (!ERRORS_MODES.has(errorsMode)) throw new Error(`build: errors must be auto, inline or shared (got "${errorsMode}")`);
94
118
  const inputs = await expandGlobs(globs);
95
119
  const cache = readCache(opts.cacheFile);
96
120
  const newCache = {};
121
+ const source = resolveSourceDefault(opts);
122
+ // Error detail from the shared runtime replaces generated error code, so it
123
+ // is only on offer where that code is what the build would emit: not with
124
+ // schema-source frames (development builds), stub errors or the one-pass
125
+ // function, which are each asked for by name.
126
+ const sharable = errorsMode !== 'inline' && !source && !opts.abortEarly && opts.onePass !== true;
127
+ // `mode: 'schema'`, asked for by name: every module that can be is a schema
128
+ // module (its schema and a few lines), with the verdict and the errors from
129
+ // one runtime. Smaller and quicker to load than generated code, slower per
130
+ // check once warm.
131
+ if (opts.mode !== undefined && opts.mode !== 'code' && opts.mode !== 'schema') throw new Error(`build: mode must be code or schema (got "${opts.mode}")`);
132
+ const schemaMode = opts.mode === 'schema' && !opts.abortEarly;
97
133
 
98
134
  const compiled = [];
99
135
  const cached = [];
100
136
  const skipped = [];
101
137
  const failed = [];
102
138
  const warnings = [];
139
+ const entries = [];
140
+
141
+ const compileOne = (input, raw) => {
142
+ const schema = parseSchemaFile(input);
143
+ // The build is where an authoring mistake is cheapest to stop: a typo
144
+ // like maxLenght compiles into a validator that simply lacks the rule,
145
+ // and a compiled module fails even more quietly than a runtime one,
146
+ // since nobody ever revisits it. Findings fail the input's build.
147
+ if (opts.strictSchema) {
148
+ const { checkSchemaStrict } = require('./strict-check');
149
+ const problems = checkSchemaStrict(schema, {});
150
+ if (problems.length > 0) {
151
+ return { error: problems.map((x) => `strict mode: ${x.message} at ${x.path}`).join('; ') };
152
+ }
153
+ }
154
+ const v = new Validator(schema);
155
+ let sourceMap = null;
156
+ if (source) {
157
+ // Only attempt position map for JSON (the scanner is JSON-only).
158
+ const ext = path.extname(input).toLowerCase();
159
+ if (ext === '.json') {
160
+ try { sourceMap = buildPositionMap(raw.toString('utf8')); }
161
+ catch { sourceMap = null; }
162
+ }
163
+ }
164
+ const schemaFile = path.relative(process.cwd(), input) || input;
165
+ const inputWarnings = [];
166
+ const src = aot.toStandaloneModule(v, {
167
+ format,
168
+ abortEarly: !!opts.abortEarly,
169
+ onePass: opts.onePass === true ? true : undefined,
170
+ source,
171
+ sourceMap,
172
+ schemaFile,
173
+ formatMode: opts.formatMode,
174
+ onWarning: (w, meta) => inputWarnings.push({ message: w, kind: (meta && meta.kind) || 'general' }),
175
+ });
176
+ const compact = schemaMode && src
177
+ ? aot.toSchemaModule(v, { format, runtime: RUNTIME_PLACEHOLDER })
178
+ : sharable && src ? aot.toCompactModule(v, { format, formatMode: opts.formatMode, errorRuntime: RUNTIME_PLACEHOLDER }) : null;
179
+ // 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;
181
+ // A module that imports the pattern engine needs it in the runtime,
182
+ // whatever the schema scan said.
183
+ if (features && compact.includes('_ataCompileSafe')) features['safe-regex'] = true;
184
+ return { schema, src, compact, features, inputWarnings };
185
+ };
103
186
 
104
187
  for (const input of inputs) {
105
188
  try {
@@ -107,100 +190,119 @@ async function build(opts) {
107
190
  const inputHash = hashContent(raw);
108
191
  const output = outputPathFor(input, { ...opts, format });
109
192
  const cacheEntry = cache[input];
110
- if (opts.check) {
111
- const upToDate = (
112
- cacheEntry &&
113
- cacheEntry.inputHash === inputHash &&
114
- cacheEntry.output === output &&
115
- fs.existsSync(output) &&
116
- cacheEntry.outputHash === hashContent(fs.readFileSync(output))
117
- );
118
- if (upToDate) {
119
- cached.push({ input, output });
120
- }
121
- continue;
122
- }
123
- if (
193
+ const fresh = !!(
124
194
  cacheEntry &&
125
195
  cacheEntry.inputHash === inputHash &&
126
196
  cacheEntry.output === output &&
197
+ !!cacheEntry.sharable === sharable &&
198
+ !!cacheEntry.schemaMode === schemaMode &&
127
199
  fs.existsSync(output) &&
128
200
  cacheEntry.outputHash === hashContent(fs.readFileSync(output))
129
- ) {
130
- cached.push({ input, output });
131
- newCache[input] = cacheEntry;
201
+ );
202
+ if (opts.check) {
203
+ if (fresh) cached.push({ input, output });
132
204
  continue;
133
205
  }
134
- const schema = parseSchemaFile(input);
135
- // The build is where an authoring mistake is cheapest to stop: a typo
136
- // like maxLenght compiles into a validator that simply lacks the rule,
137
- // and a compiled module fails even more quietly than a runtime one,
138
- // since nobody ever revisits it. Findings fail the input's build.
139
- if (opts.strictSchema) {
140
- const { checkSchemaStrict } = require('./strict-check');
141
- const problems = checkSchemaStrict(schema, {});
142
- if (problems.length > 0) {
143
- failed.push({ input, error: problems.map((x) => `strict mode: ${x.message} at ${x.path}`).join('; ') });
144
- continue;
145
- }
146
- }
147
- const v = new Validator(schema);
148
- const source = resolveSourceDefault(opts);
149
- let sourceMap = null;
150
- if (source) {
151
- // Only attempt position map for JSON (the scanner is JSON-only).
152
- const ext = path.extname(input).toLowerCase();
153
- if (ext === '.json') {
154
- try { sourceMap = buildPositionMap(raw.toString('utf8')); }
155
- catch { sourceMap = null; }
156
- }
206
+ if (fresh) {
207
+ entries.push({ input, output, raw, inputHash, cacheEntry, fullGz: cacheEntry.fullGz, compactGz: cacheEntry.compactGz, features: cacheEntry.features || null });
208
+ continue;
157
209
  }
158
- const schemaFile = path.relative(process.cwd(), input) || input;
159
- const inputWarnings = [];
160
- const src = aot.toStandaloneModule(v, {
161
- format,
162
- abortEarly: !!opts.abortEarly,
163
- onePass: opts.onePass === true ? true : undefined,
164
- source,
165
- sourceMap,
166
- schemaFile,
167
- formatMode: opts.formatMode,
168
- onWarning: (w, meta) => inputWarnings.push({ message: w, kind: (meta && meta.kind) || 'general' }),
169
- });
170
- if (inputWarnings.length > 0) {
210
+ const r = compileOne(input, raw);
211
+ if (r.error) { failed.push({ input, error: r.error }); continue; }
212
+ if (r.inputWarnings.length > 0) {
171
213
  if (opts.strict) {
172
- failed.push({ input, error: inputWarnings.map((x) => x.message).join('; ') });
214
+ failed.push({ input, error: r.inputWarnings.map((x) => x.message).join('; ') });
173
215
  continue;
174
216
  }
175
217
  // `kind` says which capability degraded ('error-detail', 'parse'),
176
218
  // so a caller that requested both can tell the warnings apart.
177
- for (const x of inputWarnings) warnings.push({ input, warning: x.message, kind: x.kind });
219
+ for (const x of r.inputWarnings) warnings.push({ input, warning: x.message, kind: x.kind });
178
220
  }
179
- if (!src) {
221
+ if (!r.src) {
180
222
  const reason = 'schema is not AOT-compatible (toStandaloneModule returned null)';
181
223
  if (opts.strict) failed.push({ input, error: reason });
182
224
  else skipped.push({ input, reason });
183
225
  continue;
184
226
  }
227
+ entries.push({
228
+ input, output, raw, inputHash, schema: r.schema, src: r.src, compact: r.compact, features: r.features,
229
+ fullGz: zlib.gzipSync(r.src).length,
230
+ compactGz: r.compact ? zlib.gzipSync(r.compact).length : null,
231
+ });
232
+ } catch (e) {
233
+ failed.push({ input, error: e.message });
234
+ }
235
+ }
236
+
237
+ if (opts.check) {
238
+ const staleCount = inputs.length - cached.length;
239
+ return { compiled: [], cached, skipped, failed, warnings, staleCount };
240
+ }
241
+
242
+ // A module takes its errors from the shared runtime when that makes it
243
+ // smaller, and the runtime is written when the modules that use it save
244
+ // more than it weighs. With `errors: 'shared'` every module that can, does.
245
+ // The runtime carries only the parts the compact modules' schemas need
246
+ // (formats, the pattern engine, deep equality); an entry with no record of
247
+ // its needs (a cache written before this) counts as needing everything.
248
+ const { errorRuntimeSource, runtimeFeatures } = require('./error-runtime');
249
+ const ATA_VERSION = require('./version');
250
+ const unionOf = (list) => {
251
+ const out = runtimeFeatures([]);
252
+ for (const e of list) for (const k of Object.keys(out)) if (!e.features || e.features[k]) out[k] = true;
253
+ return out;
254
+ };
255
+ const candidates = entries.filter((e) => typeof e.compactGz === 'number' && (schemaMode || errorsMode === 'shared' || e.compactGz < e.fullGz));
256
+ let runtime = null;
257
+ if ((sharable || schemaMode) && candidates.length > 0) {
258
+ const runtimeSrc = errorRuntimeSource(format, ATA_VERSION, unionOf(candidates), schemaMode);
259
+ const runtimeGz = zlib.gzipSync(runtimeSrc).length;
260
+ let saving = 0;
261
+ for (const e of candidates) saving += e.fullGz - e.compactGz;
262
+ const use = schemaMode || errorsMode === 'shared' || saving > runtimeGz;
263
+ if (use) runtime = { path: runtimePathFor(entries.map((e) => e.output), opts, format), src: runtimeSrc, gz: runtimeGz };
264
+ }
265
+ for (const e of entries) {
266
+ e.mode = runtime !== null && candidates.includes(e) ? (schemaMode ? 'schema' : 'compact') : 'inline';
267
+ }
268
+
269
+ for (const e of entries) {
270
+ const { input, output } = e;
271
+ try {
272
+ if (e.cacheEntry) {
273
+ const sameRuntime = e.mode === 'inline' || (runtime !== null && e.cacheEntry.runtime === runtime.path);
274
+ if (e.cacheEntry.mode === e.mode && sameRuntime) {
275
+ cached.push({ input, output });
276
+ newCache[input] = e.cacheEntry;
277
+ continue;
278
+ }
279
+ // The build's choice for this module changed: compile it again.
280
+ const r = compileOne(input, e.raw);
281
+ if (r.error || !r.src) { failed.push({ input, error: r.error || 'schema is not AOT-compatible' }); continue; }
282
+ e.schema = r.schema; e.src = r.src; e.compact = r.compact; e.features = r.features;
283
+ if (e.mode !== 'inline' && !r.compact) e.mode = 'inline';
284
+ }
285
+ const src = e.mode !== 'inline'
286
+ ? e.compact.split(JSON.stringify(RUNTIME_PLACEHOLDER)).join(JSON.stringify(importSpecifier(output, runtime.path)))
287
+ : e.src;
185
288
  fs.mkdirSync(path.dirname(output), { recursive: true });
186
289
  fs.writeFileSync(output, src);
187
290
  const outBytes = Buffer.byteLength(src, 'utf8');
188
- const gz = zlib.gzipSync(src);
189
- const gzBytes = gz.length;
291
+ const gzBytes = zlib.gzipSync(src).length;
190
292
  if (typeof opts.maxSize === 'number' && gzBytes > opts.maxSize) {
191
293
  // Roll back the write so a failed build doesn't leave a stale artifact.
192
294
  try { fs.unlinkSync(output); } catch {}
193
295
  failed.push({ input, error: `output ${output} exceeds --max-size: ${gzBytes} > ${opts.maxSize} (gzipped bytes)` });
194
296
  continue;
195
297
  }
196
- compiled.push({ input, output, bytes: outBytes, gzipBytes: gzBytes });
298
+ compiled.push({ input, output, bytes: outBytes, gzipBytes: gzBytes, errors: e.mode });
197
299
  if (opts.types !== false) {
198
300
  const { toTypeScript } = require('./ts-gen');
199
301
  const stem = path.basename(input, path.extname(input)).replace(/\.schema$/, '');
200
302
  const typeName = stem
201
303
  .replace(/[^A-Za-z0-9_]/g, '_')
202
304
  .replace(/^([a-z])/, (m) => m.toUpperCase()) || 'Data';
203
- const dts = toTypeScript(schema, { name: typeName });
305
+ const dts = toTypeScript(e.schema, { name: typeName });
204
306
  const ext = path.extname(output);
205
307
  const dtsExt = ext === '.mjs' ? '.d.mts'
206
308
  : ext === '.cjs' ? '.d.cts'
@@ -209,23 +311,35 @@ async function build(opts) {
209
311
  fs.writeFileSync(base + dtsExt, dts);
210
312
  }
211
313
  newCache[input] = {
212
- inputHash,
314
+ inputHash: e.inputHash,
213
315
  output,
214
316
  outputHash: hashContent(Buffer.from(src, 'utf8')),
317
+ sharable,
318
+ schemaMode,
319
+ mode: e.mode,
320
+ runtime: e.mode !== 'inline' ? runtime.path : undefined,
321
+ fullGz: e.fullGz,
322
+ compactGz: e.compactGz,
323
+ features: e.features || undefined,
215
324
  };
216
- } catch (e) {
217
- failed.push({ input, error: e.message });
325
+ } catch (err) {
326
+ failed.push({ input, error: err.message });
218
327
  }
219
328
  }
220
329
 
221
- if (opts.check) {
222
- const staleCount = inputs.length - cached.length;
223
- return { compiled: [], cached, skipped, failed, warnings, staleCount };
330
+ let runtimeReport = null;
331
+ if (runtime !== null && entries.some((e) => e.mode !== 'inline')) {
332
+ const current = fs.existsSync(runtime.path) ? fs.readFileSync(runtime.path, 'utf8') : null;
333
+ if (current !== runtime.src) {
334
+ fs.mkdirSync(path.dirname(runtime.path), { recursive: true });
335
+ fs.writeFileSync(runtime.path, runtime.src);
336
+ }
337
+ runtimeReport = { output: runtime.path, bytes: Buffer.byteLength(runtime.src, 'utf8'), gzipBytes: runtime.gz };
224
338
  }
225
339
 
226
340
  writeCache(opts.cacheFile, newCache);
227
341
 
228
- return { compiled, cached, skipped, failed, warnings };
342
+ return { compiled, cached, skipped, failed, warnings, runtime: runtimeReport };
229
343
  }
230
344
 
231
345
  async function watch(opts, onReport) {
@@ -322,12 +436,41 @@ function compiledSchemaFor(schema) {
322
436
  // error function the generator declines (unevaluated* among them), where the
323
437
  // module would report every failure as the ATA9000 stub while the runtime
324
438
  // reports the real errors.
439
+ //
440
+ // With `errorRuntime` (an import specifier, usually 'ata-validator/error-runtime'),
441
+ // the module is written compact where the validator allows it: the verdict and
442
+ // the schema, with errors from that runtime (see toCompactModule). A plugin
443
+ // writes it in place of the self-contained module when that is smaller for
444
+ // the whole bundle; the runtime is shared by every compact module in it.
445
+ // A plugin asks for both modules of one schema (the self-contained one, and
446
+ // the compact one to compare): one Validator answers both, so the verdict
447
+ // function compiles once. Keyed by the schema object the plugin passes.
448
+ const _validators = new WeakMap();
449
+ function validatorFor(schema) {
450
+ let v = _validators.get(schema);
451
+ // Built with an options object, empty, so it stays out of the identity
452
+ // cache: `new Validator(schema)` in the caller's own code must get its own
453
+ // instance, not this compiled one.
454
+ if (v === undefined) { v = new Validator(schema, {}); _validators.set(schema, v); }
455
+ return v;
456
+ }
457
+
325
458
  function compiledModuleFor(schema, opts) {
326
459
  if (!compiledEligible(schema)) return null;
460
+ if (opts && typeof opts.errorRuntime === 'string') {
461
+ try {
462
+ const v = validatorFor(schema);
463
+ const src = aot.toCompactModule(v, { format: opts.format || 'esm', errorRuntime: opts.errorRuntime });
464
+ if (src) return src;
465
+ } catch {
466
+ return null;
467
+ }
468
+ // Not writable as a compact module: the self-contained one below.
469
+ }
327
470
  let degraded = false;
328
471
  let src;
329
472
  try {
330
- src = toStandaloneModule(schema, {
473
+ src = aot.toStandaloneModule(validatorFor(schema), {
331
474
  format: (opts && opts.format) || 'esm',
332
475
  onWarning: (msg, info) => { if (info && info.kind === 'error-detail') degraded = true; },
333
476
  });