ata-validator 1.27.1 → 1.29.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/README.md CHANGED
@@ -445,11 +445,14 @@ const v = new Validator(schema, {
445
445
  removeAdditional: true, // strip properties not in schema
446
446
  schemas: [otherSchema], // cross-schema $ref registry
447
447
  abortEarly: true, // skip detailed error collection on failure (~4x faster on invalid data)
448
+ engine: 'interpreter', // eval-free interpreted engine only: for a schema from outside the trust boundary
448
449
  });
449
450
  ```
450
451
 
451
452
  `abortEarly` returns a shared `{ valid: false, errors: [{ message: 'validation failed' }] }` on failure instead of running the detailed error collector. Useful when the caller only needs a pass/fail decision (Fastify route guards, high-throughput gatekeepers, request rejection at the edge).
452
453
 
454
+ `engine: 'interpreter'` keeps one validator off code generation: no `new Function`, no shared compile cache, the interpreted engine answers every call. The default turns a schema into JavaScript source, which is the right trade for a schema you wrote; a schema that arrives at runtime from a plugin or a tenant is input, and this option validates against it without ever executing anything derived from it. Same verdict, higher per-call cost; `ATA_FORCE_NAPI=1` does the same for a whole process.
455
+
453
456
  ### Build-time compile (`ata compile`)
454
457
 
455
458
  The `ata` CLI turns a JSON Schema file into a self-contained JavaScript module. No runtime dependency on `ata-validator`, so only the generated validator ships to the browser. Typical output is about 4.5 KB gzipped for a ten-field schema, full error detail included, against 87 KB for the runtime bundled for the browser.
@@ -700,6 +703,9 @@ npm run test:suite
700
703
 
701
704
  ## Project
702
705
 
706
+ - [CHANGELOG.md](CHANGELOG.md) records every release. It is kept in the
707
+ repository and not shipped in the npm package, where its history had grown to
708
+ about 13% of the install.
703
709
  - [CONTRIBUTING.md](CONTRIBUTING.md) explains how to build the project and what a
704
710
  pull request needs before it can be merged.
705
711
  - [GOVERNANCE.md](GOVERNANCE.md) says who decides what, which changes the project
package/build.d.ts CHANGED
@@ -144,8 +144,10 @@ export function bundleCompact(schemas: unknown[], options?: BundleStandaloneOpti
144
144
  * Stable content hash of a schema, 16 hex characters, over a canonical JSON
145
145
  * form (keys sorted at every level). Every module from
146
146
  * {@link toStandaloneModule} exports its own `schemaHash`; comparing that
147
- * against `schemaHash(currentSchema)` tells a build the module is stale.
148
- * An integrity aid, not a security boundary.
147
+ * against `schemaHash(currentSchema)` tells a build the schema has changed.
148
+ * It does not tell the build that ata has changed, since an upgrade leaves
149
+ * the hash matching, so compare the module's `ataVersion` against the
150
+ * installed version as well. An integrity aid, not a security boundary.
149
151
  */
150
152
  export function schemaHash(schema: unknown): string;
151
153
 
package/index.d.ts CHANGED
@@ -425,6 +425,17 @@ export interface ValidatorOptions {
425
425
  * v0.14 error shape.
426
426
  */
427
427
  richErrors?: boolean;
428
+ /**
429
+ * Which engine may answer this validator. 'auto' (default) picks the fastest
430
+ * engine that handles the schema, generated JavaScript for most schemas.
431
+ * 'interpreter' keeps the schema off code generation entirely: no
432
+ * `new Function`, no shared compile cache; the eval-free interpreted engine
433
+ * answers `validate()`, `isValidObject()` and `validateJSON()`. For a schema
434
+ * that arrives from outside the trust boundary. The verdict is the same on
435
+ * every engine; the cost is not. The buffer APIs (`isValid`, `countValid`,
436
+ * `batchIsValid`) are native-only and unaffected.
437
+ */
438
+ engine?: 'auto' | 'interpreter';
428
439
  }
429
440
 
430
441
  export interface BundleStandaloneOptions extends ValidatorOptions {
@@ -473,7 +484,7 @@ export interface Validator<T = unknown> {
473
484
  * Which engine answers `validate()` for this schema: 'codegen' (generated
474
485
  * JS), 'closure' (the closure compiler) or 'interpreter'; 'native' is
475
486
  * reserved. The verdict is the same on every engine; the cost is not.
476
- * A diagnostic, not a configuration.
487
+ * A diagnostic; the `engine` option is the setting.
477
488
  */
478
489
  engine(): 'codegen' | 'closure' | 'native' | 'interpreter';
479
490
 
package/index.js CHANGED
@@ -462,11 +462,18 @@ const { rankFor: schemaOrderRank, ordinalFor: schemaOrdinal } = require('./lib/s
462
462
  // that carry only message and path, such as the Standard Schema bridge.
463
463
  // Prototype accessors, not per-instance ones: see LazyRejection.
464
464
  class RichRejection {
465
- constructor(result, data, positions, self, root, enrich) {
465
+ // `rawInput` is the JSON text validateJSON was given, or null for validate(data).
466
+ // The position map it implies is built in the `errors` getter, not here: it is
467
+ // a full walk of the document, it is only ever read through an error's
468
+ // dataFrame, and building it on every rejection cost a caller that reads
469
+ // `.valid` about 460 microseconds on a 50 KB document. Holding the text rather
470
+ // than reading `self._lastRawInput` later also keeps the result independent of
471
+ // what the instance does after this call returns.
472
+ constructor(result, data, rawInput, self, root, enrich) {
466
473
  this.valid = false;
467
474
  this._result = result;
468
475
  this._data = data;
469
- this._positions = positions;
476
+ this._rawInput = rawInput;
470
477
  this._self = self;
471
478
  this._root = root;
472
479
  this._enrich = enrich;
@@ -490,11 +497,17 @@ Object.defineProperty(RichRejection.prototype, 'errors', {
490
497
  const enrich = this._enrich;
491
498
  let raw = this._result.errors || [];
492
499
  if (raw.length > 1) raw = sortErrorsBySchemaOrder(this._root, raw);
500
+ // The position map, resolved now that an error is actually being read.
501
+ let positions = null;
502
+ if (enrich && raw.length && this._rawInput != null) {
503
+ positions = self._pos().get(this._rawInput);
504
+ if (positions) self._posCache.reset();
505
+ }
493
506
  // One options object for the whole list, not one per error.
494
507
  const opts = enrich && raw.length
495
508
  ? {
496
509
  data: this._data,
497
- positions: this._positions,
510
+ positions,
498
511
  schemaPositions: self._schemaPositions,
499
512
  schemaFile: self._source ? self._source.path : undefined,
500
513
  }
@@ -518,6 +531,98 @@ Object.defineProperty(RichRejection.prototype, 'errors', {
518
531
  },
519
532
  });
520
533
 
534
+ // The rejection validateJSON returns. Everything the text path adds over
535
+ // validate(data), the value tree enrichment needs, the position map, the
536
+ // diagnostic payload, happens on first access to `.errors`. Reading `.valid`
537
+ // touches none of it. The JSON text is held here rather than read back off the
538
+ // validator, so the result does not depend on what the instance does next.
539
+ class LazyJsonRejection {
540
+ constructor(result, jsonStr, self, enrich) {
541
+ this.valid = false;
542
+ this._result = result;
543
+ this._jsonStr = jsonStr;
544
+ this._self = self;
545
+ this._enrich = enrich;
546
+ this._cached = null;
547
+ }
548
+ toJSON() {
549
+ return { valid: false, errors: this.errors };
550
+ }
551
+ }
552
+ Object.defineProperty(LazyJsonRejection.prototype, 'errors', {
553
+ enumerable: true,
554
+ configurable: true,
555
+ get() {
556
+ if (this._cached !== null) return this._cached;
557
+ const self = this._self;
558
+ const enrich = this._enrich;
559
+ const jsonStr = this._jsonStr;
560
+ // Reading the inner errors realizes the inner lazy layer, if there was one.
561
+ const raw = this._result.errors || [];
562
+ if (!raw.length) { this._cached = raw; return raw; }
563
+
564
+ // The enrich pass plucks `received` from the value tree and the suggestion
565
+ // engine (required-typo, format hints, coercion nudges) walks it too.
566
+ let parsedData;
567
+ try { parsedData = JSON.parse(jsonStr); } catch { parsedData = undefined; }
568
+
569
+ // Errors the inner path already enriched carry a docUrl: only enrich() sets
570
+ // one. `code` is not a safe signal, because branch-collapse attaches codes
571
+ // to raw errors, and detecting on it left every collapsed oneOf/anyOf error
572
+ // unenriched on the text path.
573
+ if (!raw[0] || !raw[0].docUrl) {
574
+ const positions = self._pos().get(jsonStr);
575
+ // Declaration order, as validate() applies it; the text path used to
576
+ // enrich in emission order.
577
+ const ordered = raw.length > 1 ? sortErrorsBySchemaOrder(self._schemaObj, raw) : raw;
578
+ const enrichOpts = {
579
+ data: parsedData,
580
+ positions,
581
+ schemaPositions: self._schemaPositions,
582
+ schemaFile: self._source ? self._source.path : undefined,
583
+ };
584
+ const enriched = ordered.map((e) => enrich(e, enrichOpts));
585
+ if (enriched.length > 1) attachRelated(enriched);
586
+ attachDiagnosticSource(enriched, {
587
+ data: parsedData,
588
+ text: jsonStr,
589
+ positions,
590
+ schema: self._schemaObj,
591
+ mutatesInput: self._mutatesInput === true,
592
+ });
593
+ if (positions) self._posCache.reset();
594
+ this._cached = enriched;
595
+ return enriched;
596
+ }
597
+
598
+ // Already enriched, so the frames are usually attached too. Only a gap in
599
+ // them is worth another walk of the document: resolving the map to discover
600
+ // there was nothing to fill cost a second full walk on every rejection.
601
+ if (raw.some((e) => e && !e.dataFrame)) {
602
+ const positions = self._pos().get(jsonStr);
603
+ if (positions) {
604
+ for (const e of raw) {
605
+ if (e && !e.dataFrame) {
606
+ const path = e.path != null ? e.path : (e.instancePath || '');
607
+ const p = positions[path];
608
+ if (p) e.dataFrame = { byteOffset: p.byteOffset, length: p.length, line: p.line, col: p.col, text: p.text };
609
+ }
610
+ }
611
+ self._posCache.reset();
612
+ }
613
+ }
614
+ if (raw.length > 1) attachRelated(raw);
615
+ attachDiagnosticSource(raw, {
616
+ data: parsedData,
617
+ text: jsonStr,
618
+ schema: self._schemaObj,
619
+ mutatesInput: self._mutatesInput === true,
620
+ });
621
+ this._cached = raw;
622
+ return raw;
623
+ },
624
+ });
625
+
521
626
  // A raw error without the `_o` ordering key, for the legacy error shape.
522
627
  function stripOrdinal(e) {
523
628
  if (e === null || typeof e !== 'object' || e._o === undefined) return e;
@@ -896,6 +1001,18 @@ class Validator {
896
1001
  this._rawIsCallers = typeof schema !== "string";
897
1002
  this._options = options;
898
1003
  this._noOpts = !opts;
1004
+ // engine: 'interpreter' keeps this validator off code generation: no
1005
+ // `new Function`, no shared compile cache, the eval-free interpreted
1006
+ // engine answers validate(), isValidObject() and validateJSON(). For a
1007
+ // schema that arrives from outside the trust boundary (a plugin's
1008
+ // declared config shape, a tenant's upload), where turning it into source
1009
+ // is not an acceptable execution model. ATA_FORCE_NAPI does this for the
1010
+ // whole process; the option does it for one validator. A misspelling
1011
+ // must not fall through to codegen, so anything else is refused.
1012
+ if (options.engine !== undefined && options.engine !== 'auto' && options.engine !== 'interpreter') {
1013
+ throw new TypeError("engine must be 'auto' or 'interpreter', got " + JSON.stringify(options.engine));
1014
+ }
1015
+ this._interpretOnly = options.engine === 'interpreter';
899
1016
  this._initialized = false;
900
1017
  this._nativeReady = false;
901
1018
  this._compiled = null;
@@ -1044,11 +1161,14 @@ class Validator {
1044
1161
  // Check cache first -- reuse compiled functions for same schema
1045
1162
  const sm = this._schemaMap.size > 0 ? this._schemaMap : null;
1046
1163
  const mapKey = compileCacheKey(this._schemaStr, this._schemaMap);
1164
+ var _forceNapi = this._interpretOnly || (typeof process !== 'undefined' && process.env && process.env.ATA_FORCE_NAPI);
1047
1165
  // Custom formats are JS functions: bypass the compile cache since they can
1048
- // differ between validators that share the same schema string.
1049
- const cached = (this._userFormats || this._usesKeywords) ? null : _compileCache.get(mapKey);
1166
+ // differ between validators that share the same schema string. An
1167
+ // interpreter-only validator never touches it either way: a function a
1168
+ // trusted validator compiled for the same schema string must not answer
1169
+ // for it.
1170
+ const cached = (this._userFormats || this._usesKeywords || _forceNapi) ? null : _compileCache.get(mapKey);
1050
1171
  let jsFn, jsCombinedFn, jsErrFn, _isCodegen = false;
1051
- var _forceNapi = typeof process !== 'undefined' && process.env && process.env.ATA_FORCE_NAPI;
1052
1172
  // v1 removes the bookending requirement for $dynamicRef. Only the
1053
1173
  // interpreted engine implements that; the JS compiler and the native
1054
1174
  // engine both resolve the 2020-12 way, so a v1 schema using the keyword
@@ -1063,15 +1183,16 @@ class Validator {
1063
1183
  // either. The closure path does not call `new Function` itself, so it
1064
1184
  // survives the block and would quietly handle schemas it gets wrong; the
1065
1185
  // interpreted engine is both eval-free and more correct, so go straight
1066
- // there.
1067
- if (this._v1Dynamic || this._usesKeywords || !codegenAvailable()) {
1186
+ // there. The forced case is decided before the probe: the probe is a
1187
+ // `new Function` too, and a validator that promised none must not run it.
1188
+ if (_forceNapi || this._v1Dynamic || this._usesKeywords || !codegenAvailable()) {
1068
1189
  jsFn = null; jsCombinedFn = null; jsErrFn = null;
1069
1190
  // `full` separates an entry that holds every compiled function from one
1070
1191
  // the verdict-only fast path seeded, where `combined` and `errFn` are null
1071
1192
  // because nothing has tried to build them yet. Both halves of that
1072
1193
  // distinction are null, and reading the second as the first costs this
1073
1194
  // schema its generated error function for the life of the process.
1074
- } else if (cached && cached.jsFn !== undefined && !_forceNapi) {
1195
+ } else if (cached && cached.jsFn !== undefined) {
1075
1196
  // `full` says the error and combined functions exist too. An entry
1076
1197
  // without it still carries a verdict function worth reusing; the pair is
1077
1198
  // built by _buildDeferred below if something asks for an error, and the
@@ -1084,7 +1205,7 @@ class Validator {
1084
1205
  jsErrFn = cached.errFn;
1085
1206
  _isCodegen = !!cached.isCodegen;
1086
1207
  this._engine = _isCodegen ? 'codegen' : jsFn ? 'closure' : null;
1087
- } else if (!_forceNapi) {
1208
+ } else {
1088
1209
  const uf = this._userFormats;
1089
1210
  const _cgFn = compileToJSCodegen(schemaObj, sm, uf);
1090
1211
  jsFn = _cgFn || compileToJS(schemaObj, null, sm);
@@ -1100,8 +1221,6 @@ class Validator {
1100
1221
  if (!uf) {
1101
1222
  _compileCache.set(mapKey, { jsFn, combined: undefined, errFn: undefined, isCodegen: _isCodegen, full: false });
1102
1223
  }
1103
- } else {
1104
- jsFn = null; jsCombinedFn = null; jsErrFn = null;
1105
1224
  }
1106
1225
  this._jsFn = jsFn;
1107
1226
  if (this._engine === undefined) this._engine = null;
@@ -1111,7 +1230,10 @@ class Validator {
1111
1230
  // shape (e.g. Fastify `params: { $ref: 'shared#' }` or property refs like
1112
1231
  // `{ id: { $ref: 'shared#/properties/id' } }`).
1113
1232
  const preprocessSchema = resolveSchemaForPreprocess(schemaObj, this._schemaMap);
1114
- let preprocess = buildPreprocessCodegen(preprocessSchema, options);
1233
+ // The mutator pass (defaults, coercion, removal) is generated source too,
1234
+ // with the schema's `default` values embedded; an interpreter-only
1235
+ // validator takes the closure mutators instead.
1236
+ let preprocess = this._interpretOnly ? null : buildPreprocessCodegen(preprocessSchema, options);
1115
1237
  if (!preprocess) {
1116
1238
  const applyDefaults = options.useDefaults === false ? null : buildDefaultsApplier(preprocessSchema);
1117
1239
  const applyCoerce = options.coerceTypes ? buildCoercer(preprocessSchema) : null;
@@ -1664,15 +1786,15 @@ class Validator {
1664
1786
  // abortEarly returns the shared ATA9000 stub; preserve it as-is so the
1665
1787
  // perf fast path stays allocation-free and the documented code stays stable.
1666
1788
  if (result && result.valid === false && result !== ABORT_EARLY_RESULT) {
1667
- // Positions come from the raw input when validateJSON set one;
1668
- // resolved eagerly since the cache is reset per call.
1669
- const positions = (enrich && self._lastRawInput != null) ? self._pos().get(self._lastRawInput) : null;
1670
- if (positions) self._posCache.reset();
1789
+ // The raw input travels with the rejection when validateJSON set one.
1790
+ // The map it implies is built on first access to `.errors`, so a
1791
+ // caller reading only `.valid` does not pay for a document walk.
1792
+ const rawInput = enrich ? self._lastRawInput : null;
1671
1793
  // One instance of a class with prototype accessors. An object
1672
1794
  // literal with a getter here cost a closure plus an accessor
1673
1795
  // definition on every rejection, several hundred nanoseconds
1674
1796
  // before any error was read.
1675
- return new RichRejection(result, data, positions, self, root, enrich);
1797
+ return new RichRejection(result, data, rawInput, self, root, enrich);
1676
1798
  }
1677
1799
  return result;
1678
1800
  };
@@ -1683,74 +1805,23 @@ class Validator {
1683
1805
  if (this._richErrors && this.validateJSON) {
1684
1806
  const innerJson = this.validateJSON;
1685
1807
  this.validateJSON = (jsonStr) => {
1808
+ // The inner path reads _lastRawInput to hand the raw text to the
1809
+ // rejection it builds. Cleared as soon as it returns: the rejection
1810
+ // carries the text itself, so nothing outlives the call.
1686
1811
  this._lastRawInput = jsonStr;
1687
1812
  let result;
1688
1813
  try {
1689
1814
  result = innerJson(jsonStr);
1690
1815
  } finally {
1691
- // Don't clear here; the enrich step below needs the cache. We
1692
- // clear after enrich, or in the early-return path.
1816
+ this._lastRawInput = null;
1693
1817
  }
1694
- if (result && !result.valid && result.errors && result.errors.length) {
1695
- // If errors came from the inner path that already ran through the
1696
- // wrapped this.validate (codegen jsonValidateFn -> validate path),
1697
- // they may already be enriched. Detect by presence of `docUrl`:
1698
- // only enrich() sets it. `code` is not a safe signal because
1699
- // branch-collapse attaches codes to raw errors, and detecting on
1700
- // it left every collapsed oneOf/anyOf error unenriched on the
1701
- // text path.
1702
- const first = result.errors[0];
1703
- // Re-parse the input once so the enrich pass can pluck `received`
1704
- // and feed the suggestion engine (required-typo, format hints,
1705
- // coercion nudges all need the live value tree), and so the
1706
- // diagnostic payload carries the data on both paths below.
1707
- let parsedData;
1708
- try { parsedData = JSON.parse(jsonStr); } catch { parsedData = undefined; }
1709
- if (!first || !first.docUrl) {
1710
- const positions = (this._lastRawInput != null) ? this._pos().get(this._lastRawInput) : null;
1711
- // Declaration order, as validate() applies it; the text path
1712
- // used to enrich in emission order.
1713
- const ordered = result.errors.length > 1 ? sortErrorsBySchemaOrder(this._schemaObj, result.errors) : result.errors;
1714
- const enrichOpts = {
1715
- data: parsedData,
1716
- positions,
1717
- schemaPositions: this._schemaPositions,
1718
- schemaFile: this._source ? this._source.path : undefined,
1719
- };
1720
- const enriched = ordered.map((e) => enrich(e, enrichOpts));
1721
- if (enriched.length > 1) attachRelated(enriched);
1722
- attachDiagnosticSource(enriched, {
1723
- data: parsedData,
1724
- text: jsonStr,
1725
- positions,
1726
- schema: this._schemaObj,
1727
- mutatesInput: this._mutatesInput === true,
1728
- });
1729
- if (positions) this._posCache.reset();
1730
- this._lastRawInput = null;
1731
- return { valid: false, errors: enriched };
1732
- }
1733
- // Already-enriched path: still attach dataFrame if missing.
1734
- const positions = (this._lastRawInput != null) ? this._pos().get(this._lastRawInput) : null;
1735
- if (positions) {
1736
- for (const e of result.errors) {
1737
- if (e && !e.dataFrame) {
1738
- const path = e.path != null ? e.path : (e.instancePath || '');
1739
- const p = positions[path];
1740
- if (p) e.dataFrame = { byteOffset: p.byteOffset, length: p.length, line: p.line, col: p.col, text: p.text };
1741
- }
1742
- }
1743
- this._posCache.reset();
1744
- }
1745
- if (result.errors.length > 1) attachRelated(result.errors);
1746
- attachDiagnosticSource(result.errors, {
1747
- data: parsedData,
1748
- text: jsonStr,
1749
- schema: this._schemaObj,
1750
- mutatesInput: this._mutatesInput === true,
1751
- });
1818
+ // Every diagnostic the text path adds is deferred. Deciding here
1819
+ // whether there is anything to add would mean reading `result.errors`,
1820
+ // and on the codegen path that realizes the inner lazy layer, which is
1821
+ // the document walk this exists to avoid.
1822
+ if (result && result.valid === false) {
1823
+ return new LazyJsonRejection(result, jsonStr, this, enrich);
1752
1824
  }
1753
- this._lastRawInput = null;
1754
1825
  return result;
1755
1826
  };
1756
1827
  }
@@ -1999,7 +2070,7 @@ class Validator {
1999
2070
  return;
2000
2071
  }
2001
2072
  this._ensureVocabularies();
2002
- if (typeof process !== 'undefined' && process.env && process.env.ATA_FORCE_NAPI) return;
2073
+ if (this._interpretOnly || (typeof process !== 'undefined' && process.env && process.env.ATA_FORCE_NAPI)) return;
2003
2074
  if (!this._schemaStr) this._schemaStr = JSON.stringify(this._schemaObj);
2004
2075
  const sm = this._schemaMap.size > 0 ? this._schemaMap : null;
2005
2076
  const mapKey = compileCacheKey(this._schemaStr, this._schemaMap);
package/lib/aot-impl.js CHANGED
@@ -16,6 +16,7 @@
16
16
  const { compileToJSCodegenWithErrors, compileToJSCodegen, unevalContributions } = require('./js-compiler');
17
17
  const { buildDataPositionMap } = require('./data-positions');
18
18
  const { schemaHash } = require('./schema-hash');
19
+ const ATA_VERSION = require('./version');
19
20
  const SAFE_REGEX_SOURCE = require('./safe-regex-source');
20
21
 
21
22
  // Embedded verbatim in standalone modules so the output file has no runtime
@@ -119,7 +120,7 @@ function toStandalone(validator) {
119
120
  const errSrc = jsErrFn && jsErrFn._errSource ? jsErrFn._errSource : '';
120
121
 
121
122
  const closureSrc = closureDeclLines(jsFn).join('\n');
122
- return `// Auto-generated by ata-validator — do not edit
123
+ return `// Auto-generated by ata-validator ${ATA_VERSION}, do not edit
123
124
  'use strict';
124
125
  ${_CP_LEN_SOURCE}
125
126
  ${safeRePrelude(jsFn, jsErrFn)}${preambleSrc}
@@ -627,10 +628,15 @@ function validateJSON(text) {
627
628
  // can compare it against schemaHash(currentSchema) and know the module is
628
629
  // stale without embedding its own fingerprint.
629
630
  const hashSrc = schemaHash(validator._rawSchema !== undefined ? validator._rawSchema : validator._schemaObj);
630
- const hashDecl = `const schemaHash = ${JSON.stringify(hashSrc)};\n`;
631
+ // The schema hash answers "is this module built from a different schema" and
632
+ // nothing else. Upgrading ata and not re-running the generate step leaves the
633
+ // hash matching, so the stale module reads as current. The module cannot ask
634
+ // the installed ata itself, since it imports nothing, so it carries the
635
+ // version that wrote it and a build compares that too.
636
+ const hashDecl = `const schemaHash = ${JSON.stringify(hashSrc)};\nconst ataVersion = ${JSON.stringify(ATA_VERSION)};\n`;
631
637
 
632
638
  let names = fmt.exportsSetFormats ? baseNames + ', setFormats' : baseNames;
633
- names += ', schemaHash';
639
+ names += ', schemaHash, ataVersion';
634
640
  if (positionsCore) names += ', validateJSON';
635
641
  const parseAlias = cloneExpr ? ', _ataParse as parse' : '';
636
642
  const parseProp = cloneExpr ? ', parse: _ataParse' : '';
@@ -644,8 +650,9 @@ function validateJSON(text) {
644
650
  if (opts && opts.parse && !cloneExpr) {
645
651
  degradedNote += '// NOTE: parse() was requested but could not be generated for this schema;\n// the module has no parse export. Validate and strip with the runtime Validator.\n';
646
652
  }
647
- return `// Auto-generated by ata-validator — do not edit.
653
+ return `// Auto-generated by ata-validator ${ATA_VERSION}, do not edit.
648
654
  // Schema is embedded; runtime has zero dependency on ata-validator.
655
+ // Re-run the build after upgrading ata: the version above is what wrote this.
649
656
  ${degradedNote}'use strict';
650
657
  ${_CP_LEN_SOURCE}
651
658
  ${safeRePrelude(jsFn, jsErrFn)}${schemaSourceConst}const VALID = Object.freeze({ valid: true, errors: Object.freeze([]) });
@@ -786,10 +793,10 @@ function bundleStandalone(Validator, schemas, opts) {
786
793
  : '';
787
794
  if (format === 'esm') {
788
795
  const extra = bundleInjects ? 'export { validators, setFormats };' : 'export { validators };';
789
- return `// Auto-generated by ata-validator — do not edit\n${safeEmbed}${registry}const R=${R};\nconst validators=${arr};\nexport default validators;\n${extra}\n`;
796
+ return `// Auto-generated by ata-validator ${ATA_VERSION}, do not edit\n${safeEmbed}${registry}const R=${R};\nconst validators=${arr};\nexport default validators;\n${extra}\n`;
790
797
  }
