ata-validator 0.7.3 → 0.8.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.
@@ -39,7 +39,11 @@ function compileToJS(schema, defs, schemaMap) {
39
39
  if (typeof schema !== 'object' || schema === null) return null
40
40
 
41
41
  // Bail if schema has edge cases that JS fast path gets wrong
42
- if (!defs && !codegenSafe(schema, schemaMap)) return null
42
+ // Exception: $dynamicRef/$anchor are handled by the interpretive path even though codegen can't
43
+ if (!defs && !codegenSafe(schema, schemaMap)) {
44
+ const str = JSON.stringify(schema)
45
+ if (!str.includes('"$dynamicRef"') && !str.includes('"$dynamicAnchor"') && !str.includes('"$anchor"')) return null
46
+ }
43
47
 
44
48
  // Collect $defs early so sub-schemas can resolve $ref
45
49
  const rootDefs = defs || collectDefs(schema)
@@ -60,6 +64,23 @@ function compileToJS(schema, defs, schemaMap) {
60
64
  checks.push(refFn)
61
65
  }
62
66
 
67
+ // $dynamicRef — resolve via anchor defs or JSON pointer
68
+ if (schema.$dynamicRef) {
69
+ const ref = schema.$dynamicRef
70
+ const anchorName = ref.startsWith('#') ? ref : '#' + ref
71
+ if (rootDefs && rootDefs[anchorName]) {
72
+ const entry = rootDefs[anchorName]
73
+ checks.push((d) => { const fn = entry.fn; return fn ? fn(d) : true })
74
+ } else {
75
+ // JSON pointer: "#/$defs/foo" or "#/definitions/foo"
76
+ const m = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/)
77
+ if (m && rootDefs && rootDefs[m[1]]) {
78
+ const entry = rootDefs[m[1]]
79
+ checks.push((d) => { const fn = entry.fn; return fn ? fn(d) : true })
80
+ }
81
+ }
82
+ }
83
+
63
84
  // type
64
85
  if (schema.type) {
65
86
  const types = Array.isArray(schema.type) ? schema.type : [schema.type]
@@ -395,12 +416,75 @@ function collectDefs(schema) {
395
416
  },
396
417
  raw: def
397
418
  }
419
+ // Register anchors
420
+ if (def && typeof def === 'object') {
421
+ if (def.$anchor) {
422
+ const anchorDef = def
423
+ let anchorCached = undefined
424
+ defs['#' + def.$anchor] = {
425
+ get fn() {
426
+ if (anchorCached === undefined) {
427
+ anchorCached = null
428
+ anchorCached = compileToJS(anchorDef, defs)
429
+ }
430
+ return anchorCached || (() => true)
431
+ },
432
+ raw: anchorDef
433
+ }
434
+ }
435
+ if (def.$dynamicAnchor) {
436
+ const daDef = def
437
+ let daCached = undefined
438
+ defs['#' + def.$dynamicAnchor] = {
439
+ get fn() {
440
+ if (daCached === undefined) {
441
+ daCached = null
442
+ daCached = compileToJS(daDef, defs)
443
+ }
444
+ return daCached || (() => true)
445
+ },
446
+ raw: daDef
447
+ }
448
+ }
449
+ }
450
+ }
451
+ }
452
+ // Register root-level $anchor/$dynamicAnchor (self-referencing schemas)
453
+ if (schema.$anchor && !defs['#' + schema.$anchor]) {
454
+ const rootAnchorSchema = schema
455
+ let rootACached = undefined
456
+ defs['#' + schema.$anchor] = {
457
+ get fn() {
458
+ if (rootACached === undefined) {
459
+ rootACached = null
460
+ rootACached = compileToJS(rootAnchorSchema, defs)
461
+ }
462
+ return rootACached || (() => true)
463
+ },
464
+ raw: rootAnchorSchema
465
+ }
466
+ }
467
+ if (schema.$dynamicAnchor && !defs['#' + schema.$dynamicAnchor]) {
468
+ const rootDASchema = schema
469
+ let rootDACached = undefined
470
+ defs['#' + schema.$dynamicAnchor] = {
471
+ get fn() {
472
+ if (rootDACached === undefined) {
473
+ rootDACached = null
474
+ rootDACached = compileToJS(rootDASchema, defs)
475
+ }
476
+ return rootDACached || (() => true)
477
+ },
478
+ raw: rootDASchema
398
479
  }
399
480
  }
400
481
  return defs
401
482
  }
402
483
 
403
484
  function resolveRef(ref, defs, schemaMap) {
485
+ // Self-reference: "#" — treat as permissive to avoid infinite recursion
486
+ if (ref === '#') return () => true
487
+
404
488
  // 1. Local ref
405
489
  if (defs) {
406
490
  const m = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/)
@@ -409,13 +493,28 @@ function resolveRef(ref, defs, schemaMap) {
409
493
  const entry = defs[name]
410
494
  if (entry) return (d) => { const fn = entry.fn; return fn ? fn(d) : true }
411
495
  }
496
+ // Anchor ref: "#foo"
497
+ if (ref.startsWith('#') && !ref.startsWith('#/')) {
498
+ const entry = defs[ref]
499
+ if (entry) return (d) => { const fn = entry.fn; return fn ? fn(d) : true }
500
+ }
412
501
  }
