/** * Template-Spec v1 — Rule Engine (Phase B) * * Evaluates ruleset.v1.json against a RuleInput and produces flags, * applied defaults, validation errors/warnings, and removed block IDs. * * Design: zero external dependencies — minimal JSONLogic evaluator inline. */ import ruleset from './ruleset.v1.json' import type { TemplateContext, ComputedFlags } from './contextBridge' // ============================================================================= // Types // ============================================================================= export interface RuleInput { doc_type: string render: { lang: string; variant: string } context: TemplateContext modules: { enabled: string[] } } export interface RuleViolation { id: string severity: 'ERROR' | 'WARN' message: string phase: string } export interface RuleEngineResult { computedFlags: ComputedFlags contextAfterDefaults: TemplateContext violations: RuleViolation[] warnings: RuleViolation[] removedBlocks: string[] appliedDefaults: string[] } // ============================================================================= // Minimal JSONLogic Evaluator // Supports: var, ==, !=, in, and, or, >=, <= // ============================================================================= type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue } interface Envelope { doc_type: string render: { lang: string; variant: string } context: TemplateContext computed_flags: ComputedFlags modules: { enabled: string[] } } function getPath(obj: unknown, path: string): unknown { if (path === '') return obj const parts = path.split('.') let cur: unknown = obj for (const part of parts) { if (cur == null || typeof cur !== 'object') return null cur = (cur as Record)[part] } return cur ?? null } function evaluate(expr: JsonValue, env: Envelope): unknown { if (expr === null || typeof expr !== 'object' || Array.isArray(expr)) { return expr } const ops = Object.keys(expr as Record) if (ops.length === 0) return null const op = ops[0] const args = (expr as Record)[op] switch (op) { case 'var': { const path = String(args) return getPath(env, path) } case '==': { const [a, b] = args as JsonValue[] return evaluate(a, env) == evaluate(b, env) // eslint-disable-line eqeqeq } case '!=': { const [a, b] = args as JsonValue[] return evaluate(a, env) != evaluate(b, env) // eslint-disable-line eqeqeq } case '>=': { const [a, b] = args as JsonValue[] const av = evaluate(a, env) const bv = evaluate(b, env) if (av == null || bv == null) return false return Number(av) >= Number(bv) } case '<=': { const [a, b] = args as JsonValue[] const av = evaluate(a, env) const bv = evaluate(b, env) if (av == null || bv == null) return false return Number(av) <= Number(bv) } case 'in': { const [needle, haystack] = args as JsonValue[] const n = evaluate(needle, env) const h = evaluate(haystack, env) if (!Array.isArray(h)) return false return h.includes(n as never) } case 'and': { const items = args as JsonValue[] return items.every((item) => !!evaluate(item, env)) } case 'or': { const items = args as JsonValue[] return items.some((item) => !!evaluate(item, env)) } default: return null } } // ============================================================================= // Deep clone helper // ============================================================================= function deepClone(obj: T): T { return JSON.parse(JSON.stringify(obj)) } // ============================================================================= // Set value at dot-path (mutates object for defaults application) // ============================================================================= function setNestedPath(obj: Record, dotPath: string, value: unknown): void { const parts = dotPath.split('.') let cur: Record = obj for (let i = 0; i < parts.length - 1; i++) { const part = parts[i] if (cur[part] == null || typeof cur[part] !== 'object') { cur[part] = {} } cur = cur[part] as Record } cur[parts[parts.length - 1]] = value } // ============================================================================= // Phase runner helpers // ============================================================================= type ComputeFlagEntry = { id: string set: string expr: JsonValue } type AutoDefaultEntry = { id: string when: JsonValue actions: { type: string; path: string; value: unknown }[] } type ValidationEntry = { id: string severity: string when: JsonValue assert_all: JsonValue[] message: string } type BlockRemoveEntry = { id: string when: JsonValue actions: { type: string; block_id: string }[] } type ModuleRequirementEntry = { id: string severity: string when: JsonValue assert_all: JsonValue[] message: string } type WarningEntry = { id: string severity: string when: JsonValue assert_all: JsonValue[] message: string } // ============================================================================= // Phase 1: compute_flags // ============================================================================= function runComputeFlags(env: Envelope): ComputedFlags { const flags: Record = {} for (const rule of (ruleset.compute_flags as unknown) as ComputeFlagEntry[]) { const result = !!evaluate(rule.expr as JsonValue, env) // set path like "computed_flags.IS_B2C" const key = rule.set.replace('computed_flags.', '') flags[key] = result } return flags as unknown as ComputedFlags } // ============================================================================= // Phase 2: auto_defaults // ============================================================================= function runAutoDefaults(env: Envelope): string[] { const applied: string[] = [] for (const rule of ruleset.auto_defaults as AutoDefaultEntry[]) { if (!!evaluate(rule.when as JsonValue, env)) { for (const action of rule.actions) { if (action.type === 'set') { // path like "context.SECURITY.LOG_RETENTION_DAYS" const contextPath = action.path.replace('context.', '') setNestedPath(env.context as unknown as Record, contextPath, action.value) } } applied.push(rule.id) } } return applied } // ============================================================================= // Phase 4 / 5 / 6: hard_validations, module_requirements, warnings // ============================================================================= function runValidations( rules: ValidationEntry[], env: Envelope, phase: string ): RuleViolation[] { const result: RuleViolation[] = [] for (const rule of rules) { if (!evaluate(rule.when as JsonValue, env)) continue // assert_all: if empty → always fires (WARN_LEGAL_REVIEW pattern) const allPass = rule.assert_all.length === 0 ? false : rule.assert_all.every((assertion) => !!evaluate(assertion as JsonValue, env)) if (!allPass) { result.push({ id: rule.id, severity: rule.severity as 'ERROR' | 'WARN', message: rule.message, phase, }) } } return result } // ============================================================================= // Phase 5: auto_remove_blocks // ============================================================================= function runAutoRemoveBlocks(env: Envelope): string[] { const removed: string[] = [] for (const rule of ruleset.auto_remove_blocks as BlockRemoveEntry[]) { if (!!evaluate(rule.when as JsonValue, env)) { for (const action of rule.actions) { if (action.type === 'remove_block') { removed.push(action.block_id) } } } } return removed } // ============================================================================= // Main: runRuleset // ============================================================================= export function runRuleset(input: RuleInput): RuleEngineResult { // Deep clone context so we don't mutate caller's state const clonedContext = deepClone(input.context) const env: Envelope = { doc_type: input.doc_type, render: input.render, context: clonedContext, computed_flags: {} as ComputedFlags, modules: input.modules, } // Phase 1: compute_flags env.computed_flags = runComputeFlags(env) // Phase 2: auto_defaults — mutates clonedContext const appliedDefaults = runAutoDefaults(env) // Phase 3: compute_flags again (after defaults) env.computed_flags = runComputeFlags(env) // Phase 4: hard_validations const allHardViolations = runValidations( (ruleset.hard_validations as unknown) as ValidationEntry[], env, 'hard_validations' ) // Phase 5: auto_remove_blocks const removedBlocks = runAutoRemoveBlocks(env) // Phase 6a: module_requirements const moduleViolations = runValidations( (ruleset.module_requirements as unknown) as ModuleRequirementEntry[], env, 'module_requirements' ) // Phase 6b: warnings const warningViolations = runValidations( (ruleset.warnings as unknown) as WarningEntry[], env, 'warnings' ) const allViolations = [...allHardViolations, ...moduleViolations, ...warningViolations] return { computedFlags: env.computed_flags, contextAfterDefaults: clonedContext, violations: allViolations.filter((v) => v.severity === 'ERROR'), warnings: allViolations.filter((v) => v.severity === 'WARN'), removedBlocks, appliedDefaults, } } // ============================================================================= // getDocType: maps (templateType, language) → doc_type string // ============================================================================= const DOC_TYPE_MAP: Record = { 'nda+de': 'nda_de', 'nda+en': 'nda_en', 'sla+de': 'sla_de', 'acceptable_use+en': 'acceptable_use_en', 'community_guidelines+de': 'community_de', 'copyright_policy+de': 'copyright_de', 'cloud_service_agreement+de': 'cloud_contract_de', 'data_usage_clause+de': 'data_usage_clause_de', 'cookie_banner+de': 'cookie_banner_de', 'agb+de': 'agb_de', 'agb_clause+de': 'agb_de', 'clause+en': 'liability_clause_en', } export function getDocType(templateType: string, language: string): string { const key = `${templateType}+${language}` return DOC_TYPE_MAP[key] ?? `${templateType}_${language}` } // ============================================================================= // buildBoolContext: combines computed flags + derived + FEATURES booleans // ============================================================================= export function buildBoolContext(ctx: TemplateContext, flags: ComputedFlags): Record { const f = ctx.FEATURES return { // --- From computed flags --- IS_B2C: flags.IS_B2C, IS_B2B: flags.IS_B2B, SERVICE_IS_SAAS: flags.SERVICE_IS_SAAS, SERVICE_IS_HYBRID: flags.SERVICE_IS_HYBRID, HAS_PENALTY: flags.HAS_PENALTY, HAS_ANALYTICS: flags.HAS_ANALYTICS, ANALYTICS_ENABLED: flags.HAS_ANALYTICS, // alias used in cookie banner template HAS_MARKETING: flags.HAS_MARKETING, MARKETING_ENABLED: flags.HAS_MARKETING, // alias // --- Derived from PROVIDER --- HAS_REGISTER: !!(ctx.PROVIDER.REGISTER_COURT), HAS_VAT_ID: !!(ctx.PROVIDER.VAT_ID), CONTACT_PHONE: !!(ctx.PROVIDER.PHONE), // --- Derived from PRIVACY --- HAS_DPO: !!(ctx.PRIVACY.DPO_NAME), // --- From FEATURES booleans --- THIRD_COUNTRY_POSSIBLE: f.HAS_THIRD_COUNTRY, HAS_THIRD_COUNTRY: f.HAS_THIRD_COUNTRY, FUNCTIONAL_ENABLED: f.HAS_FUNCTIONAL_COOKIES, HAS_FUNCTIONAL_COOKIES: f.HAS_FUNCTIONAL_COOKIES, CMP_LOGS_CONSENTS: f.CMP_LOGS_CONSENTS, HAS_NEWSLETTER: f.HAS_NEWSLETTER, HAS_ACCOUNT: f.HAS_ACCOUNT, HAS_PAYMENTS: f.HAS_PAYMENTS, HAS_SUPPORT: f.HAS_SUPPORT, HAS_SOCIAL_MEDIA: f.HAS_SOCIAL_MEDIA, HAS_PAID_PLANS: f.HAS_PAID_PLANS, HAS_SLA: f.HAS_SLA, HAS_EXPORT_POLICY: f.HAS_EXPORT_POLICY, HAS_WITHDRAWAL: f.HAS_WITHDRAWAL, HAS_REGULATED_PROFESSION: f.HAS_REGULATED_PROFESSION, HAS_EDITORIAL_RESPONSIBLE: f.HAS_EDITORIAL_RESPONSIBLE, HAS_DISPUTE_RESOLUTION: f.HAS_DISPUTE_RESOLUTION, } } // ============================================================================= // applyConditionalBlocks: processes {{#IF}}/{{#IF_NOT}}/{{#IF_ANY}} directives // Runs BEFORE placeholder substitution. // ============================================================================= export function applyConditionalBlocks(content: string, boolCtx: Record): string { let result = content // {{#IF_NOT COND}}...{{/IF_NOT}} — process before IF to avoid conflicts result = result.replace( /\{\{#IF_NOT ([A-Z_]+)\}\}([\s\S]*?)\{\{\/IF_NOT\}\}/g, (_, cond: string, body: string) => (boolCtx[cond] ? '' : body) ) // {{#IF_ANY A B C}}...{{/IF_ANY}} result = result.replace( /\{\{#IF_ANY ([A-Z_ ]+)\}\}([\s\S]*?)\{\{\/IF_ANY\}\}/g, (_, conds: string, body: string) => conds.trim().split(/\s+/).some((c) => boolCtx[c]) ? body : '' ) // {{#IF COND}}...{{/IF}} result = result.replace( /\{\{#IF ([A-Z_]+)\}\}([\s\S]*?)\{\{\/IF\}\}/g, (_, cond: string, body: string) => (boolCtx[cond] ? body : '') ) return result } // ============================================================================= // applyBlockRemoval: removes [BLOCK:ID]…[/BLOCK:ID] markers from content // ============================================================================= export function applyBlockRemoval(content: string, removedBlocks: string[]): string { let result = content for (const blockId of removedBlocks) { // Matches [BLOCK:ID]...content...[/BLOCK:ID] including optional trailing newline const escaped = blockId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const pattern = new RegExp( `\\[BLOCK:${escaped}\\][\\s\\S]*?\\[\\/BLOCK:${escaped}\\]\\n?`, 'g' ) result = result.replace(pattern, '') } return result }