evidence-dsl: extract Evidence DSL v2 compiler from @arbiter/core
The Evidence DSL (ADR-000) is a thin declarative layer that compiles to engine rule types. It has zero runtime coupling to the core engine (DSLCompiler takes an arbiter as a duck-typed argument; the only shared code was the ip-utils helpers, now local). Extracting it into its own package keeps the core artifact free of the DSL surface. - @arbiter/evidence-dsl depends on @arbiter/core (config formats are the compilation target) - deep-path exports for the compiler, parser, generator, validation, and built-in functions (the surface the core's DSL tests consume) - tests moved alongside; generate-parser script + peggy devDep local - CI: test on push, publish on v* tags
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DSLCompiler } from '../src/DSLCompiler.js';
|
||||
|
||||
function createMockArbiter() {
|
||||
const relationConfigs = new Map();
|
||||
return {
|
||||
relationConfigs,
|
||||
setRelationConfig(relation, config) {
|
||||
relationConfigs.set(relation, config);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('Expression Parsing', () => {
|
||||
const arbiter = createMockArbiter();
|
||||
const compiler = new DSLCompiler(arbiter);
|
||||
|
||||
test('Arithmetic operator precedence', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: '1 + 2 * 3',
|
||||
expected: 'Should evaluate as 1 + (2 * 3) = 7',
|
||||
description: 'Multiplication before addition'
|
||||
},
|
||||
{
|
||||
input: '10 - 3 * 2',
|
||||
expected: 'Should evaluate as 10 - (3 * 2) = 4',
|
||||
description: 'Multiplication before subtraction'
|
||||
},
|
||||
{
|
||||
input: '8 / 2 * 4',
|
||||
expected: 'Should evaluate as (8 / 2) * 4 = 16',
|
||||
description: 'Left-associative division and multiplication'
|
||||
},
|
||||
{
|
||||
input: '2 + 3 * 4 - 5',
|
||||
expected: 'Should evaluate as 2 + (3 * 4) - 5 = 9',
|
||||
description: 'Mixed arithmetic with correct precedence'
|
||||
},
|
||||
{
|
||||
input: '(1 + 2) * 3',
|
||||
expected: 'Should evaluate as (1 + 2) * 3 = 9',
|
||||
description: 'Parentheses override precedence'
|
||||
}
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected, description }) => {
|
||||
const dsl = `evidence test() { ${input} }`;
|
||||
const result = compiler.compile(dsl, `test-arithmetic-${Date.now()}`);
|
||||
assert.ok(result.success, `${description} should parse successfully`);
|
||||
});
|
||||
});
|
||||
|
||||
test('Logical operator precedence', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 'true && false || true',
|
||||
expected: 'Should evaluate as (true && false) || true = true',
|
||||
description: 'AND before OR'
|
||||
},
|
||||
{
|
||||
input: 'false || true && false',
|
||||
expected: 'Should evaluate as false || (true && false) = false',
|
||||
description: 'AND before OR (alternative)'
|
||||
},
|
||||
{
|
||||
input: 'NOT true && false',
|
||||
expected: 'Should evaluate as (NOT true) && false = false',
|
||||
description: 'NOT before AND'
|
||||
},
|
||||
{
|
||||
input: 'true && NOT false',
|
||||
expected: 'Should evaluate as true && (NOT false) = true',
|
||||
description: 'NOT before AND (alternative)'
|
||||
},
|
||||
{
|
||||
input: '(true || false) && true',
|
||||
expected: 'Should evaluate as (true || false) && true = true',
|
||||
description: 'Parentheses override logical precedence'
|
||||
}
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected, description }) => {
|
||||
const dsl = `evidence test() { ${input} }`;
|
||||
const result = compiler.compile(dsl, `test-logical-${Date.now()}`);
|
||||
assert.ok(result.success, `${description} should parse successfully`);
|
||||
});
|
||||
});
|
||||
|
||||
test('Comparison operators', () => {
|
||||
const testCases = [
|
||||
{ input: '1 == 1', description: 'Equality comparison' },
|
||||
{ input: '1 != 2', description: 'Inequality comparison' },
|
||||
{ input: '5 > 3', description: 'Greater than' },
|
||||
{ input: '3 < 5', description: 'Less than' },
|
||||
{ input: '4 >= 4', description: 'Greater than or equal' },
|
||||
{ input: '4 <= 4', description: 'Less than or equal' },
|
||||
{ input: '1 == 1 && 2 > 1', description: 'Comparison with logical operators' },
|
||||
{ input: '1 + 2 == 3', description: 'Arithmetic in comparison' }
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, description }) => {
|
||||
const dsl = `evidence test() { ${input} }`;
|
||||
const result = compiler.compile(dsl, `test-comparison-${Date.now()}`);
|
||||
assert.ok(result.success, `${description} should parse successfully`);
|
||||
});
|
||||
});
|
||||
|
||||
test('Temporal expressions', () => {
|
||||
const testCases = [
|
||||
{ input: 'user.lastActive within 1h', description: 'Temporal within expression' },
|
||||
{ input: 'user.lastLogin within 24h', description: 'Temporal within with hours' },
|
||||
{ input: 'user.createdAt within 7d', description: 'Temporal within with days' },
|
||||
{ input: 'user.lastActivity within 1h && user.isActive', description: 'Temporal with logical operators' }
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, description }) => {
|
||||
const dsl = `evidence test() { ${input} }`;
|
||||
const result = compiler.compile(dsl, `test-temporal-${Date.now()}`);
|
||||
assert.ok(result.success, `${description} should parse successfully`);
|
||||
});
|
||||
});
|
||||
|
||||
test('Unary operators', () => {
|
||||
const testCases = [
|
||||
{ input: 'NOT true', description: 'NOT operator' },
|
||||
{ input: '!false', description: 'Alternative NOT operator' },
|
||||
{ input: 'NOT (true && false)', description: 'NOT with parenthesized expression' },
|
||||
{ input: 'NOT user.isSuspended', description: 'NOT with attribute access' }
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, description }) => {
|
||||
const dsl = `evidence test() { ${input} }`;
|
||||
const result = compiler.compile(dsl, `test-unary-${Date.now()}`);
|
||||
assert.ok(result.success, `${description} should parse successfully`);
|
||||
});
|
||||
});
|
||||
|
||||
test('Attribute access', () => {
|
||||
const testCases = [
|
||||
{ input: 'user.role', description: 'Simple attribute access' },
|
||||
{ input: 'user.profile.name', description: 'Nested attribute access' },
|
||||
{ input: 'user.permissions[0]', description: 'Array access' },
|
||||
{ input: 'user.role.permissions[0]', description: 'Nested attribute with array access' },
|
||||
{ input: 'user.isActive && user.role == "admin"', description: 'Attribute access in logical expression' }
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, description }) => {
|
||||
const dsl = `evidence test() { ${input} }`;
|
||||
const result = compiler.compile(dsl, `test-attribute-${Date.now()}`);
|
||||
assert.ok(result.success, `${description} should parse successfully`);
|
||||
});
|
||||
});
|
||||
|
||||
test('Function calls', () => {
|
||||
const testCases = [
|
||||
{ input: 'hasRole(user, "admin")', description: 'Simple function call' },
|
||||
{ input: 'isMember(user, group)', description: 'Function call with variables' },
|
||||
{ input: 'hasPermission(user, resource, "read")', description: 'Function call with multiple arguments' },
|
||||
{ input: 'hasRole(user, "admin") && isActive(user)', description: 'Multiple function calls' },
|
||||
{ input: 'hasRole(user, user.role)', description: 'Function call with attribute access' }
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, description }) => {
|
||||
const dsl = `evidence test() { ${input} }`;
|
||||
const result = compiler.compile(dsl, `test-function-${Date.now()}`);
|
||||
assert.ok(result.success, `${description} should parse successfully`);
|
||||
});
|
||||
});
|
||||
|
||||
test('Complex expressions', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 'user.isActive && (hasRole(user, "admin") || hasPermission(user, resource, "read"))',
|
||||
description: 'Complex logical expression with function calls'
|
||||
},
|
||||
{
|
||||
input: 'user.balance > 100 && user.isActive && NOT user.isSuspended',
|
||||
description: 'Multiple conditions with NOT'
|
||||
},
|
||||
{
|
||||
input: 'user.lastActive within 1h && (user.role == "admin" || user.hasEmergencyAccess)',
|
||||
description: 'Temporal with logical conditions'
|
||||
},
|
||||
{
|
||||
input: 'hasRole(user, "admin") && user.isActive && NOT (user.isSuspended || user.isBlacklisted)',
|
||||
description: 'Complex negation with multiple conditions'
|
||||
},
|
||||
{
|
||||
input: 'user.score > 0.8 && user.isTrusted && user.lastActivity within 24h',
|
||||
description: 'Multiple attribute conditions with temporal'
|
||||
}
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, description }) => {
|
||||
const dsl = `evidence test() { ${input} }`;
|
||||
const result = compiler.compile(dsl, `test-complex-${Date.now()}`);
|
||||
assert.ok(result.success, `${description} should parse successfully`);
|
||||
});
|
||||
});
|
||||
|
||||
test('Expression error handling', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 'user.role ==',
|
||||
description: 'Incomplete comparison should fail'
|
||||
},
|
||||
{
|
||||
input: 'user.role &&',
|
||||
description: 'Incomplete logical expression should fail'
|
||||
},
|
||||
{
|
||||
input: 'hasRole(user,)',
|
||||
description: 'Function call with missing argument should fail'
|
||||
},
|
||||
{
|
||||
input: 'user.role == "admin" &&',
|
||||
description: 'Incomplete logical expression should fail'
|
||||
}
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, description }) => {
|
||||
try {
|
||||
const dsl = `evidence test() { ${input} }`;
|
||||
const result = compiler.compile(dsl, `test-error-${Date.now()}`);
|
||||
assert.ok(!result.success, `${description} should fail to parse`);
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user