/** * tests/rigor/dsl-illegal-mutations.test.js — js-rigor campaign that takes a * valid Evidence DSL program and applies ONE subtle flaw to produce illegal * DSL, asserting the compiler reliably REJECTS each mutation. * * Each mutation perturbs a single construct (swapped arg types, unknown fact, * arity mismatch, reserved built-in type, duplicate evidence, unterminated * block, malformed parameter list, type mismatch across params). A lowering or * validation bug that silently accepted structurally-broken DSL would fail the * invariant. * * Anti-vacuity: the `valid` mutation is the untouched DSL and MUST compile — * proving the harness is not trivially rejecting everything. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '@arbiter/core'; import { DSLCompiler } from '../../src/DSLCompiler.js'; const VALID_DSL = ` definition Employee { id: string } definition Group { id: string } definition Doc { id: string } fact owns(user: Employee, doc: Doc) fact member_of(user: Employee, group: Group) fact can_access(group: Group, doc: Doc) evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } evidence can_enter(user: Employee, doc: Doc) { member_of(user, *g) { can_access(g, doc) } } `; // Each mutation transforms the valid DSL into an illegal variant. // `mustFail: false` marks the control mutation (untouched DSL — must compile). const MUTATIONS = { valid: { desc: 'control (untouched DSL must compile)', mustFail: false, apply: () => VALID_DSL }, swapped_arg_types: { desc: 'swapped subject/object argument types', mustFail: true, apply: () => VALID_DSL.replace('fact owns(user: Employee, doc: Doc)', 'fact owns(doc: Doc, user: Employee)') }, undefined_fact: { desc: 'references an undeclared fact', mustFail: true, apply: () => VALID_DSL.replace('{ owns(user, doc) }', '{ ghost(user, doc) }') }, arity_mismatch: { desc: 'wrong argument arity on a binary fact', mustFail: true, apply: () => VALID_DSL.replace('{ owns(user, doc) }', '{ owns(user) }') }, reserved_builtin_type: { desc: 'redefines a reserved built-in type', mustFail: true, apply: () => VALID_DSL.replace('definition Employee { id: string }', 'definition User { id: string }') }, duplicate_evidence: { desc: 'duplicate evidence relation name', mustFail: true, apply: () => VALID_DSL + `\n evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }` }, unterminated_block: { desc: 'missing closing brace', mustFail: true, apply: () => VALID_DSL.replace('{ owns(user, doc) }', '{ owns(user, doc)') }, malformed_params: { desc: 'malformed parameter list (missing comma)', mustFail: true, apply: () => VALID_DSL.replace('owns(user: Employee, doc: Doc)', 'owns(user: Employee doc: Doc)') }, type_mismatch_arg: { desc: 'passes an Employee where a Group is required', mustFail: true, apply: () => VALID_DSL.replace('{ can_access(g, doc) }', '{ can_access(user, doc) }') }, wrong_evidence_arity: { desc: 'evidence declared with mismatched parameter arity', mustFail: true, apply: () => VALID_DSL.replace('evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }', 'evidence can_read(user: Employee) { owns(user, doc) }') }, cyclic_evidence_ref: { desc: 'two evidences referencing each other (cycle)', mustFail: true, apply: () => VALID_DSL + ` evidence can_cyc_a(user: Employee, doc: Doc) { can_cyc_b(user, doc) } evidence can_cyc_b(user: Employee, doc: Doc) { can_cyc_a(user, doc) }` }, cross_kind_collision: { desc: 'fact and evidence sharing a relation name', mustFail: true, apply: () => VALID_DSL.replace( 'evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }', 'fact can_read(user: Employee, doc: Doc)\n evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }' ) }, non_lowerable_chain_step: { desc: 'a defeasible evidence used as a chain step (cannot lower to an edge)', mustFail: true, apply: () => VALID_DSL.replace( 'evidence can_enter(user: Employee, doc: Doc) { member_of(user, *g) { can_access(g, doc) } }', 'evidence can_gated(group: Group, doc: Doc) { WHEN can_access(group, doc) UNLESS banned(group) }\n evidence can_enter(user: Employee, doc: Doc) { member_of(user, *g) { can_gated(g, doc) } }' ).replace('fact can_access(group: Group, doc: Doc)', 'fact can_access(group: Group, doc: Doc)\n fact banned(group: Group)') } }; function checkMutation(mutationName) { const mutation = MUTATIONS[mutationName]; if (!mutation) throw new Error(`unknown mutation name: ${JSON.stringify(mutationName)}`); const dsl = mutation.apply(); const arbiter = new Arbiter(); const compiler = new DSLCompiler(arbiter); const result = compiler.compile(dsl, `mut-${mutationName}`); const success = result.success; const errors = result.errors || []; if (mutation.mustFail) { if (success || errors.length === 0) { throw new Error(`mutation '${mutationName}' was NOT rejected (${mutation.desc}). ` + `success=${success}, errors=${JSON.stringify(errors)}`); } } else if (!success) { throw new Error(`control mutation '${mutationName}' should compile but failed: ${JSON.stringify(errors)}`); } return { mutationName, ok: true }; } describe('DSL illegal-mutation rejection (rigor)', () => { it('every subtle one-flaw mutation is reliably rejected; the control compiles', async () => { const report = await rigor.campaign( [ rigor.fn('reject-mutation', checkMutation, rigor.args( rigor.gen.oneOf(Object.keys(MUTATIONS)) )) ], rigor.crucible([ // `actual` is the fn's return; a contract violation (a must-fail // mutation that compiled, a control that failed, or an unknown name) // throws → actual undefined → this invariant fails. rigor.invariant('rejection-contract', ({ actual }) => !!actual && actual.ok === true) ]) ).run({ seed: 'dsl-illegal-mutations', effort: 400, artifacts: { dir: '', persist: 'never' } }); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'rejection-contract'); assert.ok(inv, 'crucible invariant missing'); assert.equal(inv.passed, true, `rejection contract violated in ${inv.failureCount} cases`); }); it('every mutation kind is exercised (no vacuous pass)', () => { const seen = new Set(); for (const name of Object.keys(MUTATIONS)) { // deterministic probe of each kind seen.add(name); checkMutation(name); } assert.equal(seen.size, Object.keys(MUTATIONS).length); }); });