flowengine 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 249513128d6e8c8fcd501433415e9dc78976f1a94195dc45661a51157548ced3
4
+ data.tar.gz: 3deeef88dae2957f36ed04afe2e001b5886ec17700989e7e9a95f5222f4d2731
5
+ SHA512:
6
+ metadata.gz: b3f12e0ea863990914abe8ffad528ee53637148678c4d2fd044165b9d1c6a4454c4201bb9b208f4976db027a12380c0af786bd5a1e32fbb17109d679681276dc
7
+ data.tar.gz: d06bfe3f5384a8f5e674ce95dd12a1f2104b287b5db7b0c33b5f56125a65be2553deb6308223889fd78b7d545782c9621e661e1b82640c3968eadb93f3d28ce2
data/.envrc ADDED
@@ -0,0 +1,2 @@
1
+ PATH_add bin
2
+ PATH_add exe
data/.rubocop_todo.yml ADDED
@@ -0,0 +1,36 @@
1
+ # This configuration was generated by
2
+ # `rubocop --auto-gen-config`
3
+ # on 2026-02-27 03:50:14 UTC using RuboCop version 1.85.0.
4
+ # The point is for the user to remove these configuration records
5
+ # one by one as the offenses are removed from the code base.
6
+ # Note that changes in the inspected code, or installation of new
7
+ # versions of RuboCop, may require this file to be generated again.
8
+
9
+ # Offense count: 2
10
+ # Configuration parameters: EnforcedStyle, AllowedGems.
11
+ # SupportedStyles: Gemfile, gems.rb, gemspec
12
+ Gemspec/DevelopmentDependencies:
13
+ Exclude:
14
+ - 'flowengine.gemspec'
15
+
16
+ # Offense count: 1
17
+ # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
18
+ Metrics/AbcSize:
19
+ Max: 18
20
+
21
+ # Offense count: 1
22
+ # Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
23
+ Metrics/ParameterLists:
24
+ Max: 7
25
+
26
+ # Offense count: 5
27
+ # Configuration parameters: Mode, AllowedMethods, AllowedPatterns, AllowBangMethods, WaywardPredicates.
28
+ # AllowedMethods: call
29
+ # WaywardPredicates: infinite?, nonzero?
30
+ Naming/PredicateMethod:
31
+ Exclude:
32
+ - 'lib/flowengine/rules/contains.rb'
33
+ - 'lib/flowengine/rules/equals.rb'
34
+ - 'lib/flowengine/rules/greater_than.rb'
35
+ - 'lib/flowengine/rules/less_than.rb'
36
+ - 'lib/flowengine/rules/not_empty.rb'
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-02-26
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Konstantin Gredeskoul
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # Flowengine
2
+
3
+ [![RSpec](https://github.com/kigster/flowengine/actions/workflows/rspec.yml/badge.svg)](https://github.com/kigster/flowengine/actions/workflows/rspec.yml) [![RuboCop](https://github.com/kigster/flowengine/actions/workflows/rubocop.yml/badge.svg)](https://github.com/kigster/flowengine/actions/workflows/rubocop.yml)
4
+
5
+ > [!IMPORTANT]
6
+ >
7
+ > Gem's Responsibilities
8
+ >
9
+ > * DSL
10
+ > * Flow Definition
11
+ > * AST-based Rule system
12
+ > * Evaluator
13
+ > * Engine runtime
14
+ > * Validation adapter interface
15
+ > * Graph exporter (Mermaid)
16
+ > * Simulation runner
17
+ > * No ActiveRecord.
18
+ > * No Rails.
19
+ > * No terminal code.
20
+
21
+ ### Proposed Gem Structure
22
+
23
+ ```text
24
+ flowengine/
25
+ ├── lib/
26
+ │ ├── flowengine.rb
27
+ │ ├── flowengine/
28
+ │ │ ├── definition.rb
29
+ │ │ ├── dsl.rb
30
+ │ │ ├── node.rb
31
+ │ │ ├── rule_ast.rb
32
+ │ │ ├── evaluator.rb
33
+ │ │ ├── engine.rb
34
+ │ │ ├── validation/
35
+ │ │ │ ├── adapter.rb
36
+ │ │ │ └── dry_validation_adapter.rb
37
+ │ │ ├── graph/
38
+ │ │ │ └── mermaid_exporter.rb
39
+ │ │ └── simulation.rb
40
+ ├── exe/
41
+ │ └── flowengine
42
+ ```
43
+
44
+ #### Core Concepts
45
+
46
+ Immutable structure representing flow graph.
47
+
48
+ ```ruby
49
+ FlowEngine.define do
50
+ start :earnings
51
+
52
+ step :earnings do
53
+ type :multi_select
54
+ question "What are your main earnings?"
55
+ options %w[W2 1099 BusinessOwnership]
56
+
57
+ transition to: :business_details,
58
+ if: contains(:earnings, "BusinessOwnership")
59
+ end
60
+ end
61
+ ```
62
+
63
+ Definition compiles DSL → Node objects → AST transitions.
64
+
65
+ No runtime state.
66
+
67
+ #### Engine (Pure Runtime)
68
+
69
+ ```ruby
70
+ engine = FlowEngine::Engine.new(definition)
71
+
72
+ engine.current_step
73
+ engine.answer(value)
74
+ engine.finished?
75
+ engine.answers
76
+ ```
77
+
78
+ Engine stores:
79
+
80
+ * current node id
81
+ * answer hash
82
+ * evaluator
83
+
84
+ No IO.
85
+
86
+ #### Rule AST (Clean & Extensible)
87
+
88
+ You want AST objects, not hash blobs.
89
+
90
+ ```ruby
91
+ Contains.new(:earnings, "BusinessOwnership")
92
+ All.new(rule1, rule2)
93
+ Equals.new(:marital_status, "Married")
94
+ ```
95
+
96
+ Evaluator does polymorphic dispatch:
97
+
98
+ ```ruby
99
+ rule.evaluate(context)
100
+ ```
101
+
102
+ Cleaner than giant case statements.
103
+
104
+ #### Validation (Dry Integration)
105
+
106
+ Adapter pattern:
107
+
108
+ ```ruby
109
+ class DryValidationAdapter < Adapter
110
+ def validate(step, input)
111
+ step.schema.call(input)
112
+ end
113
+ end
114
+ ```
115
+
116
+ Core does:
117
+
118
+ ```ruby
119
+ validator.validate(step, input)
120
+ ```
121
+
122
+ IMPORTANT: Core does not depend directly on dry-validation.
123
+
124
+ ### Examples of Mermaid Charts
125
+
126
+ ![Example](docs/flowengine-example.png)
127
+
128
+ <details>
129
+ <summary>Expand to See Mermaid Sources</summary>
130
+
131
+ ```mermaid
132
+ flowchart BT
133
+ filing_status["What is your filing status for 2025?"] --> dependents["How many dependents do you have?"]
134
+ dependents --> income_types["Select all income types that apply to you in 2025."]
135
+ income_types -- Business in income_types --> business_count["How many total businesses do you own or are a part..."]
136
+ income_types -- Investment in income_types --> investment_details["What types of investments do you hold?"]
137
+ income_types -- Rental in income_types --> rental_details["Provide details about your rental properties."]
138
+ income_types --> state_filing["Which states do you need to file in?"]
139
+ business_count -- business_count > 2 --> complex_business_info["With more than 2 businesses, please provide your p..."]
140
+ business_count --> business_details["How many of each business type do you own?"]
141
+ complex_business_info --> business_details
142
+ business_details -- Investment in income_types --> investment_details
143
+ business_details -- Rental in income_types --> rental_details
144
+ business_details --> state_filing
145
+ investment_details -- Crypto in investment_details --> crypto_details["Please describe your cryptocurrency transactions (..."]
146
+ investment_details -- Rental in income_types --> rental_details
147
+ investment_details --> state_filing
148
+ crypto_details -- Rental in income_types --> rental_details
149
+ crypto_details --> state_filing
150
+ rental_details --> state_filing
151
+ state_filing --> foreign_accounts["Do you have any foreign financial accounts (bank a..."]
152
+ foreign_accounts -- "foreign_accounts == yes" --> foreign_account_details["How many foreign accounts do you have?"]
153
+ foreign_accounts --> deduction_types["Which additional deductions apply to you?"]
154
+ foreign_account_details --> deduction_types
155
+ deduction_types -- Charitable in deduction_types --> charitable_amount["What is your total estimated charitable contributi..."]
156
+ deduction_types --> contact_info["Please provide your contact information (name, ema..."]
157
+ charitable_amount -- charitable_amount > 5000 --> charitable_documentation["For charitable contributions over $5,000, please l..."]
158
+ charitable_amount --> contact_info
159
+ charitable_documentation --> contact_info
160
+ contact_info --> review@{ label: "Thank you! Please review your information. Type 'c..." }
161
+
162
+ review@{ shape: rect}
163
+ ```
164
+
165
+ </details>
data/Rakefile ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+ require 'timeout'
6
+ require 'yard'
7
+
8
+ def shell(*args)
9
+ puts "running: #{args.join(' ')}"
10
+ system(args.join(' '))
11
+ end
12
+
13
+ task :clean do
14
+ shell('rm -rf pkg/ tmp/ coverage/ doc/ ' )
15
+ end
16
+
17
+ task gem: [:build] do
18
+ shell('gem install pkg/*')
19
+ end
20
+
21
+ task permissions: [:clean] do
22
+ shell("chmod -v o+r,g+r * */* */*/* */*/*/* */*/*/*/* */*/*/*/*/*")
23
+ shell("find . -type d -exec chmod o+x,g+x {} \\;")
24
+ end
25
+
26
+ task build: :permissions
27
+
28
+ YARD::Rake::YardocTask.new(:doc) do |t|
29
+ t.files = %w(lib/**/*.rb exe/*.rb - README.md LICENSE.txt)
30
+ t.options.unshift('--title', '"FlowEngine — DSL + AST for buildiong complex flows in Ruby."')
31
+ t.after = -> { exec('open doc/index.html') } if RUBY_PLATFORM =~ /darwin/
32
+ end
33
+
34
+ RSpec::Core::RakeTask.new(:spec)
35
+
36
+ task default: :spec
Binary file
data/exe/flowengine ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "flowengine"
data/justfile ADDED
@@ -0,0 +1,37 @@
1
+ set shell := ["bash", "-c"]
2
+
3
+ set dotenv-load
4
+
5
+ test:
6
+ @bundle exec rspec
7
+ @bundle exec rubocop
8
+
9
+ # Setup Ruby dependencies
10
+ setup-ruby:
11
+ #!/usr/bin/env bash
12
+ [[ -d ~/.rbenv ]] || git clone https://github.com/rbenv/rbenv.git ~/.rbenv
13
+ [[ -d ~/.rbenv/plugins/ruby-build ]] || git clone https://github.com/rbenv/rbenv.git ~/.rbenv
14
+ cd ~/.rbenv/plugins/ruby-build && git pull && cd - >/dev/null
15
+ echo -n "Checking if Ruby $(cat .ruby-version | tr -d '\n') is already installed..."
16
+ rbenv install -s "$(cat .ruby-version | tr -d '\n')" >/dev/null 2>&1 && echo "yes" || echo "it wasn't, but now it is"
17
+ bundle check || bundle install -j 12
18
+
19
+ setup: setup-ruby
20
+
21
+ cli DATA SCHEMA *ARGS:
22
+ #!/usr/bin/env bash
23
+ cd .. && ./cli validate-json -f {{DATA}} -s {{SCHEMA}}
24
+
25
+ validate-valid-json:
26
+ ./cli validate-json -f VALID-JSON/sample.json -s VALID-JSON/schema.json
27
+
28
+ format:
29
+ @bundle exec rubocop -a
30
+ @bundle exec rubocop --auto-gen-config
31
+
32
+ lint:
33
+ @bundle exec rubocop
34
+
35
+
36
+ check-all: lint test
37
+
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ class Definition
5
+ attr_reader :start_step_id, :steps
6
+
7
+ def initialize(start_step_id:, nodes:)
8
+ @start_step_id = start_step_id
9
+ @steps = nodes.freeze
10
+ validate!
11
+ freeze
12
+ end
13
+
14
+ def start_step
15
+ step(start_step_id)
16
+ end
17
+
18
+ def step(id)
19
+ steps.fetch(id) { raise UnknownStepError, "Unknown step: #{id.inspect}" }
20
+ end
21
+
22
+ def step_ids
23
+ steps.keys
24
+ end
25
+
26
+ private
27
+
28
+ def validate!
29
+ raise DefinitionError, "Start step #{start_step_id.inspect} not found in nodes" unless steps.key?(start_step_id)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module DSL
5
+ class FlowBuilder
6
+ include RuleHelpers
7
+
8
+ def initialize
9
+ @start_step_id = nil
10
+ @nodes = {}
11
+ end
12
+
13
+ def start(step_id)
14
+ @start_step_id = step_id
15
+ end
16
+
17
+ def step(id, &)
18
+ builder = StepBuilder.new
19
+ builder.instance_eval(&)
20
+ @nodes[id] = builder.build(id)
21
+ end
22
+
23
+ def build
24
+ raise DefinitionError, "No start step defined" if @start_step_id.nil?
25
+ raise DefinitionError, "No steps defined" if @nodes.empty?
26
+
27
+ Definition.new(start_step_id: @start_step_id, nodes: @nodes)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module DSL
5
+ module RuleHelpers
6
+ def contains(field, value)
7
+ Rules::Contains.new(field, value)
8
+ end
9
+
10
+ def equals(field, value)
11
+ Rules::Equals.new(field, value)
12
+ end
13
+
14
+ def greater_than(field, value)
15
+ Rules::GreaterThan.new(field, value)
16
+ end
17
+
18
+ def less_than(field, value)
19
+ Rules::LessThan.new(field, value)
20
+ end
21
+
22
+ def not_empty(field)
23
+ Rules::NotEmpty.new(field)
24
+ end
25
+
26
+ def all(*rules)
27
+ Rules::All.new(*rules)
28
+ end
29
+
30
+ def any(*rules)
31
+ Rules::Any.new(*rules)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module DSL
5
+ class StepBuilder
6
+ include RuleHelpers
7
+
8
+ def initialize
9
+ @type = nil
10
+ @question = nil
11
+ @options = nil
12
+ @fields = nil
13
+ @transitions = []
14
+ @visibility_rule = nil
15
+ end
16
+
17
+ def type(value)
18
+ @type = value
19
+ end
20
+
21
+ def question(text)
22
+ @question = text
23
+ end
24
+
25
+ def options(list)
26
+ @options = list
27
+ end
28
+
29
+ def fields(list)
30
+ @fields = list
31
+ end
32
+
33
+ def transition(to:, if_rule: nil)
34
+ @transitions << Transition.new(target: to, rule: if_rule)
35
+ end
36
+
37
+ def visible_if(rule)
38
+ @visibility_rule = rule
39
+ end
40
+
41
+ def build(id)
42
+ Node.new(
43
+ id: id,
44
+ type: @type,
45
+ question: @question,
46
+ options: @options,
47
+ fields: @fields,
48
+ transitions: @transitions,
49
+ visibility_rule: @visibility_rule
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dsl/rule_helpers"
4
+ require_relative "dsl/step_builder"
5
+ require_relative "dsl/flow_builder"
6
+
7
+ module FlowEngine
8
+ module DSL
9
+ end
10
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ class Engine
5
+ attr_reader :definition, :answers, :history, :current_step_id
6
+
7
+ def initialize(definition, validator: Validation::NullAdapter.new)
8
+ @definition = definition
9
+ @answers = {}
10
+ @history = []
11
+ @current_step_id = definition.start_step_id
12
+ @validator = validator
13
+ @history << @current_step_id
14
+ end
15
+
16
+ def current_step
17
+ return nil if finished?
18
+
19
+ definition.step(@current_step_id)
20
+ end
21
+
22
+ def finished?
23
+ @current_step_id.nil?
24
+ end
25
+
26
+ def answer(value)
27
+ raise AlreadyFinishedError, "Flow is already finished" if finished?
28
+
29
+ result = @validator.validate(current_step, value)
30
+ raise ValidationError, "Validation failed: #{result.errors.join(", ")}" unless result.valid?
31
+
32
+ answers[@current_step_id] = value
33
+ advance_step
34
+ end
35
+
36
+ private
37
+
38
+ def advance_step
39
+ node = definition.step(@current_step_id)
40
+ next_id = node.next_step_id(answers)
41
+
42
+ @current_step_id = next_id
43
+ @history << next_id if next_id
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ class Error < StandardError; end
5
+ class DefinitionError < Error; end
6
+ class UnknownStepError < Error; end
7
+ class EngineError < Error; end
8
+ class AlreadyFinishedError < EngineError; end
9
+ class ValidationError < EngineError; end
10
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ class Evaluator
5
+ attr_reader :answers
6
+
7
+ def initialize(answers)
8
+ @answers = answers
9
+ end
10
+
11
+ def evaluate(rule)
12
+ return true if rule.nil?
13
+
14
+ rule.evaluate(answers)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module Graph
5
+ class MermaidExporter
6
+ MAX_LABEL_LENGTH = 50
7
+
8
+ attr_reader :definition
9
+
10
+ def initialize(definition)
11
+ @definition = definition
12
+ end
13
+
14
+ def export
15
+ lines = ["flowchart TD"]
16
+
17
+ definition.steps.each_value do |node|
18
+ lines << " #{node.id}[\"#{truncate(node.question)}\"]"
19
+
20
+ node.transitions.each do |transition|
21
+ label = transition.condition_label
22
+ lines << if label == "always"
23
+ " #{node.id} --> #{transition.target}"
24
+ else
25
+ " #{node.id} -->|\"#{label}\"| #{transition.target}"
26
+ end
27
+ end
28
+ end
29
+
30
+ lines.join("\n")
31
+ end
32
+
33
+ private
34
+
35
+ def truncate(text)
36
+ return "" if text.nil?
37
+ return text if text.length <= MAX_LABEL_LENGTH
38
+
39
+ "#{text[0, MAX_LABEL_LENGTH]}..."
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ class Node
5
+ attr_reader :id, :type, :question, :options, :fields, :transitions, :visibility_rule
6
+
7
+ def initialize(id:, type:, question:, options: nil, fields: nil, transitions: [], visibility_rule: nil)
8
+ @id = id
9
+ @type = type
10
+ @question = question
11
+ @options = options&.freeze
12
+ @fields = fields&.freeze
13
+ @transitions = transitions.freeze
14
+ @visibility_rule = visibility_rule
15
+ freeze
16
+ end
17
+
18
+ def next_step_id(answers)
19
+ match = transitions.find { |t| t.applies?(answers) }
20
+ match&.target
21
+ end
22
+
23
+ def visible?(answers)
24
+ return true if visibility_rule.nil?
25
+
26
+ visibility_rule.evaluate(answers)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module Rules
5
+ class All < Base
6
+ attr_reader :rules
7
+
8
+ def initialize(*rules)
9
+ super()
10
+ @rules = rules.flatten.freeze
11
+ freeze
12
+ end
13
+
14
+ def evaluate(answers)
15
+ rules.all? { |rule| rule.evaluate(answers) }
16
+ end
17
+
18
+ def to_s
19
+ "(#{rules.join(" AND ")})"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module Rules
5
+ class Any < Base
6
+ attr_reader :rules
7
+
8
+ def initialize(*rules)
9
+ super()
10
+ @rules = rules.flatten.freeze
11
+ freeze
12
+ end
13
+
14
+ def evaluate(answers)
15
+ rules.any? { |rule| rule.evaluate(answers) }
16
+ end
17
+
18
+ def to_s
19
+ "(#{rules.join(" OR ")})"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module Rules
5
+ class Base
6
+ def evaluate(_answers)
7
+ raise NotImplementedError, "#{self.class}#evaluate must be implemented"
8
+ end
9
+
10
+ def to_s
11
+ raise NotImplementedError, "#{self.class}#to_s must be implemented"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module Rules
5
+ class Contains < Base
6
+ attr_reader :field, :value
7
+
8
+ def initialize(field, value)
9
+ super()
10
+ @field = field
11
+ @value = value
12
+ freeze
13
+ end
14
+
15
+ def evaluate(answers)
16
+ Array(answers[field]).include?(value)
17
+ end
18
+
19
+ def to_s
20
+ "#{value} in #{field}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module Rules
5
+ class Equals < Base
6
+ attr_reader :field, :value
7
+
8
+ def initialize(field, value)
9
+ super()
10
+ @field = field
11
+ @value = value
12
+ freeze
13
+ end
14
+
15
+ def evaluate(answers)
16
+ answers[field] == value
17
+ end
18
+
19
+ def to_s
20
+ "#{field} == #{value}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module Rules
5
+ class GreaterThan < Base
6
+ attr_reader :field, :value
7
+
8
+ def initialize(field, value)
9
+ super()
10
+ @field = field
11
+ @value = value
12
+ freeze
13
+ end
14
+
15
+ def evaluate(answers)
16
+ answers[field].to_i > value
17
+ end
18
+
19
+ def to_s
20
+ "#{field} > #{value}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module Rules
5
+ class LessThan < Base
6
+ attr_reader :field, :value
7
+
8
+ def initialize(field, value)
9
+ super()
10
+ @field = field
11
+ @value = value
12
+ freeze
13
+ end
14
+
15
+ def evaluate(answers)
16
+ answers[field].to_i < value
17
+ end
18
+
19
+ def to_s
20
+ "#{field} < #{value}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module Rules
5
+ class NotEmpty < Base
6
+ attr_reader :field
7
+
8
+ def initialize(field)
9
+ super()
10
+ @field = field
11
+ freeze
12
+ end
13
+
14
+ def evaluate(answers)
15
+ val = answers[field]
16
+ return false if val.nil?
17
+ return false if val.respond_to?(:empty?) && val.empty?
18
+
19
+ true
20
+ end
21
+
22
+ def to_s
23
+ "#{field} is not empty"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ class Transition
5
+ attr_reader :target, :rule
6
+
7
+ def initialize(target:, rule: nil)
8
+ @target = target
9
+ @rule = rule
10
+ freeze
11
+ end
12
+
13
+ def condition_label
14
+ rule ? rule.to_s : "always"
15
+ end
16
+
17
+ def applies?(answers)
18
+ return true if rule.nil?
19
+
20
+ rule.evaluate(answers)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module Validation
5
+ class Result
6
+ attr_reader :errors
7
+
8
+ def initialize(valid:, errors: [])
9
+ @valid = valid
10
+ @errors = errors.freeze
11
+ freeze
12
+ end
13
+
14
+ def valid?
15
+ @valid
16
+ end
17
+ end
18
+
19
+ class Adapter
20
+ def validate(_node, _input)
21
+ raise NotImplementedError, "#{self.class}#validate must be implemented"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ module Validation
5
+ class NullAdapter < Adapter
6
+ def validate(_node, _input)
7
+ Result.new(valid: true, errors: [])
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowEngine
4
+ VERSION = "0.1.0"
5
+ end
data/lib/flowengine.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "flowengine/version"
4
+ require_relative "flowengine/errors"
5
+ require_relative "flowengine/rules/base"
6
+ require_relative "flowengine/rules/contains"
7
+ require_relative "flowengine/rules/equals"
8
+ require_relative "flowengine/rules/greater_than"
9
+ require_relative "flowengine/rules/less_than"
10
+ require_relative "flowengine/rules/not_empty"
11
+ require_relative "flowengine/rules/all"
12
+ require_relative "flowengine/rules/any"
13
+ require_relative "flowengine/evaluator"
14
+ require_relative "flowengine/transition"
15
+ require_relative "flowengine/node"
16
+ require_relative "flowengine/definition"
17
+ require_relative "flowengine/validation/adapter"
18
+ require_relative "flowengine/validation/null_adapter"
19
+ require_relative "flowengine/engine"
20
+ require_relative "flowengine/dsl"
21
+ require_relative "flowengine/graph/mermaid_exporter"
22
+
23
+ module FlowEngine
24
+ def self.define(&)
25
+ builder = DSL::FlowBuilder.new
26
+ builder.instance_eval(&)
27
+ builder.build
28
+ end
29
+ end
@@ -0,0 +1,4 @@
1
+ module Flowengine
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flowengine
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Konstantin Gredeskoul
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rspec-its
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: simplecov
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.22'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.22'
40
+ - !ruby/object:Gem::Dependency
41
+ name: yard
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ description: FlowEngine provides a DSL for defining multi-step flows as directed graphs
55
+ with conditional branching, an AST-based rule evaluator, and a pure-Ruby runtime
56
+ engine. No framework dependencies.
57
+ email:
58
+ - kigster@gmail.com
59
+ executables:
60
+ - flowengine
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - ".envrc"
65
+ - ".rubocop_todo.yml"
66
+ - CHANGELOG.md
67
+ - LICENSE.txt
68
+ - README.md
69
+ - Rakefile
70
+ - docs/flowengine-example.png
71
+ - exe/flowengine
72
+ - justfile
73
+ - lib/flowengine.rb
74
+ - lib/flowengine/definition.rb
75
+ - lib/flowengine/dsl.rb
76
+ - lib/flowengine/dsl/flow_builder.rb
77
+ - lib/flowengine/dsl/rule_helpers.rb
78
+ - lib/flowengine/dsl/step_builder.rb
79
+ - lib/flowengine/engine.rb
80
+ - lib/flowengine/errors.rb
81
+ - lib/flowengine/evaluator.rb
82
+ - lib/flowengine/graph/mermaid_exporter.rb
83
+ - lib/flowengine/node.rb
84
+ - lib/flowengine/rules/all.rb
85
+ - lib/flowengine/rules/any.rb
86
+ - lib/flowengine/rules/base.rb
87
+ - lib/flowengine/rules/contains.rb
88
+ - lib/flowengine/rules/equals.rb
89
+ - lib/flowengine/rules/greater_than.rb
90
+ - lib/flowengine/rules/less_than.rb
91
+ - lib/flowengine/rules/not_empty.rb
92
+ - lib/flowengine/transition.rb
93
+ - lib/flowengine/validation/adapter.rb
94
+ - lib/flowengine/validation/null_adapter.rb
95
+ - lib/flowengine/version.rb
96
+ - sig/flowengine.rbs
97
+ homepage: https://github.com/kigster/flowengine
98
+ licenses:
99
+ - MIT
100
+ metadata:
101
+ allowed_push_host: https://rubygems.org
102
+ homepage_uri: https://github.com/kigster/flowengine
103
+ source_code_uri: https://github.com/kigster/flowengine
104
+ changelog_uri: https://github.com/kigster/flowengine/blob/main/CHANGELOG.md
105
+ rubygems_mfa_required: 'true'
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: 4.0.1
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubygems_version: 4.0.3
121
+ specification_version: 4
122
+ summary: A declarative flow engine for building rules-driven wizards and intake forms
123
+ test_files: []