@altopelago/aeos-core 0.9.6 → 0.11.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/dist/validate.js CHANGED
@@ -13,6 +13,7 @@ import { checkTypes } from './rules/typeCheck.js';
13
13
  import { checkReferenceForms } from './rules/referenceForm.js';
14
14
  import { checkNumericForm } from './rules/numericForm.js';
15
15
  import { checkStringForm, checkPatterns, matchesPortablePattern } from './rules/stringForm.js';
16
+ import { parseAddress, resolveAddress, } from '@altopelago/sansa';
16
17
  const TYPE_ALIASES = {
17
18
  NumberLiteral: ['NumberLiteral'],
18
19
  StringLiteral: ['StringLiteral'],
@@ -29,6 +30,7 @@ const TYPE_ALIASES = {
29
30
  RadixLiteral: ['RadixLiteral'],
30
31
  EncodingLiteral: ['EncodingLiteral'],
31
32
  SeparatorLiteral: ['SeparatorLiteral'],
33
+ SansaAddressLiteral: ['SansaAddressLiteral'],
32
34
  DateLiteral: ['DateLiteral'],
33
35
  TimeLiteral: ['TimeLiteral'],
34
36
  DateTimeLiteral: ['DateTimeLiteral'],
@@ -40,6 +42,15 @@ const TYPE_ALIASES = {
40
42
  function formatQuotedMemberSegment(key) {
41
43
  return `.[${JSON.stringify(String(key))}]`;
42
44
  }
45
+ function formatMemberSelector(key) {
46
+ const value = String(key);
47
+ return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)
48
+ ? `.${value}`
49
+ : formatQuotedMemberSegment(value);
50
+ }
51
+ function appendAttributePath(basePath, key) {
52
+ return `${basePath}.@${formatMemberSelector(key)}`;
53
+ }
43
54
  const DEFAULT_RESOURCE_POLICY = {
44
55
  max_events: 100_000,
45
56
  max_rules: 10_000,
@@ -89,6 +100,7 @@ const STRING_LIKE_VALUE_TYPES = new Set([
89
100
  'StringLiteral',
90
101
  'TrimtickLiteral',
91
102
  'SeparatorLiteral',
103
+ 'SansaAddressLiteral',
92
104
  'HexLiteral',
93
105
  'EncodingLiteral',
94
106
  'NullLiteral',
@@ -109,7 +121,7 @@ function enforceStringLengthResourceBudget(info, path, policy, ctx) {
109
121
  emitResourceError(ctx, path, `String-like payload length ${payloadLength} exceeds max_string_length_default ${policy.max_string_length_default}`, info.span);
110
122
  }
111
123
  for (const [key, attribute] of info.attributes ?? []) {
112
- enforceStringLengthResourceBudget(attribute, `${path}@${key}`, policy, ctx);
124
+ enforceStringLengthResourceBudget(attribute, appendAttributePath(path, key), policy, ctx);
113
125
  }
114
126
  }
115
127
  function inspectSchemaResourceShape(schema, policy, ctx) {
@@ -143,7 +155,7 @@ function inspectConstraintResourceShape(constraints, path, depth, policy, ctx) {
143
155
  }
144
156
  if (constraints.attributes !== undefined) {
145
157
  for (const [key, child] of Object.entries(constraints.attributes)) {
146
- inspectConstraintResourceShape(child, `${path}@${key}`, depth + 1, policy, ctx);
158
+ inspectConstraintResourceShape(child, appendAttributePath(path, key), depth + 1, policy, ctx);
147
159
  }
148
160
  }
149
161
  }
@@ -274,6 +286,7 @@ export function validate(aes, schema, options = {}) {
274
286
  ...(attributes ? { attributes } : {}),
275
287
  };
276
288
  eventsByPath.set(elementPath, info);
289
+ addAttributeEvents(elementPath, attributes);
277
290
  }
278
291
  }
279
292
  function buildAttributeInfoMap(attributes) {
@@ -300,6 +313,22 @@ export function validate(aes, schema, options = {}) {
300
313
  }
301
314
  return mapped;
302
315
  }
316
+ function addAttributeEvents(basePath, attributes) {
317
+ if (!attributes)
318
+ return;
319
+ for (const [key, attribute] of attributes.entries()) {
320
+ const attributePath = appendAttributePath(basePath, key);
321
+ eventsByPath.set(attributePath, {
322
+ type: attribute.type,
323
+ raw: attribute.raw,
324
+ value: attribute.value,
325
+ ...(attribute.datatype !== undefined ? { datatype: attribute.datatype } : {}),
326
+ span: attribute.span,
327
+ ...(attribute.attributes ? { attributes: attribute.attributes } : {}),
328
+ });
329
+ addAttributeEvents(attributePath, attribute.attributes);
330
+ }
331
+ }
303
332
  for (let i = 0; i < aes.length; i++) {
304
333
  const event = aes[i];
305
334
  const pathStr = formatCanonicalPath(event.path);
@@ -338,6 +367,7 @@ export function validate(aes, schema, options = {}) {
338
367
  ...(attributes ? { attributes } : {}),
339
368
  };
340
369
  eventsByPath.set(pathStr, info);
370
+ addAttributeEvents(pathStr, attributes);
341
371
  if ((event.value.type === 'TupleLiteral' || event.value.type === 'ListLiteral' || event.value.type === 'ListNode')
342
372
  && Array.isArray(event.value.elements)) {
343
373
  containerArity.set(pathStr, event.value.elements.length);
@@ -391,12 +421,12 @@ export function validate(aes, schema, options = {}) {
391
421
  // Phase 3: Build rule index from schema (run after baseline invariants)
392
422
  const ruleIndex = buildRuleIndex(schema, ctx);
393
423
  const selectorExpansionBudget = { count: 0 };
394
- const expandedRuleIndex = expandWildcardRules(expandSelectorRules(ruleIndex, schema, eventsByPath, ctx, resourcePolicy, selectorExpansionBudget), eventsByPath, ctx, resourcePolicy, selectorExpansionBudget);
424
+ const expandedRuleIndex = expandSelectorRules(ruleIndex, schema, eventsByPath, ctx, resourcePolicy, selectorExpansionBudget);
395
425
  const effectiveRuleIndex = mergeDatatypeRules(expandedRuleIndex, schema.datatype_rules, eventsByPath);
396
426
  // Phase 4: Presence checks (required fields)
397
- const boundPaths = new Set(seen.keys());
427
+ const boundPaths = new Set(eventsByPath.keys());
398
428
  checkPresence(effectiveRuleIndex, boundPaths, ctx);
399
- checkWorldPolicy(schema, aes, boundPaths, ctx);
429
+ checkWorldPolicy(schema, aes, boundPaths, eventsByPath, ctx);
400
430
  // Phase 5: Type checks (literal kind)
401
431
  checkReferenceForms(schema, effectiveRuleIndex, eventsByPath, ctx);
402
432
  const effectiveEventsByPath = resolveReferenceFormEvents(effectiveRuleIndex, eventsByPath, resourcePolicy, ctx);
@@ -494,7 +524,7 @@ export function validate(aes, schema, options = {}) {
494
524
  }
495
525
  return createPassingEnvelope(guarantees, ctx.warnings);
496
526
  }
497
- function checkWorldPolicy(schema, aes, boundPaths, ctx) {
527
+ function checkWorldPolicy(schema, aes, boundPaths, eventsByPath, ctx) {
498
528
  if ((schema.world ?? 'open') !== 'closed')
499
529
  return;
500
530
  const allowedRules = schema.rules
@@ -504,6 +534,12 @@ function checkWorldPolicy(schema, aes, boundPaths, ctx) {
504
534
  ? { kind: 'selector', value: rule.selector }
505
535
  : null)
506
536
  .filter((rule) => rule !== null);
537
+ const selectorMatches = new Map();
538
+ for (const rule of allowedRules) {
539
+ if (rule.kind !== 'selector' || selectorMatches.has(rule.value))
540
+ continue;
541
+ selectorMatches.set(rule.value, resolveSansaSelectorPathSet(rule.value, eventsByPath, ctx));
542
+ }
507
543
  for (const event of aes) {
508
544
  const key = typeof event.key === 'string' ? event.key : '';
509
545
  if (key.startsWith('aeon:'))
@@ -512,7 +548,7 @@ function checkWorldPolicy(schema, aes, boundPaths, ctx) {
512
548
  if (!boundPaths.has(path))
513
549
  continue;
514
550
  if (allowedRules.some((rule) => rule.kind === 'selector'
515
- ? matchesSelectorPath(path, rule.value)
551
+ ? selectorMatches.get(rule.value)?.has(path) === true
516
552
  : matchesAllowedPath(path, rule.value)))
517
553
  continue;
518
554
  emitError(ctx, createDiag(path, toTupleLocal(event.span), `Binding '${path}' is not allowed by closed-world schema`, ErrorCodes.UNEXPECTED_BINDING));
@@ -549,10 +585,11 @@ function expandSelectorRules(ruleIndex, schema, eventsByPath, ctx, resourcePolic
549
585
  continue;
550
586
  if (typeof rule.path === 'string' && rule.path.length > 0)
551
587
  continue;
588
+ const resolvedPaths = resolveSansaSelectorPathSet(rule.selector, eventsByPath, ctx);
589
+ if (resolvedPaths === null)
590
+ continue;
552
591
  let matched = false;
553
- for (const actualPath of eventsByPath.keys()) {
554
- if (!matchesSelectorPath(actualPath, rule.selector))
555
- continue;
592
+ for (const actualPath of resolvedPaths) {
556
593
  matched = true;
557
594
  expansionBudget.count += 1;
558
595
  if (expansionBudget.count > resourcePolicy.max_selector_expansions) {
@@ -569,25 +606,6 @@ function expandSelectorRules(ruleIndex, schema, eventsByPath, ctx, resourcePolic
569
606
  }
570
607
  return expanded;
571
608
  }
572
- function expandWildcardRules(ruleIndex, eventsByPath, ctx, resourcePolicy, expansionBudget) {
573
- const expanded = new Map(ruleIndex);
574
- for (const [path, rule] of ruleIndex.entries()) {
575
- if (!path.includes('[*]'))
576
- continue;
577
- expanded.delete(path);
578
- for (const actualPath of eventsByPath.keys()) {
579
- if (matchesAllowedPath(actualPath, path)) {
580
- expansionBudget.count += 1;
581
- if (expansionBudget.count > resourcePolicy.max_selector_expansions) {
582
- emitResourceError(ctx, path, `Wildcard expansion count exceeds max_selector_expansions ${resourcePolicy.max_selector_expansions}`);
583
- return expanded;
584
- }
585
- expanded.set(actualPath, { ...rule, path: actualPath });
586
- }
587
- }
588
- }
589
- return expanded;
590
- }
591
609
  function selectAnyOfRules(ruleIndex, eventsByPath, ctx) {
592
610
  const selected = new Map(ruleIndex);
593
611
  for (const [path, rule] of ruleIndex.entries()) {
@@ -701,156 +719,93 @@ function formatReferenceLookupPath(segments) {
701
719
  : formatQuotedMemberSegment(segment);
702
720
  continue;
703
721
  }
704
- out += /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(segment.key)
705
- ? `@${segment.key}`
706
- : `@[${JSON.stringify(segment.key)}]`;
722
+ out = appendAttributePath(out, segment.key);
707
723
  }
708
724
  return out;
709
725
  }
710
726
  function matchesAllowedPath(actualPath, allowedPath) {
711
- if (actualPath === allowedPath)
712
- return true;
713
- // Closed-world schemas may allow list descendants via canonical wildcard paths
714
- // such as `$.items[*]` or `$.items[*].x`.
715
- if (!allowedPath.includes('[*]'))
716
- return false;
717
- const escaped = allowedPath
718
- .split('[*]')
719
- .map((part) => part.replace(/[|\\{}()[\]^$+?.]/g, '\\$&'))
720
- .join('\\[\\d+\\]');
721
- const pattern = `^${escaped}$`;
722
- return new RegExp(pattern).test(actualPath);
727
+ return actualPath === allowedPath;
723
728
  }
724
- function tokenizeCanonicalLikePath(path) {
725
- if (!path.startsWith('$'))
726
- return null;
727
- const segments = [];
728
- let index = 1;
729
- while (index < path.length) {
730
- const marker = path[index];
731
- if (marker === '.') {
732
- index += 1;
733
- if (path[index] === '[') {
734
- const end = findBracketEnd(path, index);
735
- if (end < 0)
736
- return null;
737
- segments.push(path.slice(index, end + 1));
738
- index = end + 1;
739
- continue;
740
- }
741
- const start = index;
742
- while (index < path.length && !['.', '[', '@'].includes(path[index])) {
743
- index += 1;
744
- }
745
- if (start === index)
746
- return null;
747
- segments.push(path.slice(start, index));
748
- continue;
749
- }
750
- if (marker === '[') {
751
- const end = findBracketEnd(path, index);
752
- if (end < 0)
753
- return null;
754
- segments.push(path.slice(index, end + 1));
755
- index = end + 1;
756
- continue;
757
- }
758
- if (marker === '@') {
759
- index += 1;
760
- if (path[index] === '[') {
761
- const end = findBracketEnd(path, index);
762
- if (end < 0)
763
- return null;
764
- segments.push(`@${path.slice(index, end + 1)}`);
765
- index = end + 1;
766
- continue;
767
- }
768
- const start = index;
769
- while (index < path.length && !['.', '[', '@'].includes(path[index])) {
770
- index += 1;
771
- }
772
- if (start === index)
773
- return null;
774
- segments.push(`@${path.slice(start, index)}`);
775
- continue;
776
- }
729
+ function resolveSansaSelectorPathSet(selector, eventsByPath, ctx) {
730
+ const result = resolveAddress(selector, createAeosSansaResolveNamespace(eventsByPath));
731
+ if (!result.ok) {
732
+ const first = result.errors[0];
733
+ emitError(ctx, createDiag(selector, null, first ? `Invalid or unsupported SANSA selector: ${first.message}` : `Invalid or unsupported SANSA selector: ${selector}`, ErrorCodes.INVALID_SCHEMA_POLICY));
777
734
  return null;
778
735
  }
779
- return segments;
736
+ return new Set(result.bindings.map((binding) => binding.path).filter((path) => eventsByPath.has(path)));
780
737
  }
781
- function findBracketEnd(path, start) {
782
- let quote = null;
783
- let escaped = false;
784
- for (let index = start + 1; index < path.length; index++) {
785
- const ch = path[index];
786
- if (escaped) {
787
- escaped = false;
788
- continue;
789
- }
790
- if (quote) {
791
- if (ch === '\\') {
792
- escaped = true;
793
- }
794
- else if (ch === quote) {
795
- quote = null;
796
- }
797
- continue;
798
- }
799
- if (ch === '"' || ch === "'") {
800
- quote = ch;
801
- continue;
802
- }
803
- if (ch === ']')
804
- return index;
738
+ function createAeosSansaResolveNamespace(eventsByPath) {
739
+ const root = buildAeosSansaResolveTree(eventsByPath);
740
+ return {
741
+ root,
742
+ children: (binding) => binding.children,
743
+ member: (binding, name) => binding.children.find((child) => child.name === name),
744
+ position: (binding, index) => binding.children.find((child) => child.index === index),
745
+ attributeSpace: (binding) => binding.attributeSpace,
746
+ name: (binding) => binding.name,
747
+ index: (binding) => binding.index,
748
+ semanticType: (binding) => binding.info?.datatype,
749
+ representationKind: (binding) => binding.info?.type,
750
+ };
751
+ }
752
+ function buildAeosSansaResolveTree(eventsByPath) {
753
+ const root = { path: '$', children: [] };
754
+ for (const [path, info] of [...eventsByPath.entries()].sort((a, b) => a[0].length - b[0].length)) {
755
+ insertAeosSansaResolvePath(root, path, info);
805
756
  }
806
- return -1;
757
+ return root;
807
758
  }
808
- function matchesSelectorPath(actualPath, selector) {
809
- if (actualPath === selector)
810
- return true;
811
- const actualSegments = tokenizeCanonicalLikePath(actualPath);
812
- const selectorSegments = tokenizeCanonicalLikePath(selector);
813
- if (!actualSegments || !selectorSegments)
814
- return false;
815
- const matchFrom = (actualIndex, selectorIndex) => {
816
- if (selectorIndex === selectorSegments.length) {
817
- return actualIndex === actualSegments.length;
818
- }
819
- const selectorSegment = selectorSegments[selectorIndex];
820
- if (selectorSegment === '**') {
821
- if (selectorIndex === selectorSegments.length - 1)
822
- return true;
823
- for (let nextActual = actualIndex; nextActual <= actualSegments.length; nextActual++) {
824
- if (matchFrom(nextActual, selectorIndex + 1))
825
- return true;
826
- }
827
- return false;
828
- }
829
- if (actualIndex >= actualSegments.length)
830
- return false;
831
- if (selectorSegment === '*') {
832
- return matchFrom(actualIndex + 1, selectorIndex + 1);
833
- }
834
- if (selectorSegment === '[*]') {
835
- return /^\[\d+\]$/.test(actualSegments[actualIndex])
836
- && matchFrom(actualIndex + 1, selectorIndex + 1);
759
+ function insertAeosSansaResolvePath(root, path, info) {
760
+ const parsed = parseAddress(path);
761
+ if (!parsed.ok)
762
+ return;
763
+ if (parsed.address.root.kind !== 'absolute')
764
+ return;
765
+ let current = root;
766
+ let currentPath = '$';
767
+ for (const selector of parsed.address.selectors) {
768
+ switch (selector.type) {
769
+ case 'member':
770
+ currentPath += formatMemberSelector(selector.name);
771
+ current = getOrCreateChildBinding(current, currentPath, { name: selector.name });
772
+ break;
773
+ case 'position':
774
+ currentPath += `[${selector.index}]`;
775
+ current = getOrCreateChildBinding(current, currentPath, { index: selector.index });
776
+ break;
777
+ case 'attributeSpace':
778
+ currentPath += '.@';
779
+ current.attributeSpace ??= { path: currentPath, children: [] };
780
+ current = current.attributeSpace;
781
+ break;
782
+ default:
783
+ return;
837
784
  }
838
- return selectorSegment === actualSegments[actualIndex]
839
- && matchFrom(actualIndex + 1, selectorIndex + 1);
840
- };
841
- return matchFrom(0, 0);
785
+ }
786
+ current.info = info;
787
+ }
788
+ function getOrCreateChildBinding(parent, path, identity) {
789
+ const existing = parent.children.find((child) => identity.name !== undefined
790
+ ? child.name === identity.name
791
+ : child.index === identity.index);
792
+ if (existing)
793
+ return existing;
794
+ const child = { path, children: [], ...identity };
795
+ parent.children.push(child);
796
+ return child;
842
797
  }
843
798
  function collectAllowedAttributePaths(ruleIndex) {
844
799
  const allowed = [];
845
800
  function visit(basePath, constraints) {
846
- if (basePath.includes('@')) {
801
+ if (basePath.includes('.@.')) {
847
802
  allowed.push(basePath);
848
803
  }
849
804
  const attributes = constraints.attributes;
850
805
  if (!attributes)
851
806
  return;
852
807
  for (const [key, childConstraints] of Object.entries(attributes)) {
853
- visit(`${basePath}@${key}`, childConstraints);
808
+ visit(appendAttributePath(basePath, key), childConstraints);
854
809
  }
855
810
  }
856
811
  for (const [path, rule] of ruleIndex) {
@@ -864,7 +819,7 @@ function collectAttributeEntries(eventsByPath) {
864
819
  if (!attributes)
865
820
  return;
866
821
  for (const [key, entry] of attributes.entries()) {
867
- const path = `${basePath}@${key}`;
822
+ const path = appendAttributePath(basePath, key);
868
823
  entries.push({ path, span: entry.span });
869
824
  visit(path, entry.attributes);
870
825
  }
@@ -974,7 +929,7 @@ function checkAttributeConstraints(ruleIndex, eventsByPath, datatypeRules, ctx)
974
929
  function validateAttributeMap(basePath, attributes, constraints, datatypeRules, ctx) {
975
930
  const requiredAttributes = constraints.attributes ?? {};
976
931
  for (const [key, childConstraints] of Object.entries(requiredAttributes)) {
977
- const childPath = `${basePath}@${key}`;
932
+ const childPath = appendAttributePath(basePath, key);
978
933
  const entry = attributes?.get(key);
979
934
  if (childConstraints.required === true && !entry) {
980
935
  emitError(ctx, createDiag(childPath, null, `Missing required field: ${childPath}`, ErrorCodes.MISSING_REQUIRED_FIELD));
@@ -989,7 +944,7 @@ function validateAttributeMap(basePath, attributes, constraints, datatypeRules,
989
944
  for (const key of attributes.keys()) {
990
945
  if (allowed.has(key))
991
946
  continue;
992
- const childPath = `${basePath}@${key}`;
947
+ const childPath = appendAttributePath(basePath, key);
993
948
  emitError(ctx, createDiag(childPath, attributes.get(key)?.span ?? null, `Attribute '${childPath}' is not allowed by closed attribute constraints`, ErrorCodes.UNEXPECTED_ATTRIBUTE_ENTRY));
994
949
  }
995
950
  }
@@ -1217,6 +1172,7 @@ function isStringType(type) {
1217
1172
  || type === 'TrimtickLiteral'
1218
1173
  || type === 'TrimtickStringLiteral'
1219
1174
  || type === 'SeparatorLiteral'
1175
+ || type === 'SansaAddressLiteral'
1220
1176
  || type === 'NullLiteral'
1221
1177
  || type === 'EncodingLiteral'
1222
1178
  || type === 'DateLiteral'