@altopelago/aeos-core 0.9.0 → 0.9.1

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
@@ -22,6 +22,17 @@ const TYPE_ALIASES = {
22
22
  ListNode: ['ListNode'],
23
23
  ListLiteral: ['ListNode', 'ListLiteral'],
24
24
  TupleLiteral: ['TupleLiteral'],
25
+ ToggleLiteral: ['ToggleLiteral'],
26
+ InfinityLiteral: ['InfinityLiteral'],
27
+ NaNLiteral: ['NaNLiteral'],
28
+ HexLiteral: ['HexLiteral'],
29
+ RadixLiteral: ['RadixLiteral'],
30
+ EncodingLiteral: ['EncodingLiteral'],
31
+ SeparatorLiteral: ['SeparatorLiteral'],
32
+ DateLiteral: ['DateLiteral'],
33
+ TimeLiteral: ['TimeLiteral'],
34
+ DateTimeLiteral: ['DateTimeLiteral'],
35
+ ZRUTDateTimeLiteral: ['ZRUTDateTimeLiteral'],
25
36
  CloneReference: ['CloneReference'],
26
37
  PointerReference: ['PointerReference'],
27
38
  NodeLiteral: ['NodeLiteral'],
@@ -208,6 +219,9 @@ export function validate(aes, schema, options = {}) {
208
219
  containerArity.set(pathStr, event.value.elements.length);
209
220
  hydrateIndexedFallback(pathStr, event.value, toTuple(event.span));
210
221
  }
222
+ else if (event.value.type === 'ObjectNode' && Array.isArray(event.value.bindings)) {
223
+ containerArity.set(pathStr, event.value.bindings.length);
224
+ }
211
225
  else if (event.value.type === 'NodeLiteral' && Array.isArray(event.value.children)) {
212
226
  containerArity.set(pathStr, event.value.children.length);
213
227
  hydrateIndexedFallback(pathStr, event.value, toTuple(event.span));
@@ -240,33 +254,61 @@ export function validate(aes, schema, options = {}) {
240
254
  }
241
255
  // Phase 3: Build rule index from schema (run after baseline invariants)
242
256
  const ruleIndex = buildRuleIndex(schema, ctx);
257
+ const effectiveRuleIndex = expandWildcardRules(expandSelectorRules(ruleIndex, schema, eventsByPath, ctx), eventsByPath);
243
258
  // Phase 4: Presence checks (required fields)
244
259
  const boundPaths = new Set(seen.keys());
245
- checkPresence(ruleIndex, boundPaths, ctx);
260
+ checkPresence(effectiveRuleIndex, boundPaths, ctx);
246
261
  checkWorldPolicy(schema, aes, boundPaths, ctx);
247
262
  // Phase 5: Type checks (literal kind)
248
- checkReferenceForms(schema, ruleIndex, eventsByPath, ctx);
249
- const effectiveEventsByPath = resolveReferenceFormEvents(ruleIndex, eventsByPath);
250
- checkTypes(ruleIndex, effectiveEventsByPath, ctx);
251
- // Phase 5b: core v1 arity checks for tuple/list containers
252
- for (const [path, rule] of ruleIndex) {
253
- const expectedLength = rule.constraints.length_exact;
254
- if (expectedLength === undefined)
263
+ checkReferenceForms(schema, effectiveRuleIndex, eventsByPath, ctx);
264
+ const effectiveEventsByPath = resolveReferenceFormEvents(effectiveRuleIndex, eventsByPath);
265
+ const selectedRuleIndex = selectAnyOfRules(effectiveRuleIndex, effectiveEventsByPath, ctx);
266
+ checkTypes(selectedRuleIndex, effectiveEventsByPath, ctx);
267
+ // Phase 5b: core v1 arity/cardinality checks for tuple/list/node containers
268
+ for (const [path, rule] of selectedRuleIndex) {
269
+ const { length_exact, min_children, max_children } = rule.constraints;
270
+ if (length_exact === undefined && min_children === undefined && max_children === undefined)
255
271
  continue;
256
272
  const actualLength = containerArity.get(path);
257
273
  if (actualLength === undefined)
258
274
  continue;
259
- if (typeof expectedLength === 'number' && actualLength !== expectedLength) {
275
+ const span = eventsByPath.get(path)?.span ?? null;
276
+ if (typeof length_exact === 'number' && actualLength !== length_exact) {
277
+ emitError(ctx, createDiag(path, span, `Container cardinality mismatch: expected exactly ${length_exact} children, got ${actualLength}`, ErrorCodes.TUPLE_ARITY_MISMATCH));
278
+ }
279
+ if (typeof min_children === 'number' && actualLength < min_children) {
280
+ emitError(ctx, createDiag(path, span, `Container cardinality mismatch: expected at least ${min_children} children, got ${actualLength}`, ErrorCodes.CONTAINER_CARDINALITY_MISMATCH));
281
+ }
282
+ if (typeof max_children === 'number' && actualLength > max_children) {
283
+ emitError(ctx, createDiag(path, span, `Container cardinality mismatch: expected at most ${max_children} children, got ${actualLength}`, ErrorCodes.CONTAINER_CARDINALITY_MISMATCH));
284
+ }
285
+ }
286
+ checkLexicalLiteralConstraints(selectedRuleIndex, effectiveEventsByPath, ctx);
287
+ // Phase 5c: constraints that widen NumberLiteral type acceptance to infinity/NaN
288
+ for (const [path, rule] of selectedRuleIndex) {
289
+ const event = effectiveEventsByPath.get(path);
290
+ if (!event)
291
+ continue;
292
+ if (event.type === 'InfinityLiteral' && rule.constraints.allow_infinity !== true) {
293
+ continue;
294
+ }
295
+ if (event.type === 'NaNLiteral' && rule.constraints.allow_nan !== true) {
296
+ continue;
297
+ }
298
+ if ((event.type === 'InfinityLiteral' || event.type === 'NaNLiteral')
299
+ && rule.constraints.type !== undefined
300
+ && !isNumericExpectedType(rule.constraints.type)) {
260
301
  const span = eventsByPath.get(path)?.span ?? null;
261
- emitError(ctx, createDiag(path, span, `Tuple/List arity mismatch: expected ${expectedLength}, got ${actualLength}`, ErrorCodes.TUPLE_ARITY_MISMATCH));
302
+ emitError(ctx, createDiag(path, span, `Type mismatch: expected ${rule.constraints.type}, got ${event.type}`, ErrorCodes.TYPE_MISMATCH));
262
303
  }
263
304
  }
264
305
  // Phase 6: Numeric form constraints (sign, digit count)
265
- checkNumericForm(ruleIndex, effectiveEventsByPath, ctx);
306
+ checkNumericForm(selectedRuleIndex, effectiveEventsByPath, ctx);
266
307
  // Phase 7: String form constraints (length, pattern)
267
- checkStringForm(ruleIndex, effectiveEventsByPath, ctx);
268
- checkPatterns(ruleIndex, effectiveEventsByPath, ctx);
269
- checkAttributeConstraints(ruleIndex, effectiveEventsByPath, schema.datatype_rules, ctx);
308
+ checkStringForm(selectedRuleIndex, effectiveEventsByPath, ctx);
309
+ checkPatterns(selectedRuleIndex, effectiveEventsByPath, ctx);
310
+ checkAttributePolicy(schema, selectedRuleIndex, effectiveEventsByPath, ctx);
311
+ checkAttributeConstraints(selectedRuleIndex, effectiveEventsByPath, schema.datatype_rules, ctx);
270
312
  checkDatatypeRules(schema.datatype_rules, effectiveEventsByPath, ctx);
271
313
  if (ctx.errors.length > 0) {
272
314
  return createFailingEnvelope(ctx.errors, ctx.warnings, {});
@@ -317,7 +359,13 @@ export function validate(aes, schema, options = {}) {
317
359
  function checkWorldPolicy(schema, aes, boundPaths, ctx) {
318
360
  if ((schema.world ?? 'open') !== 'closed')
319
361
  return;
320
- const allowedPaths = schema.rules.map((rule) => rule.path);
362
+ const allowedRules = schema.rules
363
+ .map((rule) => typeof rule.path === 'string' && rule.path.length > 0
364
+ ? { kind: 'path', value: rule.path }
365
+ : typeof rule.selector === 'string' && rule.selector.length > 0
366
+ ? { kind: 'selector', value: rule.selector }
367
+ : null)
368
+ .filter((rule) => rule !== null);
321
369
  for (const event of aes) {
322
370
  const key = typeof event.key === 'string' ? event.key : '';
323
371
  if (key.startsWith('aeon:'))
@@ -325,7 +373,9 @@ function checkWorldPolicy(schema, aes, boundPaths, ctx) {
325
373
  const path = formatCanonicalPathLocal(event.path);
326
374
  if (!boundPaths.has(path))
327
375
  continue;
328
- if (allowedPaths.some((allowedPath) => matchesAllowedPath(path, allowedPath)))
376
+ if (allowedRules.some((rule) => rule.kind === 'selector'
377
+ ? matchesSelectorPath(path, rule.value)
378
+ : matchesAllowedPath(path, rule.value)))
329
379
  continue;
330
380
  emitError(ctx, createDiag(path, toTupleLocal(event.span), `Binding '${path}' is not allowed by closed-world schema`, ErrorCodes.UNEXPECTED_BINDING));
331
381
  }
@@ -350,6 +400,114 @@ function resolveReferenceFormEvents(ruleIndex, eventsByPath) {
350
400
  }
351
401
  return resolved;
352
402
  }
403
+ function expandSelectorRules(ruleIndex, schema, eventsByPath, ctx) {
404
+ const expanded = new Map(ruleIndex);
405
+ for (const rule of schema.rules) {
406
+ if (typeof rule.selector !== 'string' || rule.selector.length === 0)
407
+ continue;
408
+ if (typeof rule.path === 'string' && rule.path.length > 0)
409
+ continue;
410
+ let matched = false;
411
+ for (const actualPath of eventsByPath.keys()) {
412
+ if (!matchesSelectorPath(actualPath, rule.selector))
413
+ continue;
414
+ matched = true;
415
+ if (!expanded.has(actualPath)) {
416
+ expanded.set(actualPath, { ...rule, path: actualPath });
417
+ }
418
+ }
419
+ if (!matched && rule.constraints.required === true) {
420
+ emitError(ctx, createDiag(rule.selector, null, `Missing required field: ${rule.selector}`, ErrorCodes.MISSING_REQUIRED_FIELD));
421
+ }
422
+ }
423
+ return expanded;
424
+ }
425
+ function expandWildcardRules(ruleIndex, eventsByPath) {
426
+ const expanded = new Map(ruleIndex);
427
+ for (const [path, rule] of ruleIndex.entries()) {
428
+ if (!path.includes('[*]'))
429
+ continue;
430
+ expanded.delete(path);
431
+ for (const actualPath of eventsByPath.keys()) {
432
+ if (matchesAllowedPath(actualPath, path)) {
433
+ expanded.set(actualPath, { ...rule, path: actualPath });
434
+ }
435
+ }
436
+ }
437
+ return expanded;
438
+ }
439
+ function selectAnyOfRules(ruleIndex, eventsByPath, ctx) {
440
+ const selected = new Map(ruleIndex);
441
+ for (const [path, rule] of ruleIndex.entries()) {
442
+ if (!Array.isArray(rule.constraints.any_of))
443
+ continue;
444
+ const event = eventsByPath.get(path);
445
+ if (!event)
446
+ continue;
447
+ const outer = withoutAnyOf(rule.constraints);
448
+ const branch = rule.constraints.any_of.find((candidate) => constraintBranchMatchesEvent(candidate, event));
449
+ if (!branch) {
450
+ emitError(ctx, createDiag(path, event.span, `Value does not match any allowed constraint branch at ${path}`, ErrorCodes.TYPE_MISMATCH));
451
+ selected.set(path, { ...rule, constraints: outer });
452
+ continue;
453
+ }
454
+ selected.set(path, { ...rule, constraints: { ...outer, ...branch } });
455
+ }
456
+ return selected;
457
+ }
458
+ function withoutAnyOf(constraints) {
459
+ const { any_of: _anyOf, ...rest } = constraints;
460
+ return rest;
461
+ }
462
+ function constraintBranchMatchesEvent(constraints, event) {
463
+ if (constraints.type_is !== undefined) {
464
+ const containerOk = constraints.type_is === 'list'
465
+ ? (event.type === 'ListLiteral' || event.type === 'ListNode')
466
+ : event.type === 'TupleLiteral';
467
+ if (!containerOk)
468
+ return false;
469
+ }
470
+ if (constraints.type !== undefined && !constraintTypeMatches(event.type, constraints.type, event.raw, constraints)) {
471
+ return false;
472
+ }
473
+ if (constraints.datatype !== undefined && event.datatype !== constraints.datatype) {
474
+ return false;
475
+ }
476
+ if (event.type === 'NullLiteral' && !nullValueMatches(event.value, constraints)) {
477
+ return false;
478
+ }
479
+ if (event.type === 'ToggleLiteral' && constraints.toggle_pair !== undefined && constraints.toggle_pair !== 'any') {
480
+ const value = (event.raw || event.value).toLowerCase();
481
+ const allowed = constraints.toggle_pair === 'yes_no'
482
+ ? ['yes', 'no']
483
+ : constraints.toggle_pair === 'on_off'
484
+ ? ['on', 'off']
485
+ : [];
486
+ if (allowed.length > 0 && !allowed.includes(value))
487
+ return false;
488
+ }
489
+ if (isStringType(event.type)) {
490
+ const valueLength = event.value.length;
491
+ if (constraints.min_length !== undefined && valueLength < constraints.min_length)
492
+ return false;
493
+ if (constraints.max_length !== undefined && valueLength > constraints.max_length)
494
+ return false;
495
+ if (constraints.pattern !== undefined && !(new RegExp(constraints.pattern).test(event.value)))
496
+ return false;
497
+ }
498
+ if (hasDigitFormConstraints(constraints) && isDigitFormLiteral(event.type)) {
499
+ const digitCount = countFormDigits(event.type, event.raw);
500
+ if (constraints.sign === 'unsigned' && isFormNegative(event.raw))
501
+ return false;
502
+ if (constraints.min_digits !== undefined && digitCount < constraints.min_digits)
503
+ return false;
504
+ if (constraints.max_digits !== undefined && digitCount > constraints.max_digits)
505
+ return false;
506
+ if (event.type === 'RadixLiteral' && constraints.radix !== undefined && firstInvalidRadixDigit(event.raw, constraints.radix) !== null)
507
+ return false;
508
+ }
509
+ return true;
510
+ }
353
511
  function resolveTerminalReferenceEvent(event, eventsByPath, activePaths) {
354
512
  if (!isReferenceType(event.type) || !event.referencePath) {
355
513
  return event;
@@ -404,6 +562,179 @@ function matchesAllowedPath(actualPath, allowedPath) {
404
562
  const pattern = `^${escaped}$`;
405
563
  return new RegExp(pattern).test(actualPath);
406
564
  }
565
+ function tokenizeCanonicalLikePath(path) {
566
+ if (!path.startsWith('$'))
567
+ return null;
568
+ const segments = [];
569
+ let index = 1;
570
+ while (index < path.length) {
571
+ const marker = path[index];
572
+ if (marker === '.') {
573
+ index += 1;
574
+ if (path[index] === '[') {
575
+ const end = findBracketEnd(path, index);
576
+ if (end < 0)
577
+ return null;
578
+ segments.push(path.slice(index, end + 1));
579
+ index = end + 1;
580
+ continue;
581
+ }
582
+ const start = index;
583
+ while (index < path.length && !['.', '[', '@'].includes(path[index])) {
584
+ index += 1;
585
+ }
586
+ if (start === index)
587
+ return null;
588
+ segments.push(path.slice(start, index));
589
+ continue;
590
+ }
591
+ if (marker === '[') {
592
+ const end = findBracketEnd(path, index);
593
+ if (end < 0)
594
+ return null;
595
+ segments.push(path.slice(index, end + 1));
596
+ index = end + 1;
597
+ continue;
598
+ }
599
+ if (marker === '@') {
600
+ index += 1;
601
+ if (path[index] === '[') {
602
+ const end = findBracketEnd(path, index);
603
+ if (end < 0)
604
+ return null;
605
+ segments.push(`@${path.slice(index, end + 1)}`);
606
+ index = end + 1;
607
+ continue;
608
+ }
609
+ const start = index;
610
+ while (index < path.length && !['.', '[', '@'].includes(path[index])) {
611
+ index += 1;
612
+ }
613
+ if (start === index)
614
+ return null;
615
+ segments.push(`@${path.slice(start, index)}`);
616
+ continue;
617
+ }
618
+ return null;
619
+ }
620
+ return segments;
621
+ }
622
+ function findBracketEnd(path, start) {
623
+ let quote = null;
624
+ let escaped = false;
625
+ for (let index = start + 1; index < path.length; index++) {
626
+ const ch = path[index];
627
+ if (escaped) {
628
+ escaped = false;
629
+ continue;
630
+ }
631
+ if (quote) {
632
+ if (ch === '\\') {
633
+ escaped = true;
634
+ }
635
+ else if (ch === quote) {
636
+ quote = null;
637
+ }
638
+ continue;
639
+ }
640
+ if (ch === '"' || ch === "'") {
641
+ quote = ch;
642
+ continue;
643
+ }
644
+ if (ch === ']')
645
+ return index;
646
+ }
647
+ return -1;
648
+ }
649
+ function matchesSelectorPath(actualPath, selector) {
650
+ if (actualPath === selector)
651
+ return true;
652
+ const actualSegments = tokenizeCanonicalLikePath(actualPath);
653
+ const selectorSegments = tokenizeCanonicalLikePath(selector);
654
+ if (!actualSegments || !selectorSegments)
655
+ return false;
656
+ const matchFrom = (actualIndex, selectorIndex) => {
657
+ if (selectorIndex === selectorSegments.length) {
658
+ return actualIndex === actualSegments.length;
659
+ }
660
+ const selectorSegment = selectorSegments[selectorIndex];
661
+ if (selectorSegment === '**') {
662
+ if (selectorIndex === selectorSegments.length - 1)
663
+ return true;
664
+ for (let nextActual = actualIndex; nextActual <= actualSegments.length; nextActual++) {
665
+ if (matchFrom(nextActual, selectorIndex + 1))
666
+ return true;
667
+ }
668
+ return false;
669
+ }
670
+ if (actualIndex >= actualSegments.length)
671
+ return false;
672
+ if (selectorSegment === '*') {
673
+ return matchFrom(actualIndex + 1, selectorIndex + 1);
674
+ }
675
+ if (selectorSegment === '[*]') {
676
+ return /^\[\d+\]$/.test(actualSegments[actualIndex])
677
+ && matchFrom(actualIndex + 1, selectorIndex + 1);
678
+ }
679
+ return selectorSegment === actualSegments[actualIndex]
680
+ && matchFrom(actualIndex + 1, selectorIndex + 1);
681
+ };
682
+ return matchFrom(0, 0);
683
+ }
684
+ function collectAllowedAttributePaths(ruleIndex) {
685
+ const allowed = [];
686
+ function visit(basePath, constraints) {
687
+ if (basePath.includes('@')) {
688
+ allowed.push(basePath);
689
+ }
690
+ const attributes = constraints.attributes;
691
+ if (!attributes)
692
+ return;
693
+ for (const [key, childConstraints] of Object.entries(attributes)) {
694
+ visit(`${basePath}@${key}`, childConstraints);
695
+ }
696
+ }
697
+ for (const [path, rule] of ruleIndex) {
698
+ visit(path, rule.constraints);
699
+ }
700
+ return allowed;
701
+ }
702
+ function collectAttributeEntries(eventsByPath) {
703
+ const entries = [];
704
+ function visit(basePath, attributes) {
705
+ if (!attributes)
706
+ return;
707
+ for (const [key, entry] of attributes.entries()) {
708
+ const path = `${basePath}@${key}`;
709
+ entries.push({ path, span: entry.span });
710
+ visit(path, entry.attributes);
711
+ }
712
+ }
713
+ for (const [path, event] of eventsByPath) {
714
+ visit(path, event.attributes);
715
+ }
716
+ return entries;
717
+ }
718
+ function checkAttributePolicy(schema, ruleIndex, eventsByPath, ctx) {
719
+ const policy = schema.attribute_policy ?? 'inherit_world';
720
+ if (policy === 'inherit_world' && (schema.world ?? 'open') !== 'closed')
721
+ return;
722
+ if (policy !== 'inherit_world' && policy !== 'forbid')
723
+ return;
724
+ const attributeEntries = collectAttributeEntries(eventsByPath);
725
+ if (attributeEntries.length === 0)
726
+ return;
727
+ const allowedPaths = policy === 'inherit_world'
728
+ ? collectAllowedAttributePaths(ruleIndex)
729
+ : [];
730
+ for (const entry of attributeEntries) {
731
+ if (allowedPaths.some((allowedPath) => matchesAllowedPath(entry.path, allowedPath)))
732
+ continue;
733
+ emitError(ctx, createDiag(entry.path, entry.span, policy === 'forbid'
734
+ ? `Attribute '${entry.path}' is forbidden by schema attribute_policy`
735
+ : `Attribute '${entry.path}' is not allowed by closed-world schema`, ErrorCodes.UNEXPECTED_ATTRIBUTE_ENTRY));
736
+ }
737
+ }
407
738
  function checkDatatypeRules(datatypeRules, eventsByPath, ctx) {
408
739
  if (!datatypeRules)
409
740
  return;
@@ -434,18 +765,17 @@ function checkDatatypeRules(datatypeRules, eventsByPath, ctx) {
434
765
  continue;
435
766
  }
436
767
  if (constraints.min_value !== undefined || constraints.max_value !== undefined) {
437
- const normalized = normalizeIntegerLiteral(raw);
438
- if (!normalized) {
439
- emitError(ctx, createDiag(path, event.span, `Datatype rule violation for ':${event.datatype}': exact integer range requires integer literal form`, ErrorCodes.NUMERIC_FORM_VIOLATION));
768
+ const range = normalizeRangeLiteral(event.type, raw);
769
+ if (!range) {
770
+ emitError(ctx, createDiag(path, event.span, `Datatype rule violation for ':${event.datatype}': range constraints require numeric literal form`, ErrorCodes.NUMERIC_FORM_VIOLATION));
440
771
  continue;
441
772
  }
442
- const numeric = BigInt(normalized);
443
- if (constraints.min_value !== undefined && numeric < BigInt(constraints.min_value)) {
444
- emitError(ctx, createDiag(path, event.span, `Datatype rule violation for ':${event.datatype}': expected value >= ${constraints.min_value}, got ${normalized}`, ErrorCodes.NUMERIC_FORM_VIOLATION));
773
+ if (constraints.min_value !== undefined && isBelowRange(range, constraints.min_value)) {
774
+ emitError(ctx, createDiag(path, event.span, `Datatype rule violation for ':${event.datatype}': expected value >= ${constraints.min_value}, got ${range.raw}`, ErrorCodes.NUMERIC_FORM_VIOLATION));
445
775
  continue;
446
776
  }
447
- if (constraints.max_value !== undefined && numeric > BigInt(constraints.max_value)) {
448
- emitError(ctx, createDiag(path, event.span, `Datatype rule violation for ':${event.datatype}': expected value <= ${constraints.max_value}, got ${normalized}`, ErrorCodes.NUMERIC_FORM_VIOLATION));
777
+ if (constraints.max_value !== undefined && isAboveRange(range, constraints.max_value)) {
778
+ emitError(ctx, createDiag(path, event.span, `Datatype rule violation for ':${event.datatype}': expected value <= ${constraints.max_value}, got ${range.raw}`, ErrorCodes.NUMERIC_FORM_VIOLATION));
449
779
  }
450
780
  }
451
781
  }
@@ -493,9 +823,10 @@ function validateAttributeEntry(path, entry, constraints, datatypeRules, ctx) {
493
823
  emitError(ctx, createDiag(path, entry.span, `Container kind mismatch: expected ${effectiveConstraints.type_is}, got ${entry.type}`, ErrorCodes.WRONG_CONTAINER_KIND));
494
824
  }
495
825
  }
496
- if (effectiveConstraints.type !== undefined && !constraintTypeMatches(entry.type, effectiveConstraints.type, entry.raw)) {
826
+ if (effectiveConstraints.type !== undefined && !constraintTypeMatches(entry.type, effectiveConstraints.type, entry.raw, effectiveConstraints)) {
497
827
  emitError(ctx, createDiag(path, entry.span, `Type mismatch: expected ${effectiveConstraints.type}, got ${entry.type}`, ErrorCodes.TYPE_MISMATCH));
498
828
  }
829
+ checkLexicalLiteralConstraint(path, entry, effectiveConstraints, ctx);
499
830
  if (effectiveConstraints.datatype !== undefined && entry.datatype !== effectiveConstraints.datatype) {
500
831
  emitError(ctx, createDiag(path, entry.span, `Datatype mismatch: expected ${effectiveConstraints.datatype}, got ${entry.datatype ?? '<none>'}`, ErrorCodes.TYPE_MISMATCH));
501
832
  }
@@ -511,9 +842,9 @@ function validateAttributeEntry(path, entry, constraints, datatypeRules, ctx) {
511
842
  emitError(ctx, createDiag(path, entry.span, `Reference kind mismatch at ${path}: expected ${expectedType}, got ${entry.type}`, ErrorCodes.REFERENCE_KIND_MISMATCH));
512
843
  }
513
844
  }
514
- if (entry.type === 'NumberLiteral') {
515
- const digitCount = countIntegerDigits(entry.raw);
516
- if (effectiveConstraints.sign === 'unsigned' && isNegative(entry.raw)) {
845
+ if (hasDigitFormConstraints(effectiveConstraints) && isDigitFormLiteral(entry.type)) {
846
+ const digitCount = countFormDigits(entry.type, entry.raw);
847
+ if ((entry.type === 'NumberLiteral' || entry.type === 'RadixLiteral') && effectiveConstraints.sign === 'unsigned' && isFormNegative(entry.raw)) {
517
848
  emitError(ctx, createDiag(path, entry.span, `Numeric form violation: expected unsigned, got negative`, ErrorCodes.NUMERIC_FORM_VIOLATION));
518
849
  }
519
850
  if (effectiveConstraints.min_digits !== undefined && digitCount < effectiveConstraints.min_digits) {
@@ -522,6 +853,12 @@ function validateAttributeEntry(path, entry, constraints, datatypeRules, ctx) {
522
853
  if (effectiveConstraints.max_digits !== undefined && digitCount > effectiveConstraints.max_digits) {
523
854
  emitError(ctx, createDiag(path, entry.span, `Numeric form violation: expected max ${effectiveConstraints.max_digits} digits, got ${digitCount}`, ErrorCodes.NUMERIC_FORM_VIOLATION));
524
855
  }
856
+ if (entry.type === 'RadixLiteral' && effectiveConstraints.radix !== undefined) {
857
+ const invalidDigit = firstInvalidRadixDigit(entry.raw, effectiveConstraints.radix);
858
+ if (invalidDigit !== null) {
859
+ emitError(ctx, createDiag(path, entry.span, `Numeric form violation: radix literal digit '${invalidDigit}' is outside radix ${effectiveConstraints.radix}`, ErrorCodes.NUMERIC_FORM_VIOLATION));
860
+ }
861
+ }
525
862
  }
526
863
  if (entry.type === 'StringLiteral') {
527
864
  if (effectiveConstraints.min_length !== undefined && entry.value.length < effectiveConstraints.min_length) {
@@ -554,7 +891,13 @@ function mergeDatatypeRuleConstraints(constraints, datatype, datatypeRules) {
554
891
  return constraints;
555
892
  return { ...datatypeRule, ...constraints };
556
893
  }
557
- function constraintTypeMatches(actualType, expectedType, raw) {
894
+ function constraintTypeMatches(actualType, expectedType, raw, constraints) {
895
+ if (constraints?.nullable === true && actualType === 'NullLiteral')
896
+ return true;
897
+ if (constraints?.allow_infinity === true && actualType === 'InfinityLiteral' && isNumericExpectedType(expectedType))
898
+ return true;
899
+ if (constraints?.allow_nan === true && actualType === 'NaNLiteral' && isNumericExpectedType(expectedType))
900
+ return true;
558
901
  if (actualType === expectedType)
559
902
  return true;
560
903
  if (actualType === 'NumberLiteral') {
@@ -566,6 +909,49 @@ function constraintTypeMatches(actualType, expectedType, raw) {
566
909
  const satisfies = TYPE_ALIASES[actualType];
567
910
  return Boolean(satisfies?.includes(expectedType));
568
911
  }
912
+ function isNumericExpectedType(expectedType) {
913
+ return expectedType === 'NumberLiteral' || expectedType === 'IntegerLiteral' || expectedType === 'FloatLiteral';
914
+ }
915
+ function checkLexicalLiteralConstraints(ruleIndex, events, ctx) {
916
+ for (const [path, rule] of ruleIndex) {
917
+ const event = events.get(path);
918
+ if (!event)
919
+ continue;
920
+ checkLexicalLiteralConstraint(path, event, rule.constraints, ctx);
921
+ }
922
+ }
923
+ function checkLexicalLiteralConstraint(path, event, constraints, ctx) {
924
+ if (event.type === 'NullLiteral' && !nullValueMatches(event.value, constraints)) {
925
+ emitError(ctx, createDiag(path, event.span, `Null value mismatch: expected ${formatExpectedNullValues(constraints)}, got ${event.value || '<none>'}`, ErrorCodes.NULL_VALUE_MISMATCH));
926
+ }
927
+ if (event.type === 'ToggleLiteral' && constraints.toggle_pair !== undefined && constraints.toggle_pair !== 'any') {
928
+ const value = (event.raw || event.value).toLowerCase();
929
+ const allowed = constraints.toggle_pair === 'yes_no'
930
+ ? ['yes', 'no']
931
+ : constraints.toggle_pair === 'on_off'
932
+ ? ['on', 'off']
933
+ : [];
934
+ if (allowed.length > 0 && !allowed.includes(value)) {
935
+ emitError(ctx, createDiag(path, event.span, `Toggle pair mismatch: expected ${constraints.toggle_pair}, got ${value || '<none>'}`, ErrorCodes.TOGGLE_PAIR_MISMATCH));
936
+ }
937
+ }
938
+ }
939
+ function nullValueMatches(value, constraints) {
940
+ const expected = expectedNullValues(constraints);
941
+ return expected.length === 0 || expected.includes(value);
942
+ }
943
+ function expectedNullValues(constraints) {
944
+ const values = [];
945
+ if (constraints.null_value !== undefined)
946
+ values.push(constraints.null_value);
947
+ if (constraints.null_values !== undefined)
948
+ values.push(...constraints.null_values);
949
+ return values;
950
+ }
951
+ function formatExpectedNullValues(constraints) {
952
+ const values = expectedNullValues(constraints);
953
+ return values.length > 0 ? values.join(' | ') : '<any>';
954
+ }
569
955
  function isReferenceType(type) {
570
956
  return type === 'CloneReference' || type === 'PointerReference';
571
957
  }
@@ -584,17 +970,87 @@ function datatypeTypeMatches(actualType, expectedType, raw) {
584
970
  return true;
585
971
  return false;
586
972
  }
587
- function normalizeIntegerLiteral(raw) {
588
- if (!/^[+-]?\d[\d_]*$/.test(raw))
973
+ function normalizeRangeLiteral(type, raw) {
974
+ const normalized = raw.replace(/_/g, '');
975
+ if (type === 'FloatLiteral' || /[.eE]/.test(normalized)) {
976
+ if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(normalized))
977
+ return null;
978
+ const value = Number(normalized);
979
+ return Number.isFinite(value) ? { kind: 'float', raw: normalized, value } : null;
980
+ }
981
+ if (!/^[+-]?\d+$/.test(normalized))
589
982
  return null;
590
- return raw.replace(/_/g, '');
983
+ return { kind: 'integer', raw: normalized, value: BigInt(normalized) };
984
+ }
985
+ function isBelowRange(range, bound) {
986
+ if (range.kind === 'integer' && /^[-+]?\d+$/.test(bound)) {
987
+ return range.value < BigInt(bound);
988
+ }
989
+ return rangeAsNumber(range) < Number(bound);
990
+ }
991
+ function isAboveRange(range, bound) {
992
+ if (range.kind === 'integer' && /^[-+]?\d+$/.test(bound)) {
993
+ return range.value > BigInt(bound);
994
+ }
995
+ return rangeAsNumber(range) > Number(bound);
996
+ }
997
+ function rangeAsNumber(range) {
998
+ return range.kind === 'integer' ? Number(range.value) : range.value;
591
999
  }
592
1000
  function countIntegerDigits(raw) {
593
1001
  return raw.replace(/^[+-]/, '').replace(/_/g, '').split('.')[0]?.length ?? 0;
594
1002
  }
1003
+ function hasDigitFormConstraints(constraints) {
1004
+ return constraints.sign !== undefined || constraints.min_digits !== undefined || constraints.max_digits !== undefined || constraints.radix !== undefined;
1005
+ }
1006
+ function isDigitFormLiteral(type) {
1007
+ return type === 'NumberLiteral' || type === 'HexLiteral' || type === 'RadixLiteral' || type === 'SeparatorLiteral';
1008
+ }
1009
+ function isStringType(type) {
1010
+ return type === 'StringLiteral' || type === 'TrimtickLiteral';
1011
+ }
1012
+ function countFormDigits(type, raw) {
1013
+ if (type === 'NumberLiteral')
1014
+ return countIntegerDigits(raw);
1015
+ const body = raw
1016
+ .replace(/^[#%^]/, '')
1017
+ .replace(/^[+-]/, '')
1018
+ .replace(/_/g, '');
1019
+ let count = 0;
1020
+ for (const char of body) {
1021
+ if ((char >= '0' && char <= '9') || (type !== 'SeparatorLiteral' && ((char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char === '&' || char === '!'))) {
1022
+ count++;
1023
+ }
1024
+ }
1025
+ return count;
1026
+ }
1027
+ function firstInvalidRadixDigit(raw, radix) {
1028
+ const body = raw.replace(/^%/, '').replace(/^[+-]/, '').replace(/_/g, '');
1029
+ for (const char of body) {
1030
+ const value = radixDigitValue(char);
1031
+ if (value !== null && value >= radix)
1032
+ return char;
1033
+ }
1034
+ return null;
1035
+ }
1036
+ function radixDigitValue(char) {
1037
+ if (char >= '0' && char <= '9')
1038
+ return char.charCodeAt(0) - 48;
1039
+ const lower = char.toLowerCase();
1040
+ if (lower >= 'a' && lower <= 'z')
1041
+ return lower.charCodeAt(0) - 87;
1042
+ if (char === '&')
1043
+ return 36;
1044
+ if (char === '!')
1045
+ return 37;
1046
+ return null;
1047
+ }
595
1048
  function isNegative(raw) {
596
1049
  return raw.startsWith('-');
597
1050
  }
1051
+ function isFormNegative(raw) {
1052
+ return /^[$#%^]?-/.test(raw) || raw.startsWith('-');
1053
+ }
598
1054
  function formatCanonicalPathLocal(path) {
599
1055
  if (!path || !Array.isArray(path.segments))
600
1056
  return '$';