ata-validator 1.34.0 → 1.36.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.
@@ -72,6 +72,8 @@ function apMembershipCheck (ctx, names, v) {
72
72
  return `var _apk${i}=Object.keys(${v});for(var _api${i}=0;_api${i}<_apk${i}.length;_api${i}++)if(${id}[_apk${i}[_api${i}]]===undefined)return false`
73
73
  }
74
74
  }
75
+ // With nothing declared every key is additional; `if()` would not parse.
76
+ if (names.length === 0) return `for(var _k in ${v})return false`
75
77
  return `for(var _k in ${v})if(${names.map((k) => `_k!==${JSON.stringify(k)}`).join('&&')})return false`
76
78
  }
77
79
 
@@ -304,17 +306,13 @@ function compileToJS(schema, defs, schemaMap) {
304
306
  if (schema.$dynamicRef) {
305
307
  const ref = schema.$dynamicRef
306
308
  const anchorName = ref.startsWith('#') ? ref : '#' + ref
307
- if (rootDefs && rootDefs[anchorName]) {
308
- const entry = rootDefs[anchorName]
309
- checks.push((d) => { const fn = entry.fn; return fn ? fn(d) : true })
310
- } else {
311
- // JSON pointer: "#/$defs/foo" or "#/definitions/foo"
312
- const m = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/)
313
- if (m && rootDefs && rootDefs[m[1]]) {
314
- const entry = rootDefs[m[1]]
315
- checks.push((d) => { const fn = entry.fn; return fn ? fn(d) : true })
316
- }
317
- }
309
+ // Resolved at compile time; one that resolves to nothing, or to a
310
+ // definition that cannot compile, declines instead of checking nothing.
311
+ const m = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/)
312
+ const entry = rootDefs && (rootDefs[anchorName] || (m && rootDefs[m[1]]))
313
+ const fn = entry ? entry.fn : null
314
+ if (!fn) return null
315
+ checks.push(fn)
318
316
  }
319
317
 
320
318
  // type
@@ -634,6 +632,8 @@ function compileToJS(schema, defs, schemaMap) {
634
632
  })
635
633
  }
636
634
 
635
+ // A definition reached through a cycle and found uncompilable only later.
636
+ if (!defs && rootDefs && rootDefs[DEF_FAILED]) return null
637
637
  if (checks.length === 0) return () => true
638
638
  if (checks.length === 1) return checks[0]
639
639
 
@@ -646,86 +646,48 @@ function compileToJS(schema, defs, schemaMap) {
646
646
  }
647
647
  }
648
648
 
649
+ // A definition compiled on first use. A reference into it made while it is
650
+ // still compiling (a cycle) gets a function that calls the finished one when
651
+ // it runs. One that fails to compile yields null, which the reference turns
652
+ // into a decline, and marks the set so the root declines too: a cycle may have
653
+ // reached it before the failure was known. It used to return an always-true
654
+ // function for both, so a definition this path could not compile, such as one
655
+ // containing `$ref: '#'`, accepted every value that reached it.
656
+ const DEF_FAILED = Symbol('ata.defFailed')
657
+ function lazyDefEntry (def, defs) {
658
+ let state = 0
659
+ let compiled = null
660
+ return {
661
+ get fn () {
662
+ if (state === 0) {
663
+ state = 1
664
+ compiled = compileToJS(def, defs)
665
+ state = 2
666
+ if (!compiled) defs[DEF_FAILED] = true
667
+ }
668
+ if (state === 1) return (d) => (compiled ? compiled(d) : false)
669
+ return compiled
670
+ },
671
+ raw: def,
672
+ }
673
+ }
674
+
649
675
  function collectDefs(schema) {
650
676
  const defs = {}
651
677
  const raw = schema.$defs || schema.definitions
652
678
  if (raw && typeof raw === 'object') {
653
679
  for (const [name, def] of Object.entries(raw)) {
654
- // Lazy compile with circular guard — return true (permissive) on cycle
655
- let cached = undefined
656
- defs[name] = {
657
- get fn() {
658
- if (cached === undefined) {
659
- cached = null // sentinel: compilation in progress
660
- cached = compileToJS(def, defs)
661
- }
662
- // cached===null means circular ref or compile failure — be permissive
663
- return cached || (() => true)
664
- },
665
- raw: def
666
- }
680
+ defs[name] = lazyDefEntry(def, defs)
667
681
  // Register anchors
668
682
  if (def && typeof def === 'object') {
669
- if (def.$anchor) {
670
- const anchorDef = def
671
- let anchorCached = undefined
672
- defs['#' + def.$anchor] = {
673
- get fn() {
674
- if (anchorCached === undefined) {
675
- anchorCached = null
676
- anchorCached = compileToJS(anchorDef, defs)
677
- }
678
- return anchorCached || (() => true)
679
- },
680
- raw: anchorDef
681
- }
682
- }
683
- if (def.$dynamicAnchor) {
684
- const daDef = def
685
- let daCached = undefined
686
- defs['#' + def.$dynamicAnchor] = {
687
- get fn() {
688
- if (daCached === undefined) {
689
- daCached = null
690
- daCached = compileToJS(daDef, defs)
691
- }
692
- return daCached || (() => true)
693
- },
694
- raw: daDef
695
- }
696
- }
683
+ if (def.$anchor) defs['#' + def.$anchor] = lazyDefEntry(def, defs)
684
+ if (def.$dynamicAnchor) defs['#' + def.$dynamicAnchor] = lazyDefEntry(def, defs)
697
685
  }
698
686
  }
699
687
  }
700
688
  // Register root-level $anchor/$dynamicAnchor (self-referencing schemas)
701
- if (schema.$anchor && !defs['#' + schema.$anchor]) {
702
- const rootAnchorSchema = schema
703
- let rootACached = undefined
704
- defs['#' + schema.$anchor] = {
705
- get fn() {
706
- if (rootACached === undefined) {
707
- rootACached = null
708
- rootACached = compileToJS(rootAnchorSchema, defs)
709
- }
710
- return rootACached || (() => true)
711
- },
712
- raw: rootAnchorSchema
713
- }
714
- }
715
- if (schema.$dynamicAnchor && !defs['#' + schema.$dynamicAnchor]) {
716
- const rootDASchema = schema
717
- let rootDACached = undefined
718
- defs['#' + schema.$dynamicAnchor] = {
719
- get fn() {
720
- if (rootDACached === undefined) {
721
- rootDACached = null
722
- rootDACached = compileToJS(rootDASchema, defs)
723
- }
724
- return rootDACached || (() => true)
725
- },
726
- raw: rootDASchema
727
- }
728
- }
689
+ if (schema.$anchor && !defs['#' + schema.$anchor]) defs['#' + schema.$anchor] = lazyDefEntry(schema, defs)
690
+ if (schema.$dynamicAnchor && !defs['#' + schema.$dynamicAnchor]) defs['#' + schema.$dynamicAnchor] = lazyDefEntry(schema, defs)
729
691
  return defs
730
692
  }