413
- // 2. Cross-schema ref
502
+ // 2. Cross-schema ref (exact match)
414
503
  if (schemaMap && schemaMap.has(ref)) {
415
504
  const resolved = schemaMap.get(ref)
416
505
  const fn = compileToJS(resolved, null, schemaMap)
417
506
  return fn || (() => true)
418
507
  }
508
+ // 3. Cross-schema ref (relative URI resolution)
509
+ if (schemaMap && !ref.includes('://') && !ref.startsWith('#')) {
510
+ for (const [id] of schemaMap) {
511
+ if (id.endsWith('/' + ref)) {
512
+ const resolved = schemaMap.get(id)
513
+ const fn = compileToJS(resolved, null, schemaMap)
514
+ return fn || (() => true)
515
+ }
516
+ }
517
+ }
419
518
  return null
420
519
  }
421
520
 
@@ -444,7 +543,7 @@ const TYPE_CHECKS = {
444
543
 
445
544
  const FORMAT_CHECKS = {
446
545
  email: (s) => { const at = s.indexOf('@'); return at > 0 && at < s.length - 1 && s.indexOf('.', at) > at + 1 },
447
- date: (s) => s.length === 10 && /^\d{4}-\d{2}-\d{2}$/.test(s),
546
+ date: (s) => { if (s.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(s)) return false; const m = +s.slice(5, 7), d = +s.slice(8, 10); return m >= 1 && m <= 12 && d >= 1 && d <= 31 },
448
547
  uuid: (s) => s.length === 36 && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s),
449
548
  ipv4: (s) => { const p = s.split('.'); return p.length === 4 && p.every(n => { const v = +n; return v >= 0 && v <= 255 && String(v) === n }) },
450
549
  hostname: (s) => s.length > 0 && s.length <= 253 && /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(s),
@@ -454,12 +553,49 @@ const FORMAT_CHECKS = {
454
553
  const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'toString', 'valueOf',
455
554
  'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toLocaleString'])
456
555
 
556
+ // Check if all $dynamicRef in a target schema can be resolved via the calling schema's anchors.
557
+ function canResolveDynamicRefs(target, callingSchema, schemaMap) {
558
+ // Collect anchors from calling schema
559
+ const anchors = new Set()
560
+ if (callingSchema.$dynamicAnchor) anchors.add(callingSchema.$dynamicAnchor)
561
+ const defs = callingSchema.$defs || callingSchema.definitions
562
+ if (defs) {
563
+ for (const def of Object.values(defs)) {
564
+ if (def && typeof def === 'object' && def.$dynamicAnchor) anchors.add(def.$dynamicAnchor)
565
+ }
566
+ }
567
+ // Also collect from schemaMap
568
+ if (schemaMap) {
569
+ for (const ext of schemaMap.values()) {
570
+ if (ext && typeof ext === 'object' && ext.$dynamicAnchor) anchors.add(ext.$dynamicAnchor)
571
+ }
572
+ }
573
+ // Find all $dynamicRef in target
574
+ const refs = []
575
+ const findDynRefs = (s) => {
576
+ if (typeof s !== 'object' || s === null) return
577
+ if (s.$dynamicRef) {
578
+ const name = s.$dynamicRef.startsWith('#') ? s.$dynamicRef.slice(1) : s.$dynamicRef
579
+ refs.push(name)
580
+ }
581
+ for (const v of Object.values(s)) {
582
+ if (Array.isArray(v)) v.forEach(findDynRefs)
583
+ else if (typeof v === 'object' && v !== null) findDynRefs(v)
584
+ }
585
+ }
586
+ findDynRefs(target)
587
+ return refs.every(r => anchors.has(r))
588
+ }
589
+
457
590
  // Recursively check if a schema can be safely compiled to JS codegen.
458
591
  // Returns false if any sub-schema contains features codegen gets wrong.
