flowengine 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +7 -2
- data/README.md +828 -81
- data/Rakefile +11 -11
- data/docs/floweingine-architecture.png +0 -0
- data/justfile +9 -0
- data/lib/flowengine/definition.rb +13 -0
- data/lib/flowengine/dsl/flow_builder.rb +13 -0
- data/lib/flowengine/dsl/rule_helpers.rb +22 -0
- data/lib/flowengine/dsl/step_builder.rb +30 -1
- data/lib/flowengine/dsl.rb +2 -0
- data/lib/flowengine/engine.rb +82 -0
- data/lib/flowengine/errors.rb +11 -0
- data/lib/flowengine/evaluator.rb +9 -0
- data/lib/flowengine/graph/mermaid_exporter.rb +7 -0
- data/lib/flowengine/node.rb +35 -1
- data/lib/flowengine/rules/all.rb +7 -0
- data/lib/flowengine/rules/any.rb +7 -0
- data/lib/flowengine/rules/base.rb +9 -0
- data/lib/flowengine/rules/contains.rb +10 -0
- data/lib/flowengine/rules/equals.rb +9 -0
- data/lib/flowengine/rules/greater_than.rb +9 -0
- data/lib/flowengine/rules/less_than.rb +9 -0
- data/lib/flowengine/rules/not_empty.rb +7 -0
- data/lib/flowengine/transition.rb +14 -0
- data/lib/flowengine/validation/adapter.rb +13 -0
- data/lib/flowengine/validation/null_adapter.rb +4 -0
- data/lib/flowengine/version.rb +2 -1
- data/lib/flowengine.rb +43 -0
- metadata +2 -1
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Rule: the answer for the given field (coerced to integer) is greater than the threshold.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader field [Symbol] answer key
|
|
8
|
+
# @attr_reader value [Integer] threshold
|
|
5
9
|
class GreaterThan < Base
|
|
6
10
|
attr_reader :field, :value
|
|
7
11
|
|
|
12
|
+
# @param field [Symbol] answer key
|
|
13
|
+
# @param value [Integer] threshold
|
|
8
14
|
def initialize(field, value)
|
|
9
15
|
super()
|
|
10
16
|
@field = field
|
|
@@ -12,10 +18,13 @@ module FlowEngine
|
|
|
12
18
|
freeze
|
|
13
19
|
end
|
|
14
20
|
|
|
21
|
+
# @param answers [Hash] current answers (field value is coerced with to_i)
|
|
22
|
+
# @return [Boolean] true if answers[field].to_i > value
|
|
15
23
|
def evaluate(answers)
|
|
16
24
|
answers[field].to_i > value
|
|
17
25
|
end
|
|
18
26
|
|
|
27
|
+
# @return [String] e.g. "business_income > 100000"
|
|
19
28
|
def to_s
|
|
20
29
|
"#{field} > #{value}"
|
|
21
30
|
end
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Rule: the answer for the given field (coerced to integer) is less than the threshold.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader field [Symbol] answer key
|
|
8
|
+
# @attr_reader value [Integer] threshold
|
|
5
9
|
class LessThan < Base
|
|
6
10
|
attr_reader :field, :value
|
|
7
11
|
|
|
12
|
+
# @param field [Symbol] answer key
|
|
13
|
+
# @param value [Integer] threshold
|
|
8
14
|
def initialize(field, value)
|
|
9
15
|
super()
|
|
10
16
|
@field = field
|
|
@@ -12,10 +18,13 @@ module FlowEngine
|
|
|
12
18
|
freeze
|
|
13
19
|
end
|
|
14
20
|
|
|
21
|
+
# @param answers [Hash] current answers (field value is coerced with to_i)
|
|
22
|
+
# @return [Boolean] true if answers[field].to_i < value
|
|
15
23
|
def evaluate(answers)
|
|
16
24
|
answers[field].to_i < value
|
|
17
25
|
end
|
|
18
26
|
|
|
27
|
+
# @return [String] e.g. "age < 18"
|
|
19
28
|
def to_s
|
|
20
29
|
"#{field} < #{value}"
|
|
21
30
|
end
|
|
@@ -2,15 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Rule: the answer for the given field is present and not empty (nil or empty? => false).
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader field [Symbol] answer key
|
|
5
8
|
class NotEmpty < Base
|
|
6
9
|
attr_reader :field
|
|
7
10
|
|
|
11
|
+
# @param field [Symbol] answer key
|
|
8
12
|
def initialize(field)
|
|
9
13
|
super()
|
|
10
14
|
@field = field
|
|
11
15
|
freeze
|
|
12
16
|
end
|
|
13
17
|
|
|
18
|
+
# @param answers [Hash] current answers
|
|
19
|
+
# @return [Boolean] false if nil or empty, true otherwise
|
|
14
20
|
def evaluate(answers)
|
|
15
21
|
val = answers[field]
|
|
16
22
|
return false if val.nil?
|
|
@@ -19,6 +25,7 @@ module FlowEngine
|
|
|
19
25
|
true
|
|
20
26
|
end
|
|
21
27
|
|
|
28
|
+
# @return [String] e.g. "name is not empty"
|
|
22
29
|
def to_s
|
|
23
30
|
"#{field} is not empty"
|
|
24
31
|
end
|
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# A single edge from one step to another, optionally guarded by a rule.
|
|
5
|
+
# Transitions are evaluated in order; the first whose rule is true determines the next step.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader target [Symbol] id of the step to go to when this transition applies
|
|
8
|
+
# @attr_reader rule [Rules::Base, nil] condition; nil means unconditional (always applies)
|
|
4
9
|
class Transition
|
|
5
10
|
attr_reader :target, :rule
|
|
6
11
|
|
|
12
|
+
# @param target [Symbol] next step id
|
|
13
|
+
# @param rule [Rules::Base, nil] optional condition (nil = always)
|
|
7
14
|
def initialize(target:, rule: nil)
|
|
8
15
|
@target = target
|
|
9
16
|
@rule = rule
|
|
10
17
|
freeze
|
|
11
18
|
end
|
|
12
19
|
|
|
20
|
+
# Human-readable label for the condition (e.g. for graph export).
|
|
21
|
+
#
|
|
22
|
+
# @return [String] rule#to_s or "always" when rule is nil
|
|
13
23
|
def condition_label
|
|
14
24
|
rule ? rule.to_s : "always"
|
|
15
25
|
end
|
|
16
26
|
|
|
27
|
+
# Whether this transition should be taken given current answers.
|
|
28
|
+
#
|
|
29
|
+
# @param answers [Hash] current answer state
|
|
30
|
+
# @return [Boolean] true if rule is nil or rule evaluates to true
|
|
17
31
|
def applies?(answers)
|
|
18
32
|
return true if rule.nil?
|
|
19
33
|
|
|
@@ -2,21 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Validation
|
|
5
|
+
# Result of validating a step answer: either valid or a list of error messages.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader errors [Array<String>] validation error messages (empty when valid)
|
|
5
8
|
class Result
|
|
6
9
|
attr_reader :errors
|
|
7
10
|
|
|
11
|
+
# @param valid [Boolean] whether the input passed validation
|
|
12
|
+
# @param errors [Array<String>] error messages (default: [])
|
|
8
13
|
def initialize(valid:, errors: [])
|
|
9
14
|
@valid = valid
|
|
10
15
|
@errors = errors.freeze
|
|
11
16
|
freeze
|
|
12
17
|
end
|
|
13
18
|
|
|
19
|
+
# @return [Boolean] true if validation passed
|
|
14
20
|
def valid?
|
|
15
21
|
@valid
|
|
16
22
|
end
|
|
17
23
|
end
|
|
18
24
|
|
|
25
|
+
# Abstract adapter for step-level validation. Implement {#validate} to plug in
|
|
26
|
+
# dry-validation, JSON Schema, or other validators; the engine does not depend on a specific one.
|
|
19
27
|
class Adapter
|
|
28
|
+
# Validates the user's input for the given step.
|
|
29
|
+
#
|
|
30
|
+
# @param _node [Node] the current step (for schema/constraints)
|
|
31
|
+
# @param _input [Object] the value submitted by the user
|
|
32
|
+
# @return [Result] valid: true/false and optional errors list
|
|
20
33
|
def validate(_node, _input)
|
|
21
34
|
raise NotImplementedError, "#{self.class}#validate must be implemented"
|
|
22
35
|
end
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Validation
|
|
5
|
+
# No-op validator: always accepts any input. Used by default when no validation adapter is given.
|
|
5
6
|
class NullAdapter < Adapter
|
|
7
|
+
# @param _node [Node] ignored
|
|
8
|
+
# @param _input [Object] ignored
|
|
9
|
+
# @return [Result] always valid with no errors
|
|
6
10
|
def validate(_node, _input)
|
|
7
11
|
Result.new(valid: true, errors: [])
|
|
8
12
|
end
|
data/lib/flowengine/version.rb
CHANGED
data/lib/flowengine.rb
CHANGED
|
@@ -20,10 +20,53 @@ require_relative "flowengine/engine"
|
|
|
20
20
|
require_relative "flowengine/dsl"
|
|
21
21
|
require_relative "flowengine/graph/mermaid_exporter"
|
|
22
22
|
|
|
23
|
+
# Declarative flow definition and execution engine for wizards, intake forms, and
|
|
24
|
+
# multi-step decision graphs. Separates flow logic, data schema, and UI rendering.
|
|
25
|
+
#
|
|
26
|
+
# @example Define and run a flow
|
|
27
|
+
# definition = FlowEngine.define do
|
|
28
|
+
# start :earnings
|
|
29
|
+
# step :earnings do
|
|
30
|
+
# type :multi_select
|
|
31
|
+
# question "What are your main earnings?"
|
|
32
|
+
# options %w[W2 1099 BusinessOwnership]
|
|
33
|
+
# transition to: :business_details, if: contains(:earnings, "BusinessOwnership")
|
|
34
|
+
# end
|
|
35
|
+
# step :business_details do
|
|
36
|
+
# type :number_matrix
|
|
37
|
+
# question "How many business types?"
|
|
38
|
+
# fields %w[RealEstate SCorp CCorp]
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
# engine = FlowEngine::Engine.new(definition)
|
|
42
|
+
# engine.answer(["W2", "BusinessOwnership"])
|
|
43
|
+
# engine.current_step_id # => :business_details
|
|
44
|
+
#
|
|
23
45
|
module FlowEngine
|
|
46
|
+
# Builds an immutable {Definition} from the declarative DSL block.
|
|
47
|
+
#
|
|
48
|
+
# @yield context of {DSL::FlowBuilder} (start, step, and rule helpers)
|
|
49
|
+
# @return [Definition] frozen flow definition with start step and nodes
|
|
50
|
+
# @raise [DefinitionError] if no start step or no steps are defined
|
|
24
51
|
def self.define(&)
|
|
25
52
|
builder = DSL::FlowBuilder.new
|
|
26
53
|
builder.instance_eval(&)
|
|
27
54
|
builder.build
|
|
28
55
|
end
|
|
56
|
+
|
|
57
|
+
# Evaluates a string of DSL code and returns the resulting definition.
|
|
58
|
+
# Intended for loading flow definitions from files or stored text.
|
|
59
|
+
#
|
|
60
|
+
# @param text [String] Ruby source containing FlowEngine.define { ... }
|
|
61
|
+
# @return [Definition] the definition produced by evaluating the DSL
|
|
62
|
+
# @raise [DefinitionError] on syntax or evaluation errors
|
|
63
|
+
def self.load_dsl(text)
|
|
64
|
+
# rubocop:disable Security/Eval
|
|
65
|
+
eval(text, TOPLEVEL_BINDING.dup, "(dsl)", 1)
|
|
66
|
+
# rubocop:enable Security/Eval
|
|
67
|
+
rescue SyntaxError => e
|
|
68
|
+
raise DefinitionError, "DSL syntax error: #{e.message}"
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
raise DefinitionError, "DSL evaluation error: #{e.message}"
|
|
71
|
+
end
|
|
29
72
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: flowengine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Konstantin Gredeskoul
|
|
@@ -67,6 +67,7 @@ files:
|
|
|
67
67
|
- LICENSE.txt
|
|
68
68
|
- README.md
|
|
69
69
|
- Rakefile
|
|
70
|
+
- docs/floweingine-architecture.png
|
|
70
71
|
- docs/flowengine-example.png
|
|
71
72
|
- exe/flowengine
|
|
72
73
|
- justfile
|