2026-07-31 13:44:06 -07:00
|
|
|
/**
|
|
|
|
|
* 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-01 09:52:31 -07:00
|
|
|
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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-01 09:52:31 -07:00
|
|
|
).run({ effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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-01 09:52:31 -07:00
|
|
|
).run({ effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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-01 09:52:31 -07:00
|
|
|
).run({ effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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-01 09:52:31 -07:00
|
|
|
).run({ effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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-01 09:52:31 -07:00
|
|
|
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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-01 09:52:31 -07:00
|
|
|
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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-01 09:52:31 -07:00
|
|
|
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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`);
|
|
|
|
|
});
|
|
|
|
|
});
|