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 +7 -0
- data/.envrc +2 -0
- data/.rubocop_todo.yml +36 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +165 -0
- data/Rakefile +36 -0
- data/docs/flowengine-example.png +0 -0
- data/exe/flowengine +4 -0
- data/justfile +37 -0
- data/lib/flowengine/definition.rb +32 -0
- data/lib/flowengine/dsl/flow_builder.rb +31 -0
- data/lib/flowengine/dsl/rule_helpers.rb +35 -0
- data/lib/flowengine/dsl/step_builder.rb +54 -0
- data/lib/flowengine/dsl.rb +10 -0
- data/lib/flowengine/engine.rb +46 -0
- data/lib/flowengine/errors.rb +10 -0
- data/lib/flowengine/evaluator.rb +17 -0
- data/lib/flowengine/graph/mermaid_exporter.rb +43 -0
- data/lib/flowengine/node.rb +29 -0
- data/lib/flowengine/rules/all.rb +23 -0
- data/lib/flowengine/rules/any.rb +23 -0
- data/lib/flowengine/rules/base.rb +15 -0
- data/lib/flowengine/rules/contains.rb +24 -0
- data/lib/flowengine/rules/equals.rb +24 -0
- data/lib/flowengine/rules/greater_than.rb +24 -0
- data/lib/flowengine/rules/less_than.rb +24 -0
- data/lib/flowengine/rules/not_empty.rb +27 -0
- data/lib/flowengine/transition.rb +23 -0
- data/lib/flowengine/validation/adapter.rb +25 -0
- data/lib/flowengine/validation/null_adapter.rb +11 -0
- data/lib/flowengine/version.rb +5 -0
- data/lib/flowengine.rb +29 -0
- data/sig/flowengine.rbs +4 -0
- metadata +123 -0
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
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
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
|
+
[](https://github.com/kigster/flowengine/actions/workflows/rspec.yml) [](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
|
+

|
|
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
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,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
|
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
|
data/sig/flowengine.rbs
ADDED
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: []
|