731
693
 
@@ -789,12 +751,12 @@ function resolveRef(ref, defs, schemaMap) {
789
751
  if (m) {
790
752
  const name = m[1]
791
753
  const entry = defs[name]
792
- if (entry) return (d) => { const fn = entry.fn; return fn ? fn(d) : true }
754
+ if (entry) return entry.fn || null
793
755
  }
794
756
  // Anchor ref: "#foo"
795
757
  if (ref.startsWith('#') && !ref.startsWith('#/')) {
796
758
  const entry = defs[ref]
797
- if (entry) return (d) => { const fn = entry.fn; return fn ? fn(d) : true }
759
+ if (entry) return entry.fn || null
798
760
  }
799
761
  }
800
762
  // A referenced schema that cannot itself be compiled must propagate the bail.
@@ -865,7 +827,7 @@ function _canonical(x) {
865
827
  return '{' + Object.keys(x).sort().map((k) => JSON.stringify(k) + ':' + _canonical(x[k])).join(',') + '}'
866
828
  }
867
829
 
868
- const _formats = require('./formats')
830
+ const _formats = { ...require('./formats'), ...require('./formats-source') }
869
831
  // The closure path's formats. It carried its own idea of an email and knew
870
832
  // five formats in all, so a schema that reached it was told "valid" for
871
833
  // anything the other engines turned away; the first call on a fresh validator
@@ -953,9 +915,16 @@ function protoIs (ctx, v) {
953
915
  useOwnHelper(ctx, '_PN')
954
916
  return `((_PN?${v}.__proto__:_gp(${v}))===_OP)`
955
917
  }
