self::MAX_RUNTIME_LOGIC_DEPTH || $visited > self::MAX_RUNTIME_LOGIC_NODES ) { return false; } if ( 'condition' === ( $node['type'] ?? null ) ) { $operator = Operator_Registry::get( $node['operator'] ?? null ); if ( ! $operator ) { return false; } $left = self::resolve( $node['source'] ?? 'field', $node['ref'] ?? '', $context ); return (bool) $operator['evaluate']( $left, $node['value'] ?? null ); } // 'group' $results = array_map( fn( $child ) => self::evaluate_logic_node_limited( $child, $context, $depth + 1, $visited ), $node['children'] ?? [] ); if ( 'OR' === ( $node['combinator'] ?? 'AND' ) ) { $combined = in_array( true, $results, true ); } else { $combined = ! in_array( false, $results, true ); } return ! empty( $node['not'] ) ? ! $combined : $combined; } public static function evaluate_rule_trigger( array $rule, array $context ) { return self::evaluate_logic_node( $rule['trigger'] ?? null, $context ); } /** Same evaluation as evaluate_logic_node(), but returns a full trace tree — mirrors evaluate.js's traceLogicNode(). Powers the Rule Inspector / Rule Debugger REST endpoints (Includes\RestApi). */ public static function trace_logic_node( $node, array $context ) { $visited = 0; return self::trace_logic_node_limited( $node, $context, 1, $visited ); } private static function trace_logic_node_limited( $node, array $context, $depth, &$visited ) { if ( empty( $node ) ) { return [ 'type' => 'group', 'combinator' => 'AND', 'not' => false, 'result' => true, 'children' => [] ]; } $visited++; if ( $depth > self::MAX_RUNTIME_LOGIC_DEPTH || $visited > self::MAX_RUNTIME_LOGIC_NODES ) { return [ 'type' => 'condition', 'source' => '', 'ref' => 'runtime_limit', 'operator' => '', 'expected_value' => null, 'resolved_value' => null, 'result' => false, 'unknown_operator' => true ]; } if ( 'condition' === ( $node['type'] ?? null ) ) { $operator = Operator_Registry::get( $node['operator'] ?? null ); $left = $operator ? self::resolve( $node['source'] ?? 'field', $node['ref'] ?? '', $context ) : null; $result = $operator ? (bool) $operator['evaluate']( $left, $node['value'] ?? null ) : false; return [ 'type' => 'condition', 'source' => $node['source'] ?? 'field', 'ref' => $node['ref'] ?? '', 'operator' => $node['operator'] ?? '', 'expected_value' => $node['value'] ?? null, 'resolved_value' => $left, 'result' => $result, 'unknown_operator' => ! $operator, ]; } $child_traces = array_map( fn( $child ) => self::trace_logic_node_limited( $child, $context, $depth + 1, $visited ), $node['children'] ?? [] ); $child_results = array_map( fn( $t ) => $t['result'], $child_traces ); $combined = 'OR' === ( $node['combinator'] ?? 'AND' ) ? in_array( true, $child_results, true ) : ! in_array( false, $child_results, true ); $result = ! empty( $node['not'] ) ? ! $combined : $combined; return [ 'type' => 'group', 'combinator' => $node['combinator'] ?? 'AND', 'not' => ! empty( $node['not'] ), 'result' => $result, 'children' => $child_traces ]; } /** Human-readable one-line reason for a trace's result — mirrors evaluate.js's explainTrace(), same "name the actual culprit condition" behavior. */ public static function explain_trace( array $trace ) { if ( 'condition' === $trace['type'] ) { $operator = Operator_Registry::get( $trace['operator'] ); $label = $operator['label'] ?? $trace['operator']; return sprintf( '%s %s %s = %s (actual: %s)', $trace['ref'], $label, wp_json_encode( $trace['expected_value'] ), $trace['result'] ? 'TRUE' : 'FALSE', wp_json_encode( $trace['resolved_value'] ) ); } if ( $trace['result'] ) { return 'Group matched'; } $culprits = 'OR' === $trace['combinator'] ? $trace['children'] : array_values( array_filter( $trace['children'], fn( $c ) => ! $c['result'] ) ); if ( empty( $culprits ) ) { return ! empty( $trace['not'] ) ? 'Negated group matched (NOT of a true group)' : 'Group did not match'; } return implode( ' AND ', array_map( [ __CLASS__, 'explain_trace' ], $culprits ) ); } /** * Runs every field-scope rule in dependency order and folds matching * rules' actions into one field-state overlay — mirrors evaluate.js's * evaluateRules() exactly (same declarative-by-default, priority- * descending, fail-safe-on-cycle semantics). `$fields` must already be * flattened (see Includes\Entry / Form helpers for existing flatten * utilities) since containers aren't this class's concern. * * $options['trace'] = true additionally computes a full trace + timing * per rule (Rule Inspector / Debugger); left off (the default, and what * SubmissionHandler always uses) for the fast path with zero tracing * overhead on every real submission. * * Returns [ 'field_state' => array, 'evaluated' => array, 'cycle' => string[] (+ 'order', 'duration_ms' when traced) ]. */ public static function evaluate_rules( array $rules, array $fields, array $context, array $options = [] ) { self::ensure_builtins_registered(); $with_trace = ! empty( $options['trace'] ); $started_at = $with_trace ? microtime( true ) : null; $active_rules = array_values( array_filter( $rules, fn( $r ) => 'field' === ( $r['scope'] ?? 'field' ) && false !== ( $r['enabled'] ?? true ) ) ); $graph = Dependency_Graph::build_evaluation_order( $active_rules ); $order = $graph['order']; $cycle = $graph['cycle']; if ( ! empty( $cycle ) ) { do_action( 'chargeforms_cycle_detected', $cycle ); } $by_id = []; foreach ( $active_rules as $rule ) { $by_id[ $rule['id'] ] = $rule; } $state = Builtin_Actions::base_field_state( $fields, $active_rules ); $runnable = array_values( array_filter( $order, fn( $id ) => ! in_array( $id, $cycle, true ) ) ); $runnable = array_map( fn( $id ) => $by_id[ $id ], $runnable ); usort( $runnable, fn( $a, $b ) => ( $b['priority'] ?? 0 ) <=> ( $a['priority'] ?? 0 ) ); $evaluated = []; foreach ( $runnable as $rule ) { $rule_started_at = $with_trace ? microtime( true ) : null; $trace = $with_trace ? self::trace_logic_node( $rule['trigger'] ?? null, $context ) : null; $matched = $with_trace ? $trace['result'] : self::evaluate_rule_trigger( $rule, $context ); $actions_applied = []; if ( $matched ) { foreach ( $rule['actions'] ?? [] as $action ) { $action_type = Action_Registry::get( $action['type'] ?? null ); if ( ! $action_type ) { continue; } $state = $action_type['apply']( $state, $action ); if ( $with_trace ) { $actions_applied[] = [ 'type' => $action['type'], 'target' => $action['target'] ?? null, 'label' => $action_type['label'] ]; } } } $entry = [ 'rule_id' => $rule['id'], 'rule_name' => $rule['name'] ?? '', 'matched' => $matched ]; if ( $with_trace ) { $entry['trace'] = $trace; $entry['actions_applied'] = $actions_applied; $entry['duration_ms'] = ( microtime( true ) - $rule_started_at ) * 1000; } $evaluated[] = $entry; do_action( 'chargeforms_rule_evaluated', $rule, $matched, $context ); } $result = [ 'field_state' => $state, 'evaluated' => $evaluated, 'cycle' => $cycle ]; if ( $with_trace ) { $result['order'] = $order; $result['duration_ms'] = ( microtime( true ) - $started_at ) * 1000; } return $result; } }