@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/LICENSE +21 -0
- package/dist/diag/codes.d.ts +4 -0
- package/dist/diag/codes.d.ts.map +1 -1
- package/dist/diag/codes.js +4 -0
- package/dist/diag/codes.js.map +1 -1
- package/dist/rules/numericForm.d.ts.map +1 -1
- package/dist/rules/numericForm.js +88 -18
- package/dist/rules/numericForm.js.map +1 -1
- package/dist/rules/presence.d.ts.map +1 -1
- package/dist/rules/presence.js +2 -0
- package/dist/rules/presence.js.map +1 -1
- package/dist/rules/schemaIndex.d.ts.map +1 -1
- package/dist/rules/schemaIndex.js +49 -7
- package/dist/rules/schemaIndex.js.map +1 -1
- package/dist/rules/typeCheck.d.ts.map +1 -1
- package/dist/rules/typeCheck.js +22 -2
- package/dist/rules/typeCheck.js.map +1 -1
- package/dist/types/schema.d.ts +26 -2
- package/dist/types/schema.d.ts.map +1 -1
- package/dist/types/schema.js +10 -0
- package/dist/types/schema.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +488 -32
- package/dist/validate.js.map +1 -1
- package/package.json +28 -3
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(
|
|
260
|
+
checkPresence(effectiveRuleIndex, boundPaths, ctx);
|
|
246
261
|
checkWorldPolicy(schema, aes, boundPaths, ctx);
|
|
247
262
|
// Phase 5: Type checks (literal kind)
|
|
248
|
-
checkReferenceForms(schema,
|
|
249
|
-
const effectiveEventsByPath = resolveReferenceFormEvents(
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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, `
|
|
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(
|
|
306
|
+
checkNumericForm(selectedRuleIndex, effectiveEventsByPath, ctx);
|
|
266
307
|
// Phase 7: String form constraints (length, pattern)
|
|
267
|
-
checkStringForm(
|
|
268
|
-
checkPatterns(
|
|
269
|
-
|
|
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
|
|
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 (
|
|
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
|
|
438
|
-
if (!
|
|
439
|
-
emitError(ctx, createDiag(path, event.span, `Datatype rule violation for ':${event.datatype}':
|
|
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
|
-
|
|
443
|
-
|
|
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 &&
|
|
448
|
-
emitError(ctx, createDiag(path, event.span, `Datatype rule violation for ':${event.datatype}': expected value <= ${constraints.max_value}, got ${
|
|
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
|
|
515
|
-
const digitCount =
|
|
516
|
-
if (effectiveConstraints.sign === 'unsigned' &&
|
|
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
|
|
588
|
-
|
|
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
|
|
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 '$';
|