459
592
  function codegenSafe(schema, schemaMap) {
460
593
  if (typeof schema === 'boolean') return true
461
594
  if (typeof schema !== 'object' || schema === null) return true
462
595
 
596
+ // Only bail on $dynamicRef if it can't be resolved at compile time
597
+ if (schema.$dynamicRef && !schema.$dynamicRef.startsWith('#')) return false
598
+
463
599
  // Boolean sub-schemas anywhere cause bail — codegen doesn't handle schema=false correctly
464
600
  if (schema.items === false) return false
465
601
  if (schema.items === true && !schema.unevaluatedItems) return false
@@ -490,12 +626,37 @@ function codegenSafe(schema, schemaMap) {
490
626
 
491
627
  // $ref — allow local refs (#/$defs/Name) and non-local refs if in schemaMap
492
628
  if (schema.$ref) {
629
+ // Self-reference "#" — treated as permissive (no-op) to avoid infinite recursion
630
+ if (schema.$ref === '#') return true
493
631
  const isLocal = /^#\/(?:\$defs|definitions)\/[^/]+$/.test(schema.$ref)
494
- const isResolvable = !isLocal && schemaMap && schemaMap.has(schema.$ref)
495
- if (!isLocal && !isResolvable) return false
632
+ let isResolvable = !isLocal && schemaMap && schemaMap.has(schema.$ref)
633
+ // Relative URI resolution: check if any schemaMap key ends with "/" + ref
634
+ let resolvedTarget = null
635
+ if (!isLocal && !isResolvable && schemaMap && !schema.$ref.includes('://') && !schema.$ref.startsWith('#')) {
636
+ for (const [id] of schemaMap) {
637
+ if (id.endsWith('/' + schema.$ref)) { isResolvable = true; resolvedTarget = schemaMap.get(id); break }
638
+ }
639
+ }
640
+ // Anchor-style ref: #name (not #/path, not bare #) — resolvable at compile time via anchors map
641
+ const isAnchorRef = !isLocal && !isResolvable && schema.$ref.length > 1 && schema.$ref.startsWith('#') && !schema.$ref.startsWith('#/')
642
+ if (!isLocal && !isResolvable && !isAnchorRef) return false
643
+ // If the resolved target contains $dynamicRef, allow codegen only when:
644
+ // 1. All $dynamicRefs can be resolved via the current schema's anchors
645
+ // 2. The resolved target itself is simple enough for codegen (no additionalProperties: false, etc.)
646
+ if (!resolvedTarget && isResolvable) resolvedTarget = schemaMap.get(schema.$ref)
647
+ if (resolvedTarget && JSON.stringify(resolvedTarget).includes('"$dynamicRef"')) {
648
+ const canResolve = canResolveDynamicRefs(resolvedTarget, schema, schemaMap)
649
+ // Also verify the resolved target doesn't have complex features that codegen can't inline
650
+ const targetSimple = canResolve && resolvedTarget.additionalProperties === undefined &&
651
+ !resolvedTarget.patternProperties && !resolvedTarget.dependentSchemas &&
652
+ !resolvedTarget.propertyNames
653
+ if (!targetSimple && schema.unevaluatedProperties === undefined && schema.unevaluatedItems === undefined) return false
654
+ }
496
655
  // In Draft 2020-12, $ref with siblings is allowed. Only bail if no unevaluated* keyword
497
656
  // (unevaluated schemas need $ref + siblings to work properly)
498
- const siblings = Object.keys(schema).filter(k => k !== '$ref' && k !== '$defs' && k !== 'definitions' && k !== '$schema' && k !== '$id')
657
+ // Schema-organization keywords ($dynamicAnchor, $anchor) are not validation siblings
658
+ const SCHEMA_ORG_KEYS = new Set(['$ref', '$defs', 'definitions', '$schema', '$id', '$dynamicAnchor', '$anchor'])
659
+ const siblings = Object.keys(schema).filter(k => !SCHEMA_ORG_KEYS.has(k))
499
660
  if (siblings.length > 0 && schema.unevaluatedProperties === undefined && schema.unevaluatedItems === undefined) return false
500
661
  }
501
662
 
@@ -590,7 +751,31 @@ function compileToJSCodegen(schema, schemaMap) {
590
751
  if (keys.some(k => !supported.includes(k))) return null
591
752
  }
592
753
 
593
- const ctx = { varCounter: 0, helpers: [], helperCode: [], closureVars: [], closureVals: [], rootDefs, refStack: new Set(), schemaMap: schemaMap || null }
754
+ // Build anchors map for $ref/#anchor and $dynamicRef resolution
755
+ const anchors = {}
756
+ // Root schema's own $dynamicAnchor / $anchor
757
+ if (schema.$dynamicAnchor) anchors['#' + schema.$dynamicAnchor] = schema
758
+ if (schema.$anchor) anchors['#' + schema.$anchor] = schema
759
+ // Anchors from $defs
760
+ if (rootDefs) {
761
+ for (const def of Object.values(rootDefs)) {
762
+ if (def && typeof def === 'object') {
763
+ if (def.$dynamicAnchor) anchors['#' + def.$dynamicAnchor] = def
764
+ if (def.$anchor) anchors['#' + def.$anchor] = def
765
+ }
766
+ }
767
+ }
768
+ // Anchors from external schemas in schemaMap
769
+ if (schemaMap) {
770
+ for (const ext of schemaMap.values()) {
771
+ if (ext && typeof ext === 'object') {
772
+ if (ext.$dynamicAnchor && !anchors['#' + ext.$dynamicAnchor]) anchors['#' + ext.$dynamicAnchor] = ext
773
+ if (ext.$anchor && !anchors['#' + ext.$anchor]) anchors['#' + ext.$anchor] = ext
774
+ }
775
+ }
776
+ }
777
+
778
+ const ctx = { varCounter: 0, helpers: [], helperCode: [], closureVars: [], closureVals: [], rootDefs, refStack: new Set(), schemaMap: schemaMap || null, anchors, rootSchema: schema }
594
779
  const lines = []
595
780
  genCode(schema, 'd', lines, ctx)
596
781
 
@@ -616,7 +801,16 @@ function compileToJSCodegen(schema, schemaMap) {
616
801
  }
617
802
  }
618
803
 
619
- const body = checkStr + '\n return true'
804
+ let body, hybridBody
805
+ if (ctx.usesRecursion) {
806
+ // Self-recursive: wrap in named function
807
+ body = `function _validate(d){\n ${checkStr}\n return true\n }\n return _validate(d)`
808
+ const hybridCheck = replaceTopLevel(checkStr + '\n return R')
809
+ hybridBody = `function _validate(d){\n ${hybridCheck}\n }\n return _validate(d)`
810
+ } else {
811
+ body = checkStr + '\n return true'
812
+ hybridBody = replaceTopLevel(checkStr + '\n return R')
813
+ }
620
814
 
621
815
  try {
622
816
  let boolFn
@@ -628,7 +822,6 @@ function compileToJSCodegen(schema, schemaMap) {
628
822
  }
629
823
 
630
824
  // Build hybrid: same body, return R instead of true, return E(d) instead of false.
631
- const hybridBody = replaceTopLevel(checkStr + '\n return R')
632
825
  try {
633
826
  const hybridFactory = new Function(...closureNames, 'R', 'E', `return function(d){${hybridBody}}`)
634
827
  boolFn._hybridFactory = (R, E) => hybridFactory(...closureValues, R, E)
@@ -691,8 +884,12 @@ function genCode(schema, v, lines, ctx, knownType) {
691
884
  // Only when THIS schema has unevaluated keywords directly (not via $ref target)
692
885
  const hasSiblings = schema.$ref && (schema.unevaluatedProperties !== undefined || schema.unevaluatedItems !== undefined)
693
886
  if (schema.$ref) {
887
+ // Self-reference "#" — no-op (permissive) to avoid infinite recursion
888
+ if (schema.$ref === '#') {
889
+ if (!hasSiblings) return
890
+ }
694
891
  // 1. Local ref
695
- const m = schema.$ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/)
892
+ const m = schema.$ref !== '#' && schema.$ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/)
696
893
  if (m && ctx.rootDefs && ctx.rootDefs[m[1]]) {
697
894
  if (ctx.refStack.has(schema.$ref)) { if (!hasSiblings) return }
698
895
  else {
@@ -701,13 +898,36 @@ function genCode(schema, v, lines, ctx, knownType) {
701
898
  ctx.refStack.delete(schema.$ref)
702
899
  if (!hasSiblings) return
703
900
  }
704
- } else if (ctx.schemaMap && ctx.schemaMap.has(schema.$ref)) {
705
- // 2. Cross-schema ref
706
- if (ctx.refStack.has(schema.$ref)) { if (!hasSiblings) return }
707
- else {
708
- ctx.refStack.add(schema.$ref)
709
- genCode(ctx.schemaMap.get(schema.$ref), v, lines, ctx, knownType)
710
- ctx.refStack.delete(schema.$ref)
901
+ } else if (schema.$ref !== '#' && !m && schema.$ref.startsWith('#') && !schema.$ref.startsWith('#/')) {
902
+ // Anchor ref: "#foo" — resolve via rootDefs or anchors map
903
+ const entry = ctx.rootDefs && ctx.rootDefs[schema.$ref]
904
+ const anchorTarget = entry && entry.raw ? entry.raw : (ctx.anchors && ctx.anchors[schema.$ref])
905
+ if (anchorTarget) {
906
+ if (ctx.refStack.has(schema.$ref)) { if (!hasSiblings) return }
907
+ else {
908
+ ctx.refStack.add(schema.$ref)
909
+ genCode(anchorTarget, v, lines, ctx, knownType)
910
+ ctx.refStack.delete(schema.$ref)
911
+ if (!hasSiblings) return
912
+ }
913
+ }
914
+ } else if (schema.$ref !== '#' && ctx.schemaMap) {
915
+ // 2. Cross-schema ref (exact match or relative URI)
916
+ let resolved = ctx.schemaMap.get(schema.$ref)
917
+ if (!resolved && !schema.$ref.includes('://') && !schema.$ref.startsWith('#')) {
918
+ for (const [id, s] of ctx.schemaMap) {
919
+ if (id.endsWith('/' + schema.$ref)) { resolved = s; break }
920
+ }
921
+ }
922
+ if (resolved) {
923
+ if (ctx.refStack.has(schema.$ref)) { if (!hasSiblings) return }
924
+ else {
925
+ ctx.refStack.add(schema.$ref)
926
+ genCode(resolved, v, lines, ctx, knownType)
927
+ ctx.refStack.delete(schema.$ref)
928
+ if (!hasSiblings) return
929
+ }
930
+ } else {
711
931
  if (!hasSiblings) return
712
932
  }
713
933
  } else {
@@ -715,6 +935,27 @@ function genCode(schema, v, lines, ctx, knownType) {
715
935
  }
716
936
  }
717
937
 
938
+ // $dynamicRef — resolve via anchors map
939
+ if (schema.$dynamicRef) {
940
+ const anchorKey = schema.$dynamicRef.startsWith('#') ? schema.$dynamicRef : '#' + schema.$dynamicRef
941
+ if (ctx.anchors && ctx.anchors[anchorKey]) {
942
+ const target = ctx.anchors[anchorKey]
943
+ if (target === ctx.rootSchema) {
944
+ // Self-recursive: generate _validate(v) call
945
+ ctx.usesRecursion = true
946
+ lines.push(`if(!_validate(${v}))return false`)
947
+ } else {
948
+ // Different schema: inline the target validation
949
+ const refKey = '$dynamicRef:' + anchorKey
950
+ if (!ctx.refStack.has(refKey)) {
951
+ ctx.refStack.add(refKey)
952
+ genCode(target, v, lines, ctx, knownType)
953
+ ctx.refStack.delete(refKey)
954
+ }
955
+ }
956
+ }
957
+ }
958
+
718
959
  // Determine the single known type after this schema's type check
719
960
  const types = schema.type ? (Array.isArray(schema.type) ? schema.type : [schema.type]) : null
720
961
  let effectiveType = knownType
@@ -1582,8 +1823,8 @@ const FORMAT_CODEGEN = {
1582
1823
  : `if(typeof ${v}==='string'){const _at=${v}.indexOf('@');if(_at<=0||_at>=${v}.length-1||${v}.indexOf('.',_at)<=_at+1)return false}`
1583
1824
  },
1584
1825
  date: (v, isStr) => isStr
1585
- ? `if(${v}.length!==10||!/^\\d{4}-\\d{2}-\\d{2}$/.test(${v}))return false`
1586
- : `if(typeof ${v}==='string'&&(${v}.length!==10||!/^\\d{4}-\\d{2}-\\d{2}$/.test(${v})))return false`,
1826
+ ? `{if(${v}.length!==10||!/^\\d{4}-\\d{2}-\\d{2}$/.test(${v}))return false;const _dm=+${v}.slice(5,7),_dd=+${v}.slice(8,10);if(_dm<1||_dm>12||_dd<1||_dd>31)return false}`
1827
+ : `if(typeof ${v}==='string'){if(${v}.length!==10||!/^\\d{4}-\\d{2}-\\d{2}$/.test(${v}))return false;const _dm=+${v}.slice(5,7),_dd=+${v}.slice(8,10);if(_dm<1||_dm>12||_dd<1||_dd>31)return false}`,
1587
1828
  uuid: (v, isStr) => isStr
1588
1829
  ? `if(${v}.length!==36||!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(${v}))return false`
1589
1830
  : `if(typeof ${v}==='string'&&(${v}.length!==36||!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(${v})))return false`,
@@ -1770,15 +2011,46 @@ function compileToJSCodegenWithErrors(schema, schemaMap) {
1770
2011
  if (keys.some(k => !supported.includes(k))) return null
1771
2012
  }
1772
2013
 
1773
- const ctx = { varCounter: 0, helperCode: [], rootDefs: schema.$defs || schema.definitions || null, refStack: new Set(), schemaMap: schemaMap || null }
2014
+ // Build anchors map for $ref/#anchor and $dynamicRef resolution
2015
+ const eRootDefs = schema.$defs || schema.definitions || null
2016
+ const eAnchors = {}
2017
+ if (schema.$dynamicAnchor) eAnchors['#' + schema.$dynamicAnchor] = schema
2018
+ if (schema.$anchor) eAnchors['#' + schema.$anchor] = schema
2019
+ if (eRootDefs) {
2020
+ for (const def of Object.values(eRootDefs)) {
2021
+ if (def && typeof def === 'object') {
2022
+ if (def.$dynamicAnchor) eAnchors['#' + def.$dynamicAnchor] = def
2023
+ if (def.$anchor) eAnchors['#' + def.$anchor] = def
2024
+ }
2025
+ }
2026
+ }
2027
+ if (schemaMap) {
2028
+ for (const ext of schemaMap.values()) {
2029
+ if (ext && typeof ext === 'object') {
2030
+ if (ext.$dynamicAnchor && !eAnchors['#' + ext.$dynamicAnchor]) eAnchors['#' + ext.$dynamicAnchor] = ext
2031
+ if (ext.$anchor && !eAnchors['#' + ext.$anchor]) eAnchors['#' + ext.$anchor] = ext
2032
+ }
2033
+ }
2034
+ }
2035
+
2036
+ const ctx = { varCounter: 0, helperCode: [], rootDefs: eRootDefs, refStack: new Set(), schemaMap: schemaMap || null, anchors: eAnchors, rootSchema: schema }
1774
2037
  const lines = []
1775
2038
  genCodeE(schema, 'd', '', lines, ctx, '#')
1776
2039
  if (lines.length === 0) return (d) => ({ valid: true, errors: [] })
1777
2040
 
1778
- const body = `const _e=[];\n ` +
1779
- (ctx.helperCode.length ? ctx.helperCode.join('\n ') + '\n ' : '') +
1780
- lines.join('\n ') +
1781
- `\n return{valid:_e.length===0,errors:_e}`
2041
+ const checkStr = lines.join('\n ')
2042
+ let body
2043
+ if (ctx.usesRecursion) {
2044
+ body = `const _e=[];\n ` +
2045
+ (ctx.helperCode.length ? ctx.helperCode.join('\n ') + '\n ' : '') +
2046
+ `function _validateE(d,_all,_e){\n ${checkStr}\n }\n _validateE(d,_all,_e);\n ` +
2047
+ `return{valid:_e.length===0,errors:_e}`
2048
+ } else {
2049
+ body = `const _e=[];\n ` +
2050
+ (ctx.helperCode.length ? ctx.helperCode.join('\n ') + '\n ' : '') +
2051
+ checkStr +
2052
+ `\n return{valid:_e.length===0,errors:_e}`
2053
+ }
1782
2054
  try {
1783
2055
  const fn = new Function('d', '_all', body)
1784
2056
  fn._errSource = body
@@ -1797,6 +2069,8 @@ function genCodeE(schema, v, pathExpr, lines, ctx, schemaPrefix) {
1797
2069
 
1798
2070
  // $ref — resolve local and cross-schema refs
1799
2071
  if (schema.$ref) {
2072
+ // Self-reference "#" — no-op (permissive) to avoid infinite recursion
2073
+ if (schema.$ref === '#') return
1800
2074
  const m = schema.$ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/)
1801
2075
  if (m && ctx.rootDefs && ctx.rootDefs[m[1]]) {
1802
2076
  if (ctx.refStack.has(schema.$ref)) return
@@ -1805,6 +2079,18 @@ function genCodeE(schema, v, pathExpr, lines, ctx, schemaPrefix) {
1805
2079
  ctx.refStack.delete(schema.$ref)
1806
2080
  return
1807
2081
  }
2082
+ // Anchor ref: "#foo" — resolve via rootDefs or anchors map
2083
+ if (!m && schema.$ref.startsWith('#') && !schema.$ref.startsWith('#/')) {
2084
+ const entry = ctx.rootDefs && ctx.rootDefs[schema.$ref]
2085
+ const anchorTarget = entry && entry.raw ? entry.raw : (ctx.anchors && ctx.anchors[schema.$ref])
2086
+ if (anchorTarget) {
2087
+ if (ctx.refStack.has(schema.$ref)) return
2088
+ ctx.refStack.add(schema.$ref)
2089
+ genCodeE(anchorTarget, v, pathExpr, lines, ctx, schemaPrefix)
2090
+ ctx.refStack.delete(schema.$ref)
2091
+ return
2092
+ }
2093
+ }
1808
2094
  if (ctx.schemaMap && ctx.schemaMap.has(schema.$ref)) {
1809
2095
  if (ctx.refStack.has(schema.$ref)) return
1810
2096
  ctx.refStack.add(schema.$ref)
@@ -1814,6 +2100,26 @@ function genCodeE(schema, v, pathExpr, lines, ctx, schemaPrefix) {
1814
2100
  }
1815
2101
  }
1816
2102
 
2103
+ // $dynamicRef — resolve via anchors map
2104
+ if (schema.$dynamicRef) {
2105
+ const anchorKey = schema.$dynamicRef.startsWith('#') ? schema.$dynamicRef : '#' + schema.$dynamicRef
2106
+ if (ctx.anchors && ctx.anchors[anchorKey]) {
2107
+ const target = ctx.anchors[anchorKey]
2108
+ if (target === ctx.rootSchema) {
2109
+ // Self-recursive: generate _validateE call
2110
+ ctx.usesRecursion = true
2111
+ lines.push(`_validateE(${v},_all,_e)`)
2112
+ } else {
2113
+ const refKey = '$dynamicRef:' + anchorKey
2114
+ if (!ctx.refStack.has(refKey)) {
2115
+ ctx.refStack.add(refKey)
2116
+ genCodeE(target, v, pathExpr, lines, ctx, schemaPrefix)
2117
+ ctx.refStack.delete(refKey)
2118
+ }
2119
+ }
2120
+ }
2121
+ }
2122
+
1817
2123
  const types = schema.type ? (Array.isArray(schema.type) ? schema.type : [schema.type]) : null
1818
2124
  if (types) {
1819
2125
  const conds = types.map(t => {
@@ -1864,8 +2170,8 @@ function genCodeE(schema, v, pathExpr, lines, ctx, schemaPrefix) {
1864
2170
  const ci = ctx.varCounter++
1865
2171
  const canonFn = `_cnE${ci}`
1866
2172
  ctx.helperCode.push(`const ${canonFn}=function(x){if(x===null||typeof x!=='object')return JSON.stringify(x);if(Array.isArray(x))return'['+x.map(${canonFn}).join(',')+']';return'{'+Object.keys(x).sort().map(function(k){return JSON.stringify(k)+':'+${canonFn}(x[k])}).join(',')+'}'};`)
1867
- const expected = canonFn + '(' + JSON.stringify(cv) + ')'
1868
- lines.push(`if(${canonFn}(${v})!==${expected}){${fail('const', 'const', `{allowedValue:${JSON.stringify(schema.const)}}`, "'must be equal to constant'")}}`)
2173
+ const expected = canonFn + '(JSON.parse(' + JSON.stringify(JSON.stringify(cv)) + '))'
2174
+ lines.push(`if(${canonFn}(${v})!==${expected}){${fail('const', 'const', `{allowedValue:JSON.parse(${JSON.stringify(JSON.stringify(schema.const))})}`, "'must be equal to constant'")}}`)
1869
2175
  }
1870
2176
  }
1871
2177
 
@@ -2186,8 +2492,30 @@ function compileToJSCombined(schema, VALID_RESULT, schemaMap) {
2186
2492
  if (keys.some(k => !supported.includes(k))) return null
2187
2493
  }
2188
2494
 
2495
+ // Build anchors map for $ref/#anchor and $dynamicRef resolution
2496
+ const cRootDefs = schema.$defs || schema.definitions || null
2497
+ const cAnchors = {}
2498
+ if (schema.$dynamicAnchor) cAnchors['#' + schema.$dynamicAnchor] = schema
2499
+ if (schema.$anchor) cAnchors['#' + schema.$anchor] = schema
2500
+ if (cRootDefs) {
2501
+ for (const def of Object.values(cRootDefs)) {
2502
+ if (def && typeof def === 'object') {
2503
+ if (def.$dynamicAnchor) cAnchors['#' + def.$dynamicAnchor] = def
2504
+ if (def.$anchor) cAnchors['#' + def.$anchor] = def
2505
+ }
2506
+ }
2507
+ }
2508
+ if (schemaMap) {
2509
+ for (const ext of schemaMap.values()) {
2510
+ if (ext && typeof ext === 'object') {
2511
+ if (ext.$dynamicAnchor && !cAnchors['#' + ext.$dynamicAnchor]) cAnchors['#' + ext.$dynamicAnchor] = ext
2512
+ if (ext.$anchor && !cAnchors['#' + ext.$anchor]) cAnchors['#' + ext.$anchor] = ext
2513
+ }
2514
+ }
2515
+ }
2516
+
2189
2517
  const ctx = { varCounter: 0, helperCode: [], closureVars: [], closureVals: [],
2190
- rootDefs: schema.$defs || schema.definitions || null, refStack: new Set(), schemaMap: schemaMap || null }
2518
+ rootDefs: cRootDefs, refStack: new Set(), schemaMap: schemaMap || null, anchors: cAnchors, rootSchema: schema }
2191
2519
  const lines = []
2192
2520
  genCodeC(schema, 'd', '', lines, ctx, '#')
2193
2521
  if (lines.length === 0) return () => VALID_RESULT
@@ -2218,8 +2546,10 @@ function genCodeC(schema, v, pathExpr, lines, ctx, schemaPrefix) {
2218
2546
  if (!schemaPrefix) schemaPrefix = '#'
2219
2547
  if (typeof schema !== 'object' || schema === null) return
2220
2548
 
2221
- // $ref — resolve local and cross-schema refs
2549
+ // $ref — resolve local, anchor, and cross-schema refs
2222
2550
  if (schema.$ref) {
2551
+ // Self-reference "#" — no-op (permissive) to avoid infinite recursion
2552
+ if (schema.$ref === '#') return
2223
2553
  const m = schema.$ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/)
2224
2554
  if (m && ctx.rootDefs && ctx.rootDefs[m[1]]) {
2225
2555
  if (ctx.refStack.has(schema.$ref)) return
@@ -2228,6 +2558,18 @@ function genCodeC(schema, v, pathExpr, lines, ctx, schemaPrefix) {
2228
2558
  ctx.refStack.delete(schema.$ref)
2229
2559
  return
2230
2560
  }
2561
+ // Anchor ref: "#foo" — resolve via rootDefs or anchors map
2562
+ if (!m && schema.$ref.startsWith('#') && !schema.$ref.startsWith('#/')) {
2563
+ const entry = ctx.rootDefs && ctx.rootDefs[schema.$ref]
2564
+ const anchorTarget = entry && entry.raw ? entry.raw : (ctx.anchors && ctx.anchors[schema.$ref])
2565
+ if (anchorTarget) {
2566
+ if (ctx.refStack.has(schema.$ref)) return
2567
+ ctx.refStack.add(schema.$ref)
2568
+ genCodeC(anchorTarget, v, pathExpr, lines, ctx, schemaPrefix)
2569
+ ctx.refStack.delete(schema.$ref)
2570
+ return
2571
+ }
2572
+ }
2231
2573
  if (ctx.schemaMap && ctx.schemaMap.has(schema.$ref)) {
2232
2574
  if (ctx.refStack.has(schema.$ref)) return
2233
2575
  ctx.refStack.add(schema.$ref)
@@ -2237,6 +2579,25 @@ function genCodeC(schema, v, pathExpr, lines, ctx, schemaPrefix) {
2237
2579
  }
2238
2580
  }
2239
2581
 
2582
+ // $dynamicRef — resolve via anchors map
2583
+ if (schema.$dynamicRef) {
2584
+ const anchorKey = schema.$dynamicRef.startsWith('#') ? schema.$dynamicRef : '#' + schema.$dynamicRef
2585
+ if (ctx.anchors && ctx.anchors[anchorKey]) {
2586
+ const target = ctx.anchors[anchorKey]
2587
+ if (target === ctx.rootSchema) {
2588
+ // Self-recursive: bail to non-combined path (combined doesn't support named recursion)
2589
+ // Just skip — the hybrid path will handle this via jsFn + errFn
2590
+ } else {
2591
+ const refKey = '$dynamicRef:' + anchorKey
2592
+ if (!ctx.refStack.has(refKey)) {
2593
+ ctx.refStack.add(refKey)
2594
+ genCodeC(target, v, pathExpr, lines, ctx, schemaPrefix)
2595
+ ctx.refStack.delete(refKey)
2596
+ }
2597
+ }
2598
+ }
2599
+ }
2600
+
2240
2601
  const types = schema.type ? (Array.isArray(schema.type) ? schema.type : [schema.type]) : null
2241
2602
  let isObj = false, isArr = false, isStr = false, isNum = false
2242
2603
 
@@ -2312,7 +2673,7 @@ function genCodeC(schema, v, pathExpr, lines, ctx, schemaPrefix) {
2312
2673
  const ci = ctx.varCounter++
2313
2674
  const canonFn = `_cn${ci}`
2314
2675
  ctx.helperCode.push(`const ${canonFn}=function(x){if(x===null||typeof x!=='object')return JSON.stringify(x);if(Array.isArray(x))return'['+x.map(${canonFn}).join(',')+']';return'{'+Object.keys(x).sort().map(function(k){return JSON.stringify(k)+':'+${canonFn}(x[k])}).join(',')+'}'};`)
2315
- lines.push(`if(${canonFn}(${v})!==${canonFn}(${JSON.stringify(cv)})){${fail('const', 'const', `{allowedValue:${JSON.stringify(schema.const)}}`, "'must be equal to constant'")}}`)
2676
+ lines.push(`if(${canonFn}(${v})!==${canonFn}(JSON.parse(${JSON.stringify(JSON.stringify(cv))}))){${fail('const', 'const', `{allowedValue:JSON.parse(${JSON.stringify(JSON.stringify(schema.const))})}`, "'must be equal to constant'")}}`)
2316
2677
  }
2317
2678
  }
2318
2679
 
@@ -2729,11 +3090,20 @@ function _collectEval(schema, result, defs, schemaMap, refStack, isRoot) {
2729
3090
  refStack.add(schema.$ref)
2730
3091
  _collectEval(defs[m[1]], result, defs, schemaMap, refStack)
2731
3092
  refStack.delete(schema.$ref)
2732
- } else if (schemaMap && typeof schemaMap.get === 'function' && schemaMap.has(schema.$ref)) {
2733
- if (refStack.has(schema.$ref)) { result.dynamic = true; return }
2734
- refStack.add(schema.$ref)
2735
- _collectEval(schemaMap.get(schema.$ref), result, defs, schemaMap, refStack)
2736
- refStack.delete(schema.$ref)
3093
+ } else if (schemaMap && typeof schemaMap.get === 'function') {
3094
+ let resolved = schemaMap.has(schema.$ref) ? schemaMap.get(schema.$ref) : null
3095
+ // Relative URI resolution
3096
+ if (!resolved && !schema.$ref.includes('://') && !schema.$ref.startsWith('#')) {
3097
+ for (const [id, s] of schemaMap) {
3098
+ if (id.endsWith('/' + schema.$ref)) { resolved = s; break }
3099
+ }
3100
+ }
3101
+ if (resolved) {
3102
+ if (refStack.has(schema.$ref)) { result.dynamic = true; return }
3103
+ refStack.add(schema.$ref)
3104
+ _collectEval(resolved, result, defs, schemaMap, refStack)
3105
+ refStack.delete(schema.$ref)
3106
+ }
2737
3107
  }
2738
3108
  // In 2020-12, $ref can coexist with siblings — don't return early if there are other keywords
2739
3109
  const hasOtherKeywords = Object.keys(schema).some(k => k !== '$ref' && k !== '$defs' && k !== 'definitions' && k !== '$schema' && k !== '$id')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ata-validator",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "Ultra-fast JSON Schema validator. 4.7x faster validation, 1,800x faster compilation. Works without native addon. Cross-schema $ref, Draft 2020-12 + Draft 7, V8-optimized JS codegen, simdjson, RE2, multi-core. Standard Schema V1 compatible.",
5
5
  "main": "index.js",
6
6
  "module": "index.mjs",
@@ -38,7 +38,8 @@
38
38
  "test:browser": "node tests/test_browser.js",
39
39
  "bench": "node benchmark/bench_large.js",
40
40
  "fuzz": "node tests/fuzz_differential.js",
41
- "fuzz:long": "FUZZ_ITERATIONS=100000 node tests/fuzz_differential.js"
41
+ "fuzz:long": "FUZZ_ITERATIONS=100000 node tests/fuzz_differential.js",
42
+ "test:json-suite": "node tests/run_json_test_suite.js"
42
43
  },
43
44
  "keywords": [
44
45
  "json",