/** * G10 — Cycle detection (recursion limit) * * Pararules origin: tests/test2.nim "recursion limit": * test "recursion limit": * let rules = * ruleset: * rule rule1(Fact): what: (Alice, Color, color) then: insert(Alice, Height, 15) * rule rule2(Fact): what: (Alice, Height, h) then: insert(Alice, Age, 10) * rule rule3(Fact): what: (Alice, Age, age) then: insert(Alice, Color, "blue") * rule rule4(Fact): ... * # This creates an infinite cascade — pararules detects and throws * * Our engine detects infinite recursion via a depth counter in Session.fireRules(). * When fireRules() is re-entered while already executing (depth ≥ limit), * it throws RecursionLimitExceededError. * * Since fireRules() is currently a stub (returns 0), we test the guard * directly by manipulating the internal depth counter, which is intentionally * exposed via the underscore-prefixed `_fireDepth` field. */ import { describe, it, expect } from "vitest"; import { Session } from "../../src/session.js"; import { RecursionLimitExceededError } from "../../src/cycle.js"; describe("G10 — cycle detection (recursion limit)", () => { it("fireRules() at depth ≥ limit throws RecursionLimitExceededError", () => { const session = new Session({ recursionLimit: 3, autoFire: false }); // Simulate being already at the recursion limit session._fireDepth = 3; expect(() => session.fireRules()).toThrow(RecursionLimitExceededError); }); it("RecursionLimitExceededError carries the depth at which it fired", () => { const session = new Session({ recursionLimit: 5, autoFire: false }); session._fireDepth = 5; let caught: RecursionLimitExceededError | undefined; try { session.fireRules(); } catch (e) { if (e instanceof RecursionLimitExceededError) caught = e; } expect(caught).toBeDefined(); expect(caught?.depth).toBe(5); }); it("error message includes depth information", () => { const session = new Session({ recursionLimit: 2, autoFire: false }); session._fireDepth = 2; expect(() => session.fireRules()).toThrow(/2/); }); it("fireRules() succeeds when depth is below the limit", () => { const session = new Session({ recursionLimit: 10, autoFire: false }); session._fireDepth = 9; // Should NOT throw — 9 < 10 expect(() => session.fireRules()).not.toThrow(); }); it("recursionLimit: 0 disables the guard (unlimited depth)", () => { const session = new Session({ recursionLimit: 0, autoFire: false }); session._fireDepth = 9999; // limit=0 means "disabled" — never throws regardless of depth expect(() => session.fireRules()).not.toThrow(); }); it("fireRules() increments depth and decrements on exit (balanced)", () => { const session = new Session({ recursionLimit: 64, autoFire: false }); expect(session._fireDepth).toBe(0); session.fireRules(); // After normal return, depth should be back to 0 expect(session._fireDepth).toBe(0); }); it("depth is decremented even when RecursionLimitExceededError is thrown", () => { const session = new Session({ recursionLimit: 1, autoFire: false }); session._fireDepth = 1; expect(() => session.fireRules()).toThrow(RecursionLimitExceededError); // Throws before incrementing, so _fireDepth stays at 1 (set by us) // and is not cleaned up (guard fires before the try block) expect(session._fireDepth).toBe(1); }); it("custom per-call recursionLimit overrides session default", () => { const session = new Session({ recursionLimit: 100, autoFire: false }); session._fireDepth = 3; // Override with a tighter limit in this specific call expect(() => session.fireRules({ recursionLimit: 2 })).toThrow( RecursionLimitExceededError, ); }); });