/** * rigor/dsl-compiler.test.js — js-rigor property tests for the DSL compiler. * * Validates that ADR-000 Evidence DSL v2 compiles to the documented engine rule * types. ADR-000 §"Mapping to Engine Rule Types" enumerates: * - DirectRule → { type: 'direct' } * - TupleToUsersetRule → { type: 'tuple_to_userset' } * - ParentRule → { type: 'parent' } * - MultiHopRule → { type: 'multi_hop' } * - ChainRule → { type: 'chain' } * - LogicalOperators → { type: 'logical' } * - RelationalComparator → { type: 'relational_comparator' } * * Properties verified per rule type: * - DSL snippet compiles successfully * - Generated rule's `type` matches the ADR-000 mapping for that shape * - Required fields (relation, comparator, never/always/when, etc.) are present * * Bug class targeted: RF-24 — DSL compiler mapping gaps. The original generator * emitted 5 of the 7 ADR-000 rule types; ChainRule and RelationalComparatorRule * were dropped on the floor (silent gap, no test caught it). */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { DSLCompiler } from '@arbiter/evidence-dsl/DSLCompiler'; // Built-in types from lib/src/ast/validation/DSLPrelude.js are reserved (User, Account, // Device, AuthSession). Use non-reserved names so the validator accepts the DSL. const DEFINITIONS = ` definition Person { id: string } definition Document { id: string } definition Group { id: string } definition Dept { id: string } `; const FACTS = ` fact owns(p: Person, d: Document) fact group_owner(g: Group, d: Document) fact member_of(p: Person, g: Group) fact parent_of(p: Document, c: Document) fact canReadInner(p: Person, d: Document) fact friend_of(a: Person, b: Person) fact works_in(p: Person, dept: Dept) fact has_access(dept: Dept, d: Document) fact isSuspended(p: Person) fact personAge(p: Person) fact docMinAge(d: Document) `; /** * Build a fresh mock arbiter for each campaign so generated rules don't leak * between test cases. */ function makeMockArbiter() { const relationConfigs = new Map(); return { relationConfigs, setRelationConfig(relation, config) { relationConfigs.set(relation, config); }, registerDependencyIndex() { /* noop for rigor tests */ } }; } describe('DSLCompiler → engine rule mapping (rigor)', () => { it('DirectRule: simple predicate → type=direct', async () => { async function check(relationName) { const arbiter = makeMockArbiter(); const compiler = new DSLCompiler(arbiter); const dsl = `${DEFINITIONS}${FACTS}\nevidence canRead(p: Person, d: Document) { ${relationName}(p, d) }\n`; const result = compiler.compile(dsl, `direct-${relationName}-${Math.random()}`); if (!result.success) { throw new Error(`compile failed: ${result.errors.join('; ')}`); } const generated = result.generatedRules.get('canRead'); if (!generated) { throw new Error(`no rule generated for 'canRead'`); } if (generated.type !== 'direct') { throw new Error(`expected type='direct' for simple predicate call, got '${generated.type}' (relation=${relationName})`); } if (generated.relation !== relationName) { throw new Error(`expected relation='${relationName}', got '${generated.relation}'`); } return generated; } const report = await rigor.campaign( // Generator draws from declared facts with a (Person, Document) // signature — the DSL validator rejects undeclared predicates. [rigor.fn('check', check, rigor.args(rigor.gen.oneOf(['owns', 'canReadInner'])))], rigor.crucible([ rigor.invariant('direct-emission', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'dsl-compiler-direct', effort: 800 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'direct-emission'); assert.ok(inv); assert.equal(inv.passed, true, `direct emission violated in ${inv.failureCount} cases`); }); it('TupleToUsersetRule: membership predicate → type=tuple_to_userset', async () => { async function check() { const arbiter = makeMockArbiter(); const compiler = new DSLCompiler(arbiter); // ADR-000 shape: outer predicate + inner membership predicate. Use member_of // as OUTER so the existing isMembershipPredicate dispatch routes to TUS. // Inner must be type-valid: group_owner(g: Group, d: Document). const dsl = `${DEFINITIONS}${FACTS}\nevidence canRead(p: Person, d: Document) { member_of(p, *g) { group_owner(g, d) } limit 5 }\n`; const result = compiler.compile(dsl, 'tus'); if (!result.success) { throw new Error(`compile failed: ${result.errors.join('; ')}`); } const generated = result.generatedRules.get('canRead'); if (generated.type !== 'tuple_to_userset') { throw new Error(`expected type='tuple_to_userset', got '${generated.type}'`); } if (!generated.tuplesetRelation || !generated.computedRelation) { throw new Error(`tuple_to_userset missing tuplesetRelation/computedRelation: ${JSON.stringify(generated)}`); } return generated; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args())], rigor.crucible([ rigor.invariant('tus-emission', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'dsl-compiler-tus', effort: 200 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'tus-emission'); assert.ok(inv); assert.equal(inv.passed, true, `tuple_to_userset emission violated in ${inv.failureCount} cases`); }); it('ParentRule: hierarchy predicate → type=parent', async () => { async function check() { const arbiter = makeMockArbiter(); const compiler = new DSLCompiler(arbiter); const dsl = `${DEFINITIONS}${FACTS}\nevidence canRead(p: Person, d: Document) { parent_of(*parent, d) { canReadInner(p, parent) } limit 3 }\n`; const result = compiler.compile(dsl, 'parent'); if (!result.success) { throw new Error(`compile failed: ${result.errors.join('; ')}`); } const generated = result.generatedRules.get('canRead'); if (generated.type !== 'parent') { throw new Error(`expected type='parent', got '${generated.type}'`); } if (!generated.parentRelation) { throw new Error(`parent rule missing parentRelation: ${JSON.stringify(generated)}`); } return generated; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args())], rigor.crucible([ rigor.invariant('parent-emission', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'dsl-compiler-parent', effort: 200 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'parent-emission'); assert.ok(inv); assert.equal(inv.passed, true, `parent emission violated in ${inv.failureCount} cases`); }); it('ChainRule: nested predicate-call pattern → type=chain (RF-24)', async () => { async function check() { const arbiter = makeMockArbiter(); const compiler = new DSLCompiler(arbiter); // ADR-000 chain shape: "works_in(p, *d) { has_access(d, r) }" const dsl = `${DEFINITIONS}${FACTS}\nevidence canRead(p: Person, d: Document) { works_in(p, *dept) { has_access(dept, d) } }\n`; const result = compiler.compile(dsl, 'chain'); if (!result.success) { throw new Error(`compile failed: ${result.errors.join('; ')}`); } const generated = result.generatedRules.get('canRead'); if (generated.type !== 'chain') { throw new Error(`expected type='chain', got '${generated.type}' (chain rule is missing from the compiler)`); } if (!Array.isArray(generated.steps) || generated.steps.length < 2) { throw new Error(`chain rule must have at least 2 steps, got ${JSON.stringify(generated.steps)}`); } // Steps must reference both predicates from the DSL if (!generated.steps.includes('works_in') || !generated.steps.includes('has_access')) { throw new Error(`chain steps should include 'works_in' and 'has_access', got ${JSON.stringify(generated.steps)}`); } return generated; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args())], rigor.crucible([ rigor.invariant('chain-emission', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'dsl-compiler-chain', effort: 200 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'chain-emission'); assert.ok(inv); assert.equal(inv.passed, true, `chain emission violated in ${inv.failureCount} cases`); }); it('MultiHopRule: collection-processing with |var| → type=multi_hop', async () => { async function check() { const arbiter = makeMockArbiter(); const compiler = new DSLCompiler(arbiter); // ADR-000 multi-hop shape via collection-processing: friend_of(a, f) { friend_of(f, b) } limit 5 // NOTE: a wildcard intermediate (*f) is classified as a CHAIN by the // current dispatch (chain detection precedes multi_hop), so the // multi_hop shape uses a plain variable binding instead. We bypass the // validator by directly exercising the parser→generator path: parse // only, then run the generator against the parsed AST. const { parse } = await import('@arbiter/evidence-dsl/parser/DSLParser'); const { RuleGenerator } = await import('@arbiter/evidence-dsl/generator/RuleGenerator'); const dsl = `${DEFINITIONS}${FACTS}\nevidence canReach(a: Person, b: Person) { friend_of(a, f) { friend_of(f, b) } limit 5 }\n`; const program = parse(dsl); const generator = new RuleGenerator(arbiter); const programNode = { definitions: program.body.filter(s => s.type === 'Definition'), facts: program.body.filter(s => s.type === 'Fact'), evidence: program.body.filter(s => s.type === 'Evidence'), measures: program.body.filter(s => s.type === 'Measure') }; const genResult = generator.generateRules(programNode); if (!genResult.success) { throw new Error(`generator failed: ${genResult.errors.join('; ')}`); } const generated = generator.getGeneratedRules().get('canReach'); if (!generated) throw new Error('no rule generated for canReach'); if (generated.type !== 'multi_hop') { throw new Error(`expected type='multi_hop', got '${generated.type}'`); } if (!generated.relation) { throw new Error(`multi_hop rule missing relation: ${JSON.stringify(generated)}`); } return generated; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args())], rigor.crucible([ rigor.invariant('multi_hop-emission', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'dsl-compiler-multi-hop', effort: 200 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'multi_hop-emission'); assert.ok(inv); assert.equal(inv.passed, true, `multi_hop emission violated in ${inv.failureCount} cases`); }); it('LogicalOperators: NEVER/ALWAYS/REQUIRES → type=logical with right level', async () => { async function check(pair) { // pair is [level, keyword] — keeps the two correlated const [level, keyword] = pair; const arbiter = makeMockArbiter(); const compiler = new DSLCompiler(arbiter); const dsl = `${DEFINITIONS}${FACTS}\nevidence canRead(p: Person, d: Document) { ${keyword} isSuspended(p) }\n`; const result = compiler.compile(dsl, `logical-${level}-${Math.random()}`); if (!result.success) { throw new Error(`compile failed: ${result.errors.join('; ')}`); } const generated = result.generatedRules.get('canRead'); if (generated.type !== 'logical') { throw new Error(`expected type='logical' for ${keyword}, got '${generated.type}'`); } if (!generated[level]) { throw new Error(`logical rule missing '${level}' block (expected under keyword=${keyword}): ${JSON.stringify(generated)}`); } return generated; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args( rigor.gen.oneOf([ rigor.gen.constant(['never', 'NEVER']), rigor.gen.constant(['always', 'ALWAYS']), rigor.gen.constant(['requires', 'REQUIRES']) ]) ) )], rigor.crucible([ rigor.invariant('logical-emission', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'dsl-compiler-logical', effort: 800 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'logical-emission'); assert.ok(inv); assert.equal(inv.passed, true, `logical emission violated in ${inv.failureCount} cases`); }); it('RelationalComparatorRule: comparison → type=relational_comparator (RF-24)', async () => { async function check(op) { const arbiter = makeMockArbiter(); const compiler = new DSLCompiler(arbiter); const dsl = `${DEFINITIONS}${FACTS}\nevidence canRead(p: Person, d: Document) { personAge(p) ${op} docMinAge(d) }\n`; const result = compiler.compile(dsl, `rc-${op}-${Math.random()}`); if (!result.success) { throw new Error(`compile failed: ${result.errors.join('; ')}`); } const generated = result.generatedRules.get('canRead'); if (generated.type !== 'relational_comparator') { throw new Error(`expected type='relational_comparator' for op='${op}', got '${generated.type}'`); } if (generated.comparator !== op) { throw new Error(`expected comparator='${op}', got '${generated.comparator}'`); } if (!generated.left || !generated.right) { throw new Error(`comparator missing left/right operand: ${JSON.stringify(generated)}`); } return generated; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args(rigor.gen.enum(['>', '>=', '<', '<=', '==', '!='])))], rigor.crucible([ rigor.invariant('relational-comparator-emission', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'dsl-compiler-relational-comparator', effort: 800 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'relational-comparator-emission'); assert.ok(inv); assert.equal(inv.passed, true, `relational_comparator emission violated in ${inv.failureCount} cases`); }); it('mapping consistency: each ADR-000 mapping emits exactly one of the documented types', async () => { // Property sweep: a small catalog of DSL snippets, each tagged with the // expected rule type per ADR-000. Catch future regressions where someone // re-routes through `logical` (or any other type) by accident. // NOTE: multi_hop is intentionally absent — the validator cannot // type-infer its shape (see the dedicated MultiHopRule test above, which // bypasses the validator via the parser→generator path). const catalog = [ { dsl: 'evidence x(p: Person, d: Document) { owns(p, d) }', expectedType: 'direct' }, { dsl: 'evidence x(p: Person, d: Document) { member_of(p, *g) { group_owner(g, d) } limit 5 }', expectedType: 'tuple_to_userset' }, { dsl: 'evidence x(p: Person, d: Document) { parent_of(*parent, d) { canReadInner(p, parent) } limit 3 }', expectedType: 'parent' }, { dsl: 'evidence x(p: Person, d: Document) { works_in(p, *dept) { has_access(dept, d) } }', expectedType: 'chain' }, { dsl: 'evidence x(p: Person, d: Document) { NEVER isSuspended(p) }', expectedType: 'logical' }, { dsl: 'evidence x(p: Person, d: Document) { personAge(p) >= docMinAge(d) }', expectedType: 'relational_comparator' } ]; async function check(idx) { const entry = catalog[idx]; const arbiter = makeMockArbiter(); const compiler = new DSLCompiler(arbiter); const result = compiler.compile(DEFINITIONS + FACTS + entry.dsl, `cat-${idx}-${Math.random()}`); if (!result.success) { throw new Error(`compile failed for catalog[${idx}]: ${result.errors.join('; ')}`); } const evidenceName = entry.dsl.match(/evidence\s+(\w+)/)[1]; const generated = result.generatedRules.get(evidenceName); if (!generated) { throw new Error(`no rule generated for '${evidenceName}' (catalog[${idx}])`); } if (generated.type !== entry.expectedType) { throw new Error(`catalog[${idx}]: expected type='${entry.expectedType}', got '${generated.type}'`); } return generated; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args(rigor.gen.int(0, catalog.length - 1)))], rigor.crucible([ rigor.invariant('mapping-consistency', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'dsl-compiler-mapping-consistency', effort: 800 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'mapping-consistency'); assert.ok(inv); assert.equal(inv.passed, true, `mapping consistency violated in ${inv.failureCount} cases`); }); });