Files
core/tests/rigor/dsl-compiler.test.js
T

382 lines
18 KiB
JavaScript
Raw Normal View History

/**
* 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 '../../src/ast/DSLCompiler.js';
// 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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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('../../src/ast/parser/DSLParser.js');
const { RuleGenerator } = await import('../../src/ast/generator/RuleGenerator.js');
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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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`);
});
});