2dc478f5a3
A chain step that references a derived evidence is now expanded at compile
time, keeping the engine a flat edge-traversal evaluator:
- DIRECT evidence step -> renamed to its underlying relation
(member_of(user,*g){ group_read(g,doc) } where group_read = can_view
becomes step 'can_view').
- CHAIN evidence step -> its steps are spliced into the parent chain
(a sub-path flattens into the linear source->...->object traversal).
- Any other evidence type (defeasible/logical/comparator) as a step is a
compile-time error: it is a condition, not an edge traversal.
- Cycles and self-references through chain steps are compile-time errors
(the existing composition cycle guard now covers steps).
Rigor: oracle campaign gains a chain_step_composition construct; illegal
mutations gain a non-lowerable-chain-step case. Fixture suites updated to
retarget the self-recursive 'canRead/canAccess/...' terminals (an unsupported
recursion pattern that now fails loudly) to an any-typed 'reachable' fact,
preserving the nested-pattern parsing intent.
162 lines
6.7 KiB
JavaScript
162 lines
6.7 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|