@altopelago/aeos-core 0.12.0 → 0.13.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
@@ -14,7 +14,9 @@ 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
16
  import { datatypeBase, declaredRadixFromDatatype, parseClarifierValues } from './util/datatypes.js';
17
+ import { compareNumericValues } from './util/numericBounds.js';
17
18
  import { parseAddress, resolveAddress, } from '@altopelago/sansa';
19
+ import { formatDatatypeDescriptor, } from '@altopelago/aeon-aes';
18
20
  const TYPE_ALIASES = {
19
21
  NumberLiteral: ['NumberLiteral'],
20
22
  StringLiteral: ['StringLiteral'],
@@ -39,6 +41,7 @@ const TYPE_ALIASES = {
39
41
  CloneReference: ['CloneReference'],
40
42
  PointerReference: ['PointerReference'],
41
43
  NodeLiteral: ['NodeLiteral'],
44
+ NodeHead: ['NodeHead'],
42
45
  };
43
46
  function formatQuotedMemberSegment(key) {
44
47
  return `.[${JSON.stringify(String(key))}]`;
@@ -207,6 +210,8 @@ export function validate(aes, schema, options = {}) {
207
210
  // Phase 3: (moved to run after Phase 2)
208
211
  // Helpers: format canonical path (local, no runtime AEON deps)
209
212
  function formatCanonicalPath(path) {
213
+ if (typeof path === 'string')
214
+ return path;
210
215
  if (!path || !Array.isArray(path.segments))
211
216
  return '$';
212
217
  let result = '';
@@ -235,6 +240,10 @@ export function validate(aes, schema, options = {}) {
235
240
  function toTuple(span) {
236
241
  if (!span)
237
242
  return null;
243
+ if (typeof span === 'string') {
244
+ const match = span.match(/^(0|[1-9][0-9]*):(0|[1-9][0-9]*)$/u);
245
+ return match === null ? null : [Number(match[1]), Number(match[2])];
246
+ }
238
247
  if (Array.isArray(span) && span.length === 2 && typeof span[0] === 'number')
239
248
  return span;
240
249
  if (span.start && span.end && typeof span.start.offset === 'number')
@@ -264,6 +273,7 @@ export function validate(aes, schema, options = {}) {
264
273
  const element = elements[i];
265
274
  const attributes = buildAttributeInfoMap(element?.attributes);
266
275
  const info = {
276
+ ...(typeof element?.structuralId === 'string' ? { identity: element.structuralId } : {}),
267
277
  type: typeof element?.type === 'string' ? element.type : 'Unknown',
268
278
  raw: typeof element?.raw === 'string' ? element.raw : '',
269
279
  value: typeof element?.value === 'string' ? element.value : '',
@@ -288,6 +298,7 @@ export function validate(aes, schema, options = {}) {
288
298
  const valueNode = entry?.value;
289
299
  const nestedAttributes = buildAttributeInfoMap(entry?.annotations);
290
300
  const info = {
301
+ ...(typeof entry?.structuralId === 'string' ? { identity: entry.structuralId } : {}),
291
302
  type: typeof valueNode?.type === 'string' ? valueNode.type : 'Unknown',
292
303
  raw: typeof valueNode?.raw === 'string' ? valueNode.raw : '',
293
304
  value: typeof valueNode?.value === 'string' ? valueNode.value : '',
@@ -305,6 +316,7 @@ export function validate(aes, schema, options = {}) {
305
316
  for (const [key, attribute] of attributes.entries()) {
306
317
  const attributePath = appendAttributePath(basePath, key);
307
318
  eventsByPath.set(attributePath, {
319
+ ...(attribute.identity !== undefined ? { identity: attribute.identity } : {}),
308
320
  type: attribute.type,
309
321
  raw: attribute.raw,
310
322
  value: attribute.value,
@@ -318,6 +330,7 @@ export function validate(aes, schema, options = {}) {
318
330
  for (let i = 0; i < aes.length; i++) {
319
331
  const event = aes[i];
320
332
  const pathStr = formatCanonicalPath(event.path);
333
+ const portable = isPortableTelexRecord(event);
321
334
  if (pathStr.length > resourcePolicy.max_path_length) {
322
335
  emitResourceError(ctx, pathStr, `Path length ${pathStr.length} exceeds max_path_length ${resourcePolicy.max_path_length}`, toTuple(event.span));
323
336
  }
@@ -341,9 +354,27 @@ export function validate(aes, schema, options = {}) {
341
354
  else {
342
355
  seen.set(pathStr, event.span);
343
356
  // Collect event info for Phase 5-7 checks
344
- if (event.value && typeof event.value.type === 'string') {
357
+ if (portable) {
358
+ const value = typeof event.value === 'string' ? event.value : '';
359
+ const datatype = portableDatatype(event);
360
+ const info = {
361
+ ...(typeof event.identity === 'string' ? { identity: event.identity } : {}),
362
+ type: event.kind,
363
+ raw: value,
364
+ value,
365
+ ...(datatype !== undefined ? { datatype } : {}),
366
+ span: toTuple(event.span),
367
+ ...((event.kind === 'CloneReference' || event.kind === 'PointerReference')
368
+ && typeof event.value === 'string'
369
+ ? { referencePath: parsePortableReferencePath(event.value) }
370
+ : {}),
371
+ };
372
+ eventsByPath.set(pathStr, info);
373
+ }
374
+ else if (event.value && typeof event.value.type === 'string') {
345
375
  const attributes = buildAttributeInfoMap(event.annotations);
346
376
  const info = {
377
+ ...(typeof event.structuralId === 'string' ? { identity: event.structuralId } : {}),
347
378
  type: event.value.type,
348
379
  raw: typeof event.value.raw === 'string' ? event.value.raw : '',
349
380
  value: typeof event.value.value === 'string' ? event.value.value : '',
@@ -379,18 +410,26 @@ export function validate(aes, schema, options = {}) {
379
410
  }
380
411
  // Register index even for first occurrence
381
412
  }
413
+ hydratePortableAttributes(eventsByPath, aes);
414
+ hydratePortableContainerArities(aes, containerArity, resourcePolicy, ctx, toTuple);
382
415
  for (const [path, info] of eventsByPath) {
383
416
  enforceStringLengthResourceBudget(info, path, resourcePolicy, ctx);
384
417
  }
385
418
  // Optional separator literal trailing-delimiter policy
386
419
  if (trailingSeparatorPolicy !== 'off') {
387
420
  for (const event of aes) {
388
- if (event?.value?.type !== 'SeparatorLiteral')
421
+ const portable = isPortableTelexRecord(event);
422
+ const kind = portable ? event.kind : event?.value?.type;
423
+ if (kind !== 'SeparatorLiteral')
389
424
  continue;
390
- const payload = typeof event.value.value === 'string' ? event.value.value : '';
425
+ const payload = portable
426
+ ? (typeof event.value === 'string' ? event.value : '')
427
+ : (typeof event.value.value === 'string' ? event.value.value : '');
391
428
  if (payload.length === 0)
392
429
  continue;
393
- const separators = decodeSeparatorChars(typeof event.datatype === 'string' ? event.datatype : undefined);
430
+ const separators = decodeSeparatorChars(portable
431
+ ? portableDatatype(event)
432
+ : (typeof event.datatype === 'string' ? event.datatype : undefined));
394
433
  if (separators.length === 0)
395
434
  continue;
396
435
  const lastChar = payload[payload.length - 1];
@@ -405,7 +444,11 @@ export function validate(aes, schema, options = {}) {
405
444
  }
406
445
  }
407
446
  // Phase 3: Build rule index from schema (run after baseline invariants)
447
+ const errorCountBeforeSchemaIndex = ctx.errors.length;
408
448
  const ruleIndex = buildRuleIndex(schema, ctx);
449
+ if (ctx.errors.length > errorCountBeforeSchemaIndex) {
450
+ return createFailingEnvelope(ctx.errors, ctx.warnings, {});
451
+ }
409
452
  const selectorExpansionBudget = { count: 0 };
410
453
  const expandedRuleIndex = expandSelectorRules(ruleIndex, schema, eventsByPath, ctx, resourcePolicy, selectorExpansionBudget);
411
454
  const effectiveRuleIndex = mergeDatatypeRules(expandedRuleIndex, schema.datatype_rules, eventsByPath);
@@ -528,7 +571,7 @@ function checkWorldPolicy(schema, aes, boundPaths, eventsByPath, ctx) {
528
571
  }
529
572
  for (const event of aes) {
530
573
  const key = typeof event.key === 'string' ? event.key : '';
531
- if (key.startsWith('aeon:'))
574
+ if (event.sourcePlane === 'header' || (event.sourcePlane === undefined && key.startsWith('aeon:')))
532
575
  continue;
533
576
  const path = formatCanonicalPathLocal(event.path);
534
577
  if (!boundPaths.has(path))
@@ -664,6 +707,15 @@ function constraintBranchMatchesEvent(constraints, event) {
664
707
  return false;
665
708
  if (event.type === 'RadixLiteral' && constraints.radix !== undefined && !radixConstraintMatches(event.datatype, event.raw, constraints))
666
709
  return false;
710
+ if (constraints.min_value !== undefined || constraints.max_value !== undefined) {
711
+ const normalized = normalizeRangeLiteral(event.type, event.raw);
712
+ if (normalized === null)
713
+ return false;
714
+ if (constraints.min_value !== undefined && compareNumericValues(normalized, constraints.min_value) === -1)
715
+ return false;
716
+ if (constraints.max_value !== undefined && compareNumericValues(normalized, constraints.max_value) === 1)
717
+ return false;
718
+ }
667
719
  }
668
720
  return true;
669
721
  }
@@ -770,6 +822,8 @@ function insertAeosSansaResolvePath(root, path, info) {
770
822
  }
771
823
  }
772
824
  current.info = info;
825
+ if (info.identity !== undefined)
826
+ current.identity = info.identity;
773
827
  }
774
828
  function getOrCreateChildBinding(parent, path, identity) {
775
829
  const existing = parent.children.find((child) => identity.name !== undefined
@@ -865,17 +919,17 @@ function checkDatatypeRules(datatypeRules, eventsByPath, ctx) {
865
919
  continue;
866
920
  }
867
921
  if (constraints.min_value !== undefined || constraints.max_value !== undefined) {
868
- const range = normalizeRangeLiteral(event.type, raw);
869
- if (!range) {
922
+ const normalized = normalizeRangeLiteral(event.type, raw);
923
+ if (!normalized) {
870
924
  emitError(ctx, createDiag(path, event.span, `Datatype rule violation for ':${event.datatype}': range constraints require numeric literal form`, ErrorCodes.NUMERIC_FORM_VIOLATION));
871
925
  continue;
872
926
  }
873
- if (constraints.min_value !== undefined && isBelowRange(range, constraints.min_value)) {
874
- emitError(ctx, createDiag(path, event.span, `Datatype rule violation for ':${event.datatype}': expected value >= ${constraints.min_value}, got ${range.raw}`, ErrorCodes.NUMERIC_FORM_VIOLATION));
927
+ if (constraints.min_value !== undefined && compareNumericValues(normalized, constraints.min_value) === -1) {
928
+ emitError(ctx, createDiag(path, event.span, `Datatype rule violation for ':${event.datatype}': expected value >= ${constraints.min_value}, got ${normalized}`, ErrorCodes.NUMERIC_FORM_VIOLATION));
875
929
  continue;
876
930
  }
877
- if (constraints.max_value !== undefined && isAboveRange(range, constraints.max_value)) {
878
- emitError(ctx, createDiag(path, event.span, `Datatype rule violation for ':${event.datatype}': expected value <= ${constraints.max_value}, got ${range.raw}`, ErrorCodes.NUMERIC_FORM_VIOLATION));
931
+ if (constraints.max_value !== undefined && compareNumericValues(normalized, constraints.max_value) === 1) {
932
+ emitError(ctx, createDiag(path, event.span, `Datatype rule violation for ':${event.datatype}': expected value <= ${constraints.max_value}, got ${normalized}`, ErrorCodes.NUMERIC_FORM_VIOLATION));
879
933
  }
880
934
  }
881
935
  }
@@ -987,6 +1041,21 @@ function validateAttributeEntry(path, entry, constraints, datatypeRules, ctx) {
987
1041
  emitError(ctx, createDiag(path, entry.span, `Numeric form violation: radix literal digit '${invalidDigit}' is outside radix ${effectiveConstraints.radix}`, ErrorCodes.NUMERIC_FORM_VIOLATION));
988
1042
  }
989
1043
  }
1044
+ if (effectiveConstraints.min_value !== undefined || effectiveConstraints.max_value !== undefined) {
1045
+ const normalized = normalizeRangeLiteral(entry.type, entry.raw);
1046
+ if (normalized === null) {
1047
+ emitError(ctx, createDiag(path, entry.span, 'Numeric form violation: range constraints require numeric literal form', ErrorCodes.NUMERIC_FORM_VIOLATION));
1048
+ return;
1049
+ }
1050
+ if (effectiveConstraints.min_value !== undefined
1051
+ && compareNumericValues(normalized, effectiveConstraints.min_value) === -1) {
1052
+ emitError(ctx, createDiag(path, entry.span, `Numeric form violation: expected value >= ${effectiveConstraints.min_value}, got ${normalized}`, ErrorCodes.NUMERIC_FORM_VIOLATION));
1053
+ }
1054
+ if (effectiveConstraints.max_value !== undefined
1055
+ && compareNumericValues(normalized, effectiveConstraints.max_value) === 1) {
1056
+ emitError(ctx, createDiag(path, entry.span, `Numeric form violation: expected value <= ${effectiveConstraints.max_value}, got ${normalized}`, ErrorCodes.NUMERIC_FORM_VIOLATION));
1057
+ }
1058
+ }
990
1059
  }
991
1060
  if (isStringType(entry.type)) {
992
1061
  if (effectiveConstraints.min_length !== undefined && entry.value.length < effectiveConstraints.min_length) {
@@ -1092,36 +1161,20 @@ function datatypeTypeMatches(actualType, expectedType, raw) {
1092
1161
  }
1093
1162
  function normalizeRangeLiteral(type, raw) {
1094
1163
  const normalized = raw.replace(/_/g, '');
1095
- if (type === 'FloatLiteral' || /[.eE]/.test(normalized)) {
1096
- if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(normalized))
1097
- return null;
1098
- const value = Number(normalized);
1099
- return Number.isFinite(value) ? { kind: 'float', raw: normalized, value } : null;
1100
- }
1101
- if (!/^[+-]?\d+$/.test(normalized))
1164
+ if (type !== 'FloatLiteral' && type !== 'NumberLiteral' && type !== 'IntegerLiteral')
1102
1165
  return null;
1103
- return { kind: 'integer', raw: normalized, value: BigInt(normalized) };
1104
- }
1105
- function isBelowRange(range, bound) {
1106
- if (range.kind === 'integer' && /^[-+]?\d+$/.test(bound)) {
1107
- return range.value < BigInt(bound);
1108
- }
1109
- return rangeAsNumber(range) < Number(bound);
1110
- }
1111
- function isAboveRange(range, bound) {
1112
- if (range.kind === 'integer' && /^[-+]?\d+$/.test(bound)) {
1113
- return range.value > BigInt(bound);
1114
- }
1115
- return rangeAsNumber(range) > Number(bound);
1116
- }
1117
- function rangeAsNumber(range) {
1118
- return range.kind === 'integer' ? Number(range.value) : range.value;
1166
+ return compareNumericValues(normalized, normalized) === null ? null : normalized;
1119
1167
  }
1120
1168
  function countIntegerDigits(raw) {
1121
1169
  return raw.replace(/^[+-]/, '').replace(/_/g, '').split('.')[0]?.length ?? 0;
1122
1170
  }
1123
1171
  function hasDigitFormConstraints(constraints) {
1124
- return constraints.sign !== undefined || constraints.min_digits !== undefined || constraints.max_digits !== undefined || constraints.radix !== undefined;
1172
+ return constraints.sign !== undefined
1173
+ || constraints.min_digits !== undefined
1174
+ || constraints.max_digits !== undefined
1175
+ || constraints.radix !== undefined
1176
+ || constraints.min_value !== undefined
1177
+ || constraints.max_value !== undefined;
1125
1178
  }
1126
1179
  function isDigitFormLiteral(type) {
1127
1180
  return type === 'NumberLiteral' || type === 'HexLiteral' || type === 'RadixLiteral';
@@ -1191,7 +1244,159 @@ function isNegative(raw) {
1191
1244
  function isFormNegative(raw) {
1192
1245
  return /^[$#%^]?-/.test(raw) || raw.startsWith('-');
1193
1246
  }
1247
+ function isPortableTelexRecord(value) {
1248
+ if (value === null || typeof value !== 'object')
1249
+ return false;
1250
+ const record = value;
1251
+ return typeof record.path === 'string' && typeof record.kind === 'string' && record.header === undefined;
1252
+ }
1253
+ function portableDatatype(record) {
1254
+ if (typeof record.datatype !== 'string')
1255
+ return undefined;
1256
+ if (!Array.isArray(record.generics) || !Array.isArray(record.clarifiers))
1257
+ return record.datatype;
1258
+ return formatDatatypeDescriptor({
1259
+ datatype: record.datatype,
1260
+ generics: record.generics,
1261
+ clarifiers: record.clarifiers,
1262
+ });
1263
+ }
1264
+ function parsePortablePath(path) {
1265
+ if (!path.startsWith('$'))
1266
+ throw new TypeError(`Expected absolute portable AES path: ${path}`);
1267
+ const segments = [];
1268
+ const prefixes = [];
1269
+ let cursor = 1;
1270
+ while (cursor < path.length) {
1271
+ const start = cursor;
1272
+ let type;
1273
+ if (path.startsWith('.@.', cursor)) {
1274
+ type = 'attribute';
1275
+ cursor += 3;
1276
+ }
1277
+ else if (path[cursor] === '.') {
1278
+ type = 'member';
1279
+ cursor += 1;
1280
+ }
1281
+ else if (path[cursor] === '[') {
1282
+ const match = path.slice(cursor).match(/^\[(0|[1-9][0-9]*)\]/u);
1283
+ if (match === null)
1284
+ throw new TypeError(`Invalid portable AES index: ${path}`);
1285
+ cursor += match[0].length;
1286
+ segments.push({ type: 'index', index: Number(match[1]) });
1287
+ prefixes.push(path.slice(0, cursor));
1288
+ continue;
1289
+ }
1290
+ else {
1291
+ throw new TypeError(`Invalid portable AES path: ${path}`);
1292
+ }
1293
+ let key;
1294
+ if (path[cursor] === '[' && path[cursor + 1] === '"') {
1295
+ let end = cursor + 2;
1296
+ let escaped = false;
1297
+ for (; end < path.length; end += 1) {
1298
+ const character = path[end];
1299
+ if (escaped)
1300
+ escaped = false;
1301
+ else if (character === '\\')
1302
+ escaped = true;
1303
+ else if (character === '"')
1304
+ break;
1305
+ }
1306
+ if (path[end] !== '"' || path[end + 1] !== ']')
1307
+ throw new TypeError(`Invalid quoted member: ${path}`);
1308
+ key = JSON.parse(path.slice(cursor + 1, end + 1));
1309
+ cursor = end + 2;
1310
+ }
1311
+ else {
1312
+ const match = path.slice(cursor).match(/^[A-Za-z_][A-Za-z0-9_]*/u);
1313
+ if (match === null)
1314
+ throw new TypeError(`Invalid portable AES member: ${path}`);
1315
+ key = match[0];
1316
+ cursor += key.length;
1317
+ }
1318
+ segments.push({ type, key });
1319
+ prefixes.push(path.slice(0, cursor));
1320
+ if (cursor === start)
1321
+ throw new TypeError(`Invalid portable AES path: ${path}`);
1322
+ }
1323
+ return { segments, prefixes };
1324
+ }
1325
+ function parsePortableReferencePath(path) {
1326
+ return parsePortablePath(path).segments.map((segment) => {
1327
+ if (segment.type === 'index')
1328
+ return segment.index;
1329
+ if (segment.type === 'attribute')
1330
+ return { type: 'attr', key: segment.key };
1331
+ return segment.key;
1332
+ });
1333
+ }
1334
+ function hydratePortableAttributes(eventsByPath, aes) {
1335
+ const attributes = aes
1336
+ .filter(isPortableTelexRecord)
1337
+ .map((record) => {
1338
+ try {
1339
+ return { record, details: parsePortablePath(record.path) };
1340
+ }
1341
+ catch {
1342
+ return null;
1343
+ }
1344
+ })
1345
+ .filter((entry) => entry !== null)
1346
+ .filter(({ details }) => details.segments.at(-1)?.type === 'attribute')
1347
+ .sort((a, b) => a.details.segments.length - b.details.segments.length);
1348
+ for (const { record, details } of attributes) {
1349
+ const final = details.segments.at(-1);
1350
+ if (final?.type !== 'attribute')
1351
+ continue;
1352
+ const ownerPath = details.prefixes.at(-2) ?? '$';
1353
+ const owner = eventsByPath.get(ownerPath);
1354
+ const attribute = eventsByPath.get(record.path);
1355
+ if (owner === undefined || attribute === undefined)
1356
+ continue;
1357
+ const mapped = new Map(owner.attributes ?? []);
1358
+ mapped.set(final.key, attribute);
1359
+ owner.attributes = mapped;
1360
+ }
1361
+ }
1362
+ function hydratePortableContainerArities(aes, containerArity, resourcePolicy, ctx, toTuple) {
1363
+ const records = aes.filter(isPortableTelexRecord);
1364
+ if (records.length === 0)
1365
+ return;
1366
+ const details = new Map();
1367
+ for (const record of records) {
1368
+ try {
1369
+ details.set(record.path, parsePortablePath(record.path));
1370
+ }
1371
+ catch {
1372
+ // The portable AES validation layer reports malformed paths.
1373
+ }
1374
+ }
1375
+ for (const record of records) {
1376
+ if (!['ObjectNode', 'ListNode', 'TupleLiteral', 'NodeLiteral', 'NodeHead'].includes(record.kind))
1377
+ continue;
1378
+ const childOwner = record.kind === 'NodeLiteral' ? `${record.path}[0]` : record.path;
1379
+ let count = 0;
1380
+ for (const candidate of records) {
1381
+ const candidateDetails = details.get(candidate.path);
1382
+ if (candidateDetails === undefined)
1383
+ continue;
1384
+ const parent = candidateDetails.prefixes.at(-2) ?? '$';
1385
+ const last = candidateDetails.segments.at(-1);
1386
+ if (parent !== childOwner || last === undefined)
1387
+ continue;
1388
+ if (record.kind === 'ObjectNode' ? last.type === 'member' : last.type === 'index')
1389
+ count += 1;
1390
+ }
1391
+ containerArity.set(record.path, count);
1392
+ if (count > resourcePolicy.max_container_children_default) {
1393
+ emitResourceError(ctx, record.path, `Container child count ${count} exceeds max_container_children_default ${resourcePolicy.max_container_children_default}`, toTuple(record.span));
1394
+ }
1395
+ }
1396
+ }
1194
1397
  function formatCanonicalPathLocal(path) {
1398
+ if (typeof path === 'string')
1399
+ return path;
1195
1400
  if (!path || !Array.isArray(path.segments))
1196
1401
  return '$';
1197
1402
  let result = '';
@@ -1220,6 +1425,10 @@ function formatCanonicalPathLocal(path) {
1220
1425
  function toTupleLocal(span) {
1221
1426
  if (!span)
1222
1427
  return null;
1428
+ if (typeof span === 'string') {
1429
+ const match = span.match(/^(0|[1-9][0-9]*):(0|[1-9][0-9]*)$/u);
1430
+ return match === null ? null : [Number(match[1]), Number(match[2])];
1431
+ }
1223
1432
  if (Array.isArray(span) && span.length === 2 && typeof span[0] === 'number')
1224
1433
  return span;
1225
1434
  if (span.start && span.end && typeof span.start.offset === 'number')