918
+ // The flag withPlain declared for `v`, if any, recorded as used when read so
919
+ // withPlain can drop an unused declaration without searching the code for it.
920
+ function plainFlag (ctx, v) {
921
+ const pk = ctx.plainOf ? ctx.plainOf.get(v) : undefined
922
+ if (pk !== undefined) (ctx.plainUsed || (ctx.plainUsed = new Set())).add(pk)
923
+ return pk
924
+ }
956
925
  function ownKeyExpr (ctx, v, key) {
957
926
  const k = JSON.stringify(key)
958
- const pk = ctx.plainOf ? ctx.plainOf.get(v) : undefined
927
+ const pk = plainFlag(ctx, v)
959
928
  useOwnHelper(ctx, PROTO_NAMES.has(key) || pk !== undefined ? '_h' : '_ok')
960
929
  if (PROTO_NAMES.has(key)) return `_h(${v},${k})`
961
930
  return pk !== undefined ? `(${pk}?${k} in ${v}:_h(${v},${k}))` : `(${k} in ${v}&&_ok(${v},${k}))`
@@ -964,7 +933,7 @@ function ownKeyExpr (ctx, v, key) {
964
933
  // it came from `v` itself. `in` is known to hold, so only the second half runs.
965
934
  function ownGuard (ctx, v, key) {
966
935
  const k = JSON.stringify(key)
967
- const pk = ctx.plainOf ? ctx.plainOf.get(v) : undefined
936
+ const pk = plainFlag(ctx, v)
968
937
  useOwnHelper(ctx, PROTO_NAMES.has(key) || pk !== undefined ? '_h' : '_ok')
969
938
  if (PROTO_NAMES.has(key)) return `_h(${v},${k})`
970
939
  return pk !== undefined ? `(${pk}||_h(${v},${k}))` : `_ok(${v},${k})`
@@ -993,9 +962,10 @@ function withPlain (schema, v, lines, ctx, run) {
993
962
  else ctx.plainOf.set(v, prev)
994
963
  // A node whose keys are all typed never reads the flag; declaring it
995
964
  // anyway cost a strict-object check about five percent. Unused, it goes.
996
- const re = new RegExp('\\b' + name + '\\b')
997
- let used = false
998
- for (let i = at + 1; i < lines.length && !used; i++) if (re.test(lines[i])) used = true
965
+ // Readers record the flag through plainFlag(); searching the lines this
966
+ // node emitted for its name instead made every ancestor scan its whole
967
+ // subtree again, 2.2 of the 5 seconds a 7.6 MB schema took to compile.
968
+ const used = ctx.plainUsed !== undefined && ctx.plainUsed.has(name)
999
969
  if (!used && lines[at] === decl) lines.splice(at, 1)
1000
970
  }
1001
971
  }
@@ -1157,6 +1127,21 @@ function hasNestedIdScope(node, isRoot = true) {
1157
1127
  // only safe way out.
1158
1128
  const DECLINE = Symbol('ata.decline')
1159
1129
 
1130
+ // How much source the verdict generator may hoist before it hands the schema to
1131
+ // the interpreted engine. Across the 977 SchemaStore schemas the median emits
1132
+ // 11 KB and the largest ordinary one, SARIF, 3.4 MB; Kestra's emitted 75 MB and
1133
+ // took 7.3 seconds and 1.6 GB before its first answer, which the interpreted
1134
+ // engine gives in about 0.4 seconds.
1135
+ const CODE_BUDGET = 16 * 1024 * 1024
1136
+ let codeBudget = CODE_BUDGET
1137
+ function hoist (ctx, code) {
1138
+ ctx.preamble.push(code)
1139
+ ctx.preambleChars = (ctx.preambleChars || 0) + code.length
1140
+ if (ctx.preambleChars > codeBudget) throw DECLINE
1141
+ }
1142
+ // For tests: a budget small enough to reach with a schema that fits in a test.
1143
+ function _setCodeBudget (n) { codeBudget = n === undefined ? CODE_BUDGET : n }
1144
+
1160
1145
  // The error and combined generators compile the subschemas of not, if, contains
1161
1146
  // and their anyOf/oneOf closures with the boolean generator. That generator
1162
1147
  // follows a reference back to the root with `_validate` and a reference cycle
@@ -1243,6 +1228,15 @@ function cyclicDefNames (defs) {
1243
1228
  return out
1244
1229
  }
1245
1230
 
1231
+ // Keys beside a $ref that change nothing about validation: schema organization,
1232
+ // the annotation vocabulary, and the annotation keys editors read.
1233
+ const REF_NEUTRAL_SIBLINGS = new Set([
1234
+ '$ref', '$defs', 'definitions', '$schema', '$id', '$dynamicAnchor', '$anchor', '$comment',
1235
+ 'title', 'description', 'default', 'examples', 'deprecated', 'readOnly', 'writeOnly',
1236
+ 'markdownDescription', 'deprecationMessage', 'markdownDeprecationMessage', 'enumDescriptions',
1237
+ 'markdownEnumDescriptions', 'defaultSnippets', 'doNotSuggest', 'suggestSortText',
1238
+ ])
1239
+
1246
1240
  function codegenSafe(schema, schemaMap) {
1247
1241
  if (typeof schema === 'boolean') return true
1248
1242
  if (typeof schema !== 'object' || schema === null) return true
@@ -1258,7 +1252,10 @@ function codegenSafe(schema, schemaMap) {
1258
1252
  // Boolean sub-schemas anywhere cause bail — codegen doesn't handle schema=false correctly
1259
1253
  // items: false and items: true are constants; every generator handles a
1260
1254
  // boolean child at its head, so neither falls through as "nothing".
1261
- if (schema.additionalProperties === true) return true // permissive — fine
1255
+ // `additionalProperties: true` needs no check of its own, and it says nothing
1256
+ // about the rest of the node: returning early here waved the whole subtree
1257
+ // through, so a nested construct the generator cannot express was dropped
1258
+ // instead of declined, from 1.0.0 to 1.35.0.
1262
1259
  if (schema.properties) {
1263
1260
  for (const v of Object.values(schema.properties)) {
1264
1261
  if (typeof v === 'boolean') continue // constants; every generator handles them at its head
@@ -1316,10 +1313,15 @@ function codegenSafe(schema, schemaMap) {
1316
1313
  if (!targetSimple && schema.unevaluatedProperties === undefined && schema.unevaluatedItems === undefined) return false
1317
1314
  }
1318
1315
  // In Draft 2020-12, $ref with siblings is allowed. Only bail if no unevaluated* keyword
1319
- // (unevaluated schemas need $ref + siblings to work properly)
1320
- // Schema-organization keywords ($dynamicAnchor, $anchor) are not validation siblings
1321
- const SCHEMA_ORG_KEYS = new Set(['$ref', '$defs', 'definitions', '$schema', '$id', '$dynamicAnchor', '$anchor'])
1322
- const siblings = Object.keys(schema).filter(k => !SCHEMA_ORG_KEYS.has(k))
1316
+ // (unevaluated schemas need $ref + siblings to work properly): without one, the
1317
+ // generators emit the $ref and skip the rest of the node, so a sibling that
1318
+ // validates would be dropped. Schema-organization keywords and annotations
1319
+ // validate nothing and do not count. Annotations are listed rather than
1320
+ // inferred from what is not a validation keyword, so a keyword this list
1321
+ // does not know still declines instead of being skipped. Measured on the
1322
+ // 977 SchemaStore schemas: a $ref with a description or title beside it sent
1323
+ // 223 of them to the interpreted engine.
1324
+ const siblings = Object.keys(schema).filter(k => !REF_NEUTRAL_SIBLINGS.has(k) && !k.startsWith('x-'))
1323
1325
  if (siblings.length > 0 && schema.unevaluatedProperties === undefined && schema.unevaluatedItems === undefined) return false
1324
1326
  }
1325
1327
 
@@ -1333,7 +1335,6 @@ function codegenSafe(schema, schemaMap) {
1333
1335
  if (typeof schema.additionalProperties === 'object' && schema.additionalProperties !== null) {
1334
1336
  if (!codegenSafe(schema.additionalProperties, schemaMap)) return false
1335
1337
  }
1336
- if (schema.additionalProperties === false && !schema.properties) return false
1337
1338
 
1338
1339
  // propertyNames: false — codegen doesn't handle this
1339
1340
  // propertyNames: false is emitted as a per-key rejection by every generator.
@@ -1386,6 +1387,26 @@ function codegenSafe(schema, schemaMap) {
1386
1387
  }
1387
1388
  }
1388
1389
 
1390
+ // The same checks compileToJSCodegen makes on the root, at every depth. They
1391
+ // were made on the root only, so a nested patternProperties with a Unicode
1392
+ // property escape, or a nested propertyNames the generators cannot express,
1393
+ // compiled to code that ignored it.
1394
+ if (schema.patternProperties) {
1395
+ for (const [pat, sub] of Object.entries(schema.patternProperties)) {
1396
+ if (/\\[pP]\{/.test(pat)) return false
1397
+ if (typeof sub === 'object' && sub !== null && !codegenSafe(sub, schemaMap)) return false
1398
+ }
1399
+ }
1400
+ if (schema.dependentSchemas) {
1401
+ for (const sub of Object.values(schema.dependentSchemas)) {
1402
+ if (typeof sub === 'object' && sub !== null && !codegenSafe(sub, schemaMap)) return false
1403
+ }
1404
+ }
1405
+ if (typeof schema.propertyNames === 'object' && schema.propertyNames !== null) {
1406
+ const supported = ['maxLength', 'minLength', 'pattern', 'const', 'enum']
1407
+ if (Object.keys(schema.propertyNames).some(k => k !== '$schema' && !supported.includes(k))) return false
1408
+ }
1409
+
1389
1410
  // Recurse into sub-schemas — bail on boolean schemas in any position
1390
1411
  // A boolean subschema is a constant that every generator emits at its
1391
1412
  // head with the child's own path. The emitters below test for presence,
@@ -1749,7 +1770,12 @@ function compileToJSCodegen(schema, schemaMap, userFormats, opts) {
1749
1770
 
1750
1771
  const ctx = { varCounter: 0, helpers: [], helperCode: [], preamble: [], shared: [], closureVars: ['_cpLen'], closureVals: [_cpLen], rootDefs, refStack: new Set(), schemaMap: schemaMap || null, anchors, rootSchema: schema, userFormats: userFormats || null, removeNodes }
1751
1772
  const lines = []
1752
- genCode(schema, 'd', lines, ctx)
1773
+ try {
1774
+ genCode(schema, 'd', lines, ctx)
1775
+ } catch (e) {
1776
+ if (e === DECLINE) return null
1777
+ throw e
1778
+ }
1753
1779
 
1754
1780
  // Append deferred checks (additionalProperties, unevaluatedProperties) at the end
1755
1781
  if (ctx.deferredChecks) {
@@ -2224,7 +2250,7 @@ function genCodeNode(schema, v, lines, ctx, knownType) {
2224
2250
  // When withPlain already holds the answer for this object in a flag,
2225
2251
  // read the flag: the prototype test is an accessor call, and making it
2226
2252
  // twice per object was a tenth of a plain order's verdict.
2227
- const pk = ctx.plainOf ? ctx.plainOf.get(v) : undefined
2253
+ const pk = plainFlag(ctx, v)
2228
2254
  lines.push(`if(!${pk || protoIs(ctx, v)}&&!_hall(${v},${JSON.stringify(typed)}))return false`)
2229
2255
  }
2230
2256
  } else if (schema.required && schema.required.length > 0) {
@@ -2375,14 +2401,17 @@ function genCodeNode(schema, v, lines, ctx, knownType) {
2375
2401
  loop = `{var ${n}=0;for(var ${kv} in ${v})${n}++;if(${n}!==${declared.length}){${loop}}}`
2376
2402
  }
2377
2403
  _deferOrInline(ctx, lines, v, isObj ? loop : `if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v})){${loop}}`)
2378
- } else if (schema.additionalProperties === false && schema.properties && !schema.patternProperties) {
2379
- const propCount = Object.keys(schema.properties).length
2404
+ } else if (schema.additionalProperties === false && !schema.patternProperties) {
2405
+ // No `properties` means nothing is declared, so every key is additional.
2406
+ // The generators used to emit nothing for that shape, and the safety gate
2407
+ // declined it everywhere; at any depth it now compiles.
2408
+ const propCount = Object.keys(schema.properties || {}).length
2380
2409
  const allRequired = schema.required && schema.required.length === propCount
2381
2410
  const inner = allRequired
2382
2411
  ? (propCount <= 15
2383
2412
  ? `var _n=0;for(var _k in ${v})_n++;if(_n!==${propCount})return false`
2384
2413
  : `if(Object.keys(${v}).length!==${propCount})return false`)
2385
- : apMembershipCheck(ctx, Object.keys(schema.properties), v)
2414
+ : apMembershipCheck(ctx, Object.keys(schema.properties || {}), v)
2386
2415
  _deferOrInline(ctx, lines, v, isObj ? inner : `if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v})){${inner}}`)
2387
2416
  }
2388
2417
 
@@ -2650,18 +2679,33 @@ function genContainsNode(schema, v, lines, ctx, knownType, isArr) {
2650
2679
  function genAnyOfNode(schema, v, lines, ctx, knownType) {
2651
2680
  const fi = ctx.varCounter++
2652
2681
  const branchBodies = []
2682
+ const reused = []
2653
2683
  let canHoist = !!ctx.preamble
2684
+ // Branch reuse as in genOneOfNode; anyOf branches take `_av`, so they keep
2685
+ // their own map.
2686
+ if (canHoist && !ctx.hoistedAny) ctx.hoistedAny = new Map()
2654
2687
  for (let i = 0; i < schema.anyOf.length; i++) {
2688
+ const sub = schema.anyOf[i]
2689
+ const key = canHoist ? branchKey(sub, ctx) : undefined
2690
+ const known = key !== undefined ? ctx.hoistedAny.get(key) : undefined
2691
+ if (known !== undefined) {
2692
+ reused[i] = known.name
2693
+ branchBodies.push(known.body)
2694
+ continue
2695
+ }
2655
2696
  const subLines = []
2656
- genCode(schema.anyOf[i], '_av', subLines, ctx)
2697
+ genCode(sub, '_av', subLines, ctx)
2657
2698
  const body = subLines.length === 0 ? 'return true' : `${subLines.join(';')};return true`
2658
2699
  if (/\b_validate\b/.test(body)) canHoist = false
2659
2700
  branchBodies.push(body)
2660
2701
  }
2661
2702
  if (canHoist) {
2662
2703
  const names = branchBodies.map((body, i) => {
2704
+ if (reused[i] !== undefined) return reused[i]
2663
2705
  const name = `_af${fi}_b${i}`
2664
- ctx.preamble.push(`function ${name}(_av){${body}}`)
2706
+ hoist(ctx, `function ${name}(_av){${body}}`)
2707
+ const key = branchKey(schema.anyOf[i], ctx)
2708
+ if (key !== undefined) ctx.hoistedAny.set(key, { name, body })
2665
2709
  return name
2666
2710
  })
2667
2711
  const checks = names.map(n => `${n}(${v})`).join('||')
@@ -2672,13 +2716,46 @@ function genAnyOfNode(schema, v, lines, ctx, knownType) {
2672
2716
  }
2673
2717
  }
2674
2718
 
2719
+ // What identifies a oneOf branch for reuse: the schema object, or for a branch
2720
+ // that is only a local $ref beside annotations, the reference itself, since
2721
+ // `{ $ref: '#/definitions/X' }` is a new object at every place it is written.
2722
+ // A reference means the same thing everywhere only when no nested $id opens
2723
+ // another base URI, so that is checked once per compilation.
2724
+ const SCOPE_KEYS = new Set(['$id', '$defs', 'definitions', '$schema', '$anchor', '$dynamicAnchor'])
2725
+ function branchKey (sub, ctx) {
2726
+ if (typeof sub !== 'object' || sub === null) return undefined
2727
+ if (typeof sub.$ref === 'string' && sub.$ref.startsWith('#') &&
2728
+ Object.keys(sub).every(k => (REF_NEUTRAL_SIBLINGS.has(k) && !SCOPE_KEYS.has(k)) || k.startsWith('x-'))) {
2729
+ if (ctx.refKeysSafe === undefined) ctx.refKeysSafe = !hasNestedIdScope(ctx.rootSchema)
2730
+ if (ctx.refKeysSafe) return 'ref:' + sub.$ref
2731
+ }
2732
+ return sub
2733
+ }
2734
+
2675
2735
  function genOneOfNode(schema, v, lines, ctx, knownType) {
2676
2736
  const fi = ctx.varCounter++
2677
2737
  const branchBodies = []
2738
+ const reused = []
2678
2739
  let canHoist = !!ctx.preamble
2740
+ // A branch schema object already emitted as a hoisted function is called
2741
+ // again rather than generated again. The same oneOf reached through many
2742
+ // $refs was expanded at every one: SchemaStore's Kestra schema, a oneOf of
2743
+ // several hundred task types referenced from each task list, produced
2744
+ // 86919 branch functions and 197 MB of source, and took seconds to build.
2745
+ // A hoisted branch takes its input as a parameter and reads only
2746
+ // module-level names, so the same schema object gives the same function.
2747
+ if (canHoist && !ctx.hoistedBranch) ctx.hoistedBranch = new Map()
2679
2748
  for (let i = 0; i < schema.oneOf.length; i++) {
2749
+ const sub = schema.oneOf[i]
2750
+ const key = canHoist ? branchKey(sub, ctx) : undefined
2751
+ const known = key !== undefined ? ctx.hoistedBranch.get(key) : undefined
2752
+ if (known !== undefined) {
2753
+ reused[i] = known.name
2754
+ branchBodies.push(known.body)
2755
+ continue
2756
+ }
2680
2757
  const subLines = []
2681
- genCode(schema.oneOf[i], '_ov', subLines, ctx)
2758
+ genCode(sub, '_ov', subLines, ctx)
2682
2759
  const body = subLines.length === 0 ? 'return true' : `${subLines.join(';')};return true`
2683
2760
  // _validate is the recursive entry — hoisting branches above it breaks scope.
2684
2761
  if (/\b_validate\b/.test(body)) canHoist = false
@@ -2686,8 +2763,11 @@ function genOneOfNode(schema, v, lines, ctx, knownType) {
2686
2763
  }
2687
2764
  if (canHoist) {
2688
2765
  const names = branchBodies.map((body, i) => {
2766
+ if (reused[i] !== undefined) return reused[i]
2689
2767
  const name = `_of${fi}_b${i}`
2690
- ctx.preamble.push(`function ${name}(_ov){${body}}`)
2768
+ hoist(ctx, `function ${name}(_ov){${body}}`)
2769
+ const key = branchKey(schema.oneOf[i], ctx)
2770
+ if (key !== undefined) ctx.hoistedBranch.set(key, { name, body })
2691
2771
  return name
2692
2772
  })
2693
2773
  const calls = names.map(n => `if(${n}(${v})){_oc${fi}++;if(_oc${fi}>1)return false}`).join(';')
@@ -2769,11 +2849,17 @@ function genRefNode(schema, v, lines, ctx, knownType, hasSiblings) {
2769
2849
  genCode(ctx.rootDefs[defName], 'd', bodyLines, ctx)
2770
2850
  if (ctx.deferredChecks) for (const dc of ctx.deferredChecks) bodyLines.push(dc)
2771
2851
  ctx.deferredChecks = outerDeferred
2852
+ // The def is hoisted above the entry function, where the recursive
2853
+ // entry `_validate` is not in scope; a body that calls it (a `$ref: '#'`
2854
+ // inside the def) threw a ReferenceError at the first document that
2855
+ // reached it. SchemaStore's jsone schema is such a case. Decline, as
2856
+ // oneOf and anyOf do not hoist such a branch.
2857
+ if (bodyLines.some((l) => /\b_validate\b/.test(l))) throw DECLINE
2772
2858
  // Entered through the cycle guard: see emitGuardState. In the fast
2773
2859
  // pass the wrapper only counts depth; the set is used by the second,
2774
2860
  // guarded pass that runs when data turns out to point back at
2775
2861
  // itself.
2776
- ctx.preamble.push(
2862
+ hoist(ctx,
2777
2863
  `const ${fnName}_s=new Set()\n function ${fnName}(d){\n ` +
2778
2864
  `if(_sg){if(typeof d!=='object'||d===null)return ${fnName}_b(d);if(${fnName}_s.has(d))return true;${fnName}_s.add(d);try{return ${fnName}_b(d)}finally{${fnName}_s.delete(d)}}\n ` +
2779
2865
  `if(++_sd>${CYCLE_DEPTH})throw _CYC\n const _r=${fnName}_b(d)\n _sd--\n return _r\n }\n ` +
@@ -2864,7 +2950,7 @@ function genPatternPropertiesNode(schema, v, lines, ctx, knownType, isObj) {
2864
2950
  const guard = isObj ? '' : `if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v}))`
2865
2951
 
2866
2952
  const apSchema = typeof schema.additionalProperties === 'object' && schema.additionalProperties !== null ? schema.additionalProperties : null
2867
- if ((schema.additionalProperties === false && schema.properties) || apSchema) {
2953
+ if (schema.additionalProperties === false || apSchema) {
2868
2954
  // Unified loop: properties + patterns + propertyNames + additionalProperties.
2869
2955
  // A key that is neither declared nor matched by any pattern is
2870
2956
  // additional: rejected outright, or run through the additionalProperties
@@ -4231,7 +4317,7 @@ function genCodeENode(schema, v, pathExpr, lines, ctx, schemaPrefix) {
4231
4317
  const inlineCheck = compilePatternInline(schema.pattern, v)
4232
4318
  if (inlineCheck) {
4233
4319
  const c = isStr ? `!(${inlineCheck})` : `typeof ${v}==='string'&&!(${inlineCheck})`
4234
- lines.push(`if(${c}){${fail('pattern', 'pattern', `{pattern:${JSON.stringify(schema.pattern)}}`, `'must match pattern "${schema.pattern}"'`)}}`)
4320
+ lines.push(`if(${c}){${fail('pattern', 'pattern', `{pattern:${JSON.stringify(schema.pattern)}}`, JSON.stringify(`must match pattern "${schema.pattern}"`))}}`)
4235
4321
  } else {
4236
4322
  const pattern = JSON.stringify(schema.pattern);
4237
4323
  if (!ctx.regExpMap.has(pattern)) {
@@ -4246,7 +4332,7 @@ function genCodeENode(schema, v, pathExpr, lines, ctx, schemaPrefix) {
4246
4332
  }
4247
4333
  const ri = ctx.regExpMap.get(pattern);
4248
4334
  const c = isStr ? `!_re${ri}.test(${v})` : `typeof ${v}==='string'&&!_re${ri}.test(${v})`
4249
- lines.push(`if(${c}){${fail('pattern', 'pattern', `{pattern:${JSON.stringify(schema.pattern)}}`, `'must match pattern "${schema.pattern}"'`)}}`)
4335
+ lines.push(`if(${c}){${fail('pattern', 'pattern', `{pattern:${JSON.stringify(schema.pattern)}}`, JSON.stringify(`must match pattern "${schema.pattern}"`))}}`)
4250
4336
  }
4251
4337
  }
4252
4338
  if (schema.format) {
@@ -4301,7 +4387,7 @@ function genCodeENode(schema, v, pathExpr, lines, ctx, schemaPrefix) {
4301
4387
 
4302
4388
  // additionalProperties: false. A key matched by any patternProperties
4303
4389
  // entry is not additional, so those patterns are consulted here as well.
4304
- if (schema.additionalProperties === false && (schema.properties || schema.patternProperties)) {
4390
+ if (schema.additionalProperties === false) {
4305
4391
  const allowed = Object.keys(schema.properties || {}).map(k => `${JSON.stringify(k)}`).join(',')
4306
4392
  const ci = ctx.varCounter++
4307
4393
  const apSp = `${schemaPrefix}/additionalProperties`
@@ -4519,7 +4605,7 @@ function genPatternPropertiesE(schema, v, pathExpr, lines, ctx, schemaPrefix) {
4519
4605
  const _peFn = emitPtrEsc(ctx)
4520
4606
  const p = pathExpr ? `${pathExpr}+'/'+${_peFn}(_k${ki})` : `'/'+${_peFn}(_k${ki})`
4521
4607
  // The real pointer, so source maps can locate the failing keyword.
4522
- genCodeE(sub, `${v}[_k${ki}]`, p, lines, ctx, schemaPrefix + '/patternProperties/' + pat.replace(/~/g, '~0').replace(/\//g, '~1'))
4608
+ genCodeE(sub, `${v}[_k${ki}]`, p, lines, ctx, schemaPrefix + '/patternProperties/' + ptrSeg(pat))
4523
4609
  lines.push(`}}}`)
4524
4610
  }
4525
4611
  }
@@ -4595,7 +4681,7 @@ function genPropertyNamesE(schema, v, pathExpr, lines, ctx, schemaPrefix, fail)
4595
4681
  }
4596
4682
  }
4597
4683
  const ri = ctx.regExpMap.get(pattern);
4598
- lines.push(`if(!_re${ri}.test(_k${ki})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, `'must match pattern "${pn.pattern}"'`)}}`)
4684
+ lines.push(`if(!_re${ri}.test(_k${ki})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, JSON.stringify(`must match pattern "${pn.pattern}"`))}}`)
4599
4685
  }
4600
4686
  if (pn.const !== undefined) {
4601
4687
  lines.push(`if(_k${ki}!==${JSON.stringify(pn.const)}){${fail('const', 'propertyNames/const', `{allowedValue:${JSON.stringify(pn.const)}}`, "'must be equal to constant'")}}`)
@@ -4625,9 +4711,9 @@ function genContainsE(schema, v, pathExpr, lines, ctx, schemaPrefix, fail) {
4625
4711
  const minC = schema.minContains !== undefined ? schema.minContains : 1
4626
4712
  const maxC = schema.maxContains
4627
4713
  lines.push(`if(Array.isArray(${v})){const _cf${ci}=function(_cv){${fnBody}};let _cc${ci}=0;for(let _ci${ci}=0;_ci${ci}<${v}.length;_ci${ci}++){if(_cf${ci}(${v}[_ci${ci}]))_cc${ci}++}`)
4628
- lines.push(`if(_cc${ci}<${minC}){${fail('contains', 'contains', `{minContains:${minC}}`, `'contains: need at least ${minC} match(es)'`)}}`)
4714
+ lines.push(`if(_cc${ci}<${minC}){${fail('contains', 'contains', `{minContains:${minC}}`, `'must contain at least ${minC} valid item(s)'`)}}`)
4629
4715
  if (maxC !== undefined) {
4630
- lines.push(`if(_cc${ci}>${maxC}){${fail('contains', 'contains', `{minContains:${minC},maxContains:${maxC}}`, `'contains: at most ${maxC} match(es)'`)}}`)
4716
+ lines.push(`if(_cc${ci}>${maxC}){${fail('contains', 'contains', `{minContains:${minC},maxContains:${maxC}}`, `'must NOT contain more than ${maxC} valid item(s)'`)}}`)
4631
4717
  }
4632
4718
  lines.push(`}`)
4633
4719
  }
@@ -5019,14 +5105,14 @@ function genCodeCNode(schema, v, pathExpr, lines, ctx, schemaPrefix) {
5019
5105
  const inlineCheck = compilePatternInline(schema.pattern, v)
5020
5106
  if (inlineCheck) {
5021
5107
  const c = isStr ? `!(${inlineCheck})` : `typeof ${v}==='string'&&!(${inlineCheck})`
5022
- lines.push(`if(${c}){${fail('pattern', 'pattern', `{pattern:${JSON.stringify(schema.pattern)}}`, `'must match pattern "${schema.pattern}"'`)}}`)
5108
+ lines.push(`if(${c}){${fail('pattern', 'pattern', `{pattern:${JSON.stringify(schema.pattern)}}`, JSON.stringify(`must match pattern "${schema.pattern}"`))}}`)
5023
5109
  } else {
5024
5110
  const ri = ctx.varCounter++
5025
5111
  const reVar = `_re${ri}`
5026
5112
  ctx.closureVars.push(reVar)
5027
5113
  ctx.closureVals.push(patternIsSafe(schema.pattern) ? compileSafe(schema.pattern) : new RegExp(schema.pattern))
5028
5114
  const c = isStr ? `!${reVar}.test(${v})` : `typeof ${v}==='string'&&!${reVar}.test(${v})`
5029
- lines.push(`if(${c}){${fail('pattern', 'pattern', `{pattern:${JSON.stringify(schema.pattern)}}`, `'must match pattern "${schema.pattern}"'`)}}`)
5115
+ lines.push(`if(${c}){${fail('pattern', 'pattern', `{pattern:${JSON.stringify(schema.pattern)}}`, JSON.stringify(`must match pattern "${schema.pattern}"`))}}`)
5030
5116
  }
5031
5117
  }
5032
5118
  if (schema.format) {
@@ -5062,8 +5148,8 @@ function genCodeCNode(schema, v, pathExpr, lines, ctx, schemaPrefix) {
5062
5148
 
5063
5149
  // additionalProperties — skip if patternProperties present (handled in unified loop below)
5064
5150
  // Small property sets: direct === chain (no Set allocation)
5065
- if (schema.additionalProperties === false && schema.properties && !schema.patternProperties) {
5066
- const propKeys = Object.keys(schema.properties)
5151
+ if (schema.additionalProperties === false && !schema.patternProperties) {
5152
+ const propKeys = Object.keys(schema.properties || {})
5067
5153
  const ci = ctx.varCounter++
5068
5154
  // The error has to name the offending property, so the keys have to be
5069
5155
  // materialised to report one. They do not have to be materialised to find
@@ -5077,8 +5163,10 @@ function genCodeCNode(schema, v, pathExpr, lines, ctx, schemaPrefix) {
5077
5163
  let body
5078
5164
  if (propKeys.length <= 8) {
5079
5165
  // Direct chain: no Set allocation for small schemas
5080
- const gate = propKeys.map(k => `_f${ci}!==${JSON.stringify(k)}`).join('&&')
5081
- const checks = propKeys.map(k => `_k${ci}[_i]!==${JSON.stringify(k)}`).join('&&')
5166
+ // With nothing declared every key is additional: an empty chain would
5167
+ // leave `if()`, so the test is `true`.
5168
+ const gate = propKeys.map(k => `_f${ci}!==${JSON.stringify(k)}`).join('&&') || 'true'
5169
+ const checks = propKeys.map(k => `_k${ci}[_i]!==${JSON.stringify(k)}`).join('&&') || 'true'
5082
5170
  body = `let _x${ci}=0;for(const _f${ci} in ${v}){if(${gate}){_x${ci}=1;break}}if(_x${ci}){${detail(checks)}}`
5083
5171
  } else {
5084
5172
  const allowed = propKeys.map(k => JSON.stringify(k)).join(',')
@@ -5297,9 +5385,9 @@ function genPatternPropertiesC(schema, v, pathExpr, lines, ctx, schemaPrefix, fa
5297
5385
  const guard = isObj ? '' : `if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v}))`
5298
5386
  const kVar = `_k${pi}`
5299
5387
 
5300
- if (schema.additionalProperties === false && schema.properties) {
5388
+ if (schema.additionalProperties === false) {
5301
5389
  handledPropertyNames = !!pn
5302
- const propKeys = Object.keys(schema.properties)
5390
+ const propKeys = Object.keys(schema.properties || {})
5303
5391
  // Inline key comparison for small property sets
5304
5392
  const keyCheck = propKeys.length <= 8
5305
5393
  ? propKeys.map(k => `${kVar}===${JSON.stringify(k)}`).join('||')
@@ -5318,12 +5406,12 @@ function genPatternPropertiesC(schema, v, pathExpr, lines, ctx, schemaPrefix, fa
5318
5406
  if (pn.pattern) {
5319
5407
  const fast = fastPrefixCheck(pn.pattern, kVar)
5320
5408
  if (fast) {
5321
- lines.push(`if(!(${fast})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, `'must match pattern "${pn.pattern}"'`)}}`)
5409
+ lines.push(`if(!(${fast})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, JSON.stringify(`must match pattern "${pn.pattern}"`))}}`)
5322
5410
  } else {
5323
5411
  const ri = ctx.varCounter++
5324
5412
  ctx.closureVars.push(`_re${ri}`)
5325
5413
  ctx.closureVals.push(safeReClosure(ctx, pn.pattern))
5326
- lines.push(`if(!_re${ri}.test(${kVar})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, `'must match pattern "${pn.pattern}"'`)}}`)
5414
+ lines.push(`if(!_re${ri}.test(${kVar})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, JSON.stringify(`must match pattern "${pn.pattern}"`))}}`)
5327
5415
  }
5328
5416
  }
5329
5417
  if (pn.const !== undefined) lines.push(`if(${kVar}!==${JSON.stringify(pn.const)}){${fail('const', 'propertyNames/const', `{allowedValue:${JSON.stringify(pn.const)}}`, "'must be equal to constant'")}}`)
@@ -5354,12 +5442,12 @@ function genPatternPropertiesC(schema, v, pathExpr, lines, ctx, schemaPrefix, fa
5354
5442
  if (pn.pattern) {
5355
5443
  const fast = fastPrefixCheck(pn.pattern, kVar)
5356
5444
  if (fast) {
5357
- lines.push(`if(!(${fast})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, `'must match pattern "${pn.pattern}"'`)}}`)
5445
+ lines.push(`if(!(${fast})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, JSON.stringify(`must match pattern "${pn.pattern}"`))}}`)
5358
5446
  } else {
5359
5447
  const ri = ctx.varCounter++
5360
5448
  ctx.closureVars.push(`_re${ri}`)
5361
5449
  ctx.closureVals.push(safeReClosure(ctx, pn.pattern))
5362
- lines.push(`if(!_re${ri}.test(${kVar})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, `'must match pattern "${pn.pattern}"'`)}}`)
5450
+ lines.push(`if(!_re${ri}.test(${kVar})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, JSON.stringify(`must match pattern "${pn.pattern}"`))}}`)
5363
5451
  }
5364
5452
  }
5365
5453
  if (pn.const !== undefined) lines.push(`if(${kVar}!==${JSON.stringify(pn.const)}){${fail('const', 'propertyNames/const', `{allowedValue:${JSON.stringify(pn.const)}}`, "'must be equal to constant'")}}`)
@@ -5408,7 +5496,7 @@ function genPropertyNamesC(schema, v, pathExpr, lines, ctx, schemaPrefix, fail)
5408
5496
  const ri = ctx.varCounter++
5409
5497
  ctx.closureVars.push(`_re${ri}`)
5410
5498
  ctx.closureVals.push(safeReClosure(ctx, pn.pattern))
5411
- lines.push(`if(!_re${ri}.test(_k${ki})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, `'must match pattern "${pn.pattern}"'`)}}`)
5499
+ lines.push(`if(!_re${ri}.test(_k${ki})){${fail('pattern', 'propertyNames/pattern', `{pattern:${JSON.stringify(pn.pattern)}}`, JSON.stringify(`must match pattern "${pn.pattern}"`))}}`)
5412
5500
  }
5413
5501
  if (pn.const !== undefined) {
5414
5502
  lines.push(`if(_k${ki}!==${JSON.stringify(pn.const)}){${fail('const', 'propertyNames/const', `{allowedValue:${JSON.stringify(pn.const)}}`, "'must be equal to constant'")}}`)
@@ -5439,8 +5527,8 @@ function genContainsC(schema, v, pathExpr, lines, ctx, schemaPrefix, fail) {
5439
5527
  const minC = schema.minContains !== undefined ? schema.minContains : 1
5440
5528
  const maxC = schema.maxContains
5441
5529
  lines.push(`if(Array.isArray(${v})){const _cf${ci}=function(_cv){${fnBody}};let _cc${ci}=0;for(let _ci${ci}=0;_ci${ci}<${v}.length;_ci${ci}++){if(_cf${ci}(${v}[_ci${ci}]))_cc${ci}++}`)
5442
- lines.push(`if(_cc${ci}<${minC}){${fail('contains', 'contains', `{minContains:${minC}}`, `'contains: need at least ${minC} match(es)'`)}}`)
5443
- if (maxC !== undefined) lines.push(`if(_cc${ci}>${maxC}){${fail('contains', 'contains', `{minContains:${minC},maxContains:${maxC}}`, `'contains: at most ${maxC} match(es)'`)}}`)
5530
+ lines.push(`if(_cc${ci}<${minC}){${fail('contains', 'contains', `{minContains:${minC}}`, `'must contain at least ${minC} valid item(s)'`)}}`)
5531
+ if (maxC !== undefined) lines.push(`if(_cc${ci}>${maxC}){${fail('contains', 'contains', `{minContains:${minC},maxContains:${maxC}}`, `'must NOT contain more than ${maxC} valid item(s)'`)}}`)
5444
5532
  lines.push(`}`)
5445
5533
  }
5446
5534
 
@@ -5609,4 +5697,4 @@ function _collectEval(schema, result, defs, schemaMap, refStack, isRoot) {
5609
5697
  // not → contributes nothing (spec: annotations from not are discarded)
5610
5698
  }
5611
5699
 
5612
- module.exports = { compileToJS, compileToJSCodegen, compileToJSCodegenWithErrors, compileToJSCombined, collectEvaluated, unevalContributions, AJV_MESSAGES }
5700
+ module.exports = { compileToJS, compileToJSCodegen, compileToJSCodegenWithErrors, compileToJSCombined, collectEvaluated, unevalContributions, AJV_MESSAGES, _setCodeBudget }