791
798
  const attach = bundleInjects ? 'module.exports.setFormats=setFormats;\n' : '';
792
- return `'use strict';\n${safeEmbed}${registry}var R=${R};\nmodule.exports=[${fns.join(',')}];\n${attach}`;
799
+ return `// Auto-generated by ata-validator ${ATA_VERSION}, do not edit\n'use strict';\n${safeEmbed}${registry}var R=${R};\nmodule.exports=[${fns.join(',')}];\n${attach}`;
793
800
  }
794
801
 
795
802
  // Compact bundle: deduplicated code. Shared template functions + per-schema params.
@@ -898,8 +905,8 @@ function bundleCompact(Validator, schemas, opts) {
898
905
  // Generate compact bundle
899
906
  const isEsm = format === 'esm';
900
907
  let out = isEsm
901
- ? '// Auto-generated by ata-validator — do not edit\n'
902
- : "'use strict';\n";
908
+ ? `// Auto-generated by ata-validator ${ATA_VERSION}, do not edit\n`
909
+ : `// Auto-generated by ata-validator ${ATA_VERSION}, do not edit\n'use strict';\n`;
903
910
  if (bundleUsesSafeRe) out += getSafeRegexEmbed() + '\n';
904
911
  if (sharedDecls.length) out += sharedDecls.join('\n') + '\n';
905
912
  const declKW = isEsm ? 'const' : 'var';