flowengine 0.1.0 → 0.1.1
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 +826 -81
- data/Rakefile +11 -11
- data/docs/floweingine-architecture.png +0 -0
- data/justfile +4 -0
- data/lib/flowengine/dsl/step_builder.rb +7 -1
- data/lib/flowengine/engine.rb +51 -0
- data/lib/flowengine/node.rb +9 -1
- data/lib/flowengine/version.rb +1 -1
- data/lib/flowengine.rb +10 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 50117723ca62a9851b0c8091e2619132d6e2f849e62b765dadd5c0d0d5c601ad
|
|
4
|
+
data.tar.gz: 0024ae08e3d6068d0ed913067368ddd4764a83f9ffe1f44e4d9bdcef4f544757
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8d3e5eb92cf044bed6e4a5408ed70452dc755234030e3a39785714864c09d2ba804592cca29005f304dab0fab95058f142602f0239e2f68ffa9826ef15bd748d
|
|
7
|
+
data.tar.gz: f889c3914f22d5a6cb4f439e202c318819e007a692f973bef4a288c4c4bbb34995aa65bf88bd5ddc799ee3ed684ae41066617300d7b3e67efe4e62437717149d
|
data/.rubocop_todo.yml
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# This configuration was generated by
|
|
2
2
|
# `rubocop --auto-gen-config`
|
|
3
|
-
# on 2026-
|
|
3
|
+
# on 2026-03-05 18:28:58 UTC using RuboCop version 1.85.0.
|
|
4
4
|
# The point is for the user to remove these configuration records
|
|
5
5
|
# one by one as the offenses are removed from the code base.
|
|
6
6
|
# Note that changes in the inspected code, or installation of new
|
|
7
7
|
# versions of RuboCop, may require this file to be generated again.
|
|
8
8
|
|
|
9
|
-
# Offense count:
|
|
9
|
+
# Offense count: 3
|
|
10
10
|
# Configuration parameters: EnforcedStyle, AllowedGems.
|
|
11
11
|
# SupportedStyles: Gemfile, gems.rb, gemspec
|
|
12
12
|
Gemspec/DevelopmentDependencies:
|
|
@@ -18,6 +18,11 @@ Gemspec/DevelopmentDependencies:
|
|
|
18
18
|
Metrics/AbcSize:
|
|
19
19
|
Max: 18
|
|
20
20
|
|
|
21
|
+
# Offense count: 1
|
|
22
|
+
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
|
23
|
+
Metrics/CyclomaticComplexity:
|
|
24
|
+
Max: 9
|
|
25
|
+
|
|
21
26
|
# Offense count: 1
|
|
22
27
|
# Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
|
|
23
28
|
Metrics/ParameterLists:
|
data/README.md
CHANGED
|
@@ -1,132 +1,856 @@
|
|
|
1
|
-
#
|
|
1
|
+
# FlowEngine
|
|
2
2
|
|
|
3
3
|
[](https://github.com/kigster/flowengine/actions/workflows/rspec.yml) [](https://github.com/kigster/flowengine/actions/workflows/rubocop.yml)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
> *
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
5
|
+
This gem is the foundation of collecting complex multi-branch information from a user using a flow definition written in Ruby DSL. It shouldn't take too long to learn the DSL even for a non-technical person.
|
|
6
|
+
|
|
7
|
+
This gem does not have any UI or an interactive component. It is used as the foundation for additional gems that are built on top of this one, and provide various interactive interfaces for colleting information based on the DSL definition.
|
|
8
|
+
|
|
9
|
+
The simplest way to see this in action is to use the companion gem [`flowengine-cli`](https://rubygems.org/gems/flowengine-cli), which, given the flow DSL will walk the user through the questioniare according to the DSL flow definition, but using terminal UI and ASCII-based flow.
|
|
10
|
+
|
|
11
|
+
A declarative flow engine for building rules-driven wizards and intake forms in pure Ruby.
|
|
12
|
+
|
|
13
|
+
FlowEngine lets you define multi-step flows as **directed graphs** with **conditional branching**, evaluate transitions using an **AST-based rule system**, and collect structured answers through a **stateful runtime engine** — all without framework dependencies.
|
|
14
|
+
|
|
15
|
+
> **This is not a form builder.** It's a *Form Definition Engine* that separates flow logic, data schema, and UI rendering into independent concerns.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
Add to your Gemfile:
|
|
47
20
|
|
|
48
21
|
```ruby
|
|
49
|
-
|
|
50
|
-
|
|
22
|
+
gem "flowengine"
|
|
23
|
+
```
|
|
51
24
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
25
|
+
Or install directly:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
gem install flowengine
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
require "flowengine"
|
|
56
35
|
|
|
57
|
-
|
|
58
|
-
|
|
36
|
+
# 1. Define a flow
|
|
37
|
+
definition = FlowEngine.define do
|
|
38
|
+
start :name
|
|
39
|
+
|
|
40
|
+
step :name do
|
|
41
|
+
type :text
|
|
42
|
+
question "What is your name?"
|
|
43
|
+
transition to: :age
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
step :age do
|
|
47
|
+
type :number
|
|
48
|
+
question "How old are you?"
|
|
49
|
+
transition to: :beverage, if_rule: greater_than(:age, 20)
|
|
50
|
+
transition to: :thanks
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
step :beverage do
|
|
54
|
+
type :single_select
|
|
55
|
+
question "Pick a drink."
|
|
56
|
+
options %w[Beer Wine Cocktail]
|
|
57
|
+
transition to: :thanks
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
step :thanks do
|
|
61
|
+
type :text
|
|
62
|
+
question "Thank you for your responses!"
|
|
59
63
|
end
|
|
60
64
|
end
|
|
65
|
+
|
|
66
|
+
# 2. Run the engine
|
|
67
|
+
engine = FlowEngine::Engine.new(definition)
|
|
68
|
+
|
|
69
|
+
engine.answer("Alice") # :name -> :age
|
|
70
|
+
engine.answer(25) # :age -> :beverage (25 > 20)
|
|
71
|
+
engine.answer("Wine") # :beverage -> :thanks
|
|
72
|
+
engine.answer("ok") # :thanks -> finished
|
|
73
|
+
|
|
74
|
+
engine.finished? # => true
|
|
75
|
+
engine.answers
|
|
76
|
+
# => { name: "Alice", age: 25, beverage: "Wine", thanks: "ok" }
|
|
77
|
+
engine.history
|
|
78
|
+
# => [:name, :age, :beverage, :thanks]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
If Alice were 18 instead, the engine would skip `:beverage` entirely:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
engine.answer("Alice") # :name -> :age
|
|
85
|
+
engine.answer(18) # :age -> :thanks (18 is NOT > 20)
|
|
86
|
+
engine.answer("ok") # :thanks -> finished
|
|
87
|
+
|
|
88
|
+
engine.answers
|
|
89
|
+
# => { name: "Alice", age: 18, thanks: "ok" }
|
|
90
|
+
engine.history
|
|
91
|
+
# => [:name, :age, :thanks]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Using the `flowengine-cli` gem to Generate the JSON Answers File
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
## Architecture
|
|
99
|
+
|
|
100
|
+

|
|
101
|
+
|
|
102
|
+
The core has **zero UI logic**, **zero DB logic**, and **zero framework dependencies**. Adapters translate input/output, persist state, and render UI.
|
|
103
|
+
|
|
104
|
+
### Core Components
|
|
105
|
+
|
|
106
|
+
| Component | Responsibility |
|
|
107
|
+
|-----------|---------------|
|
|
108
|
+
| `FlowEngine.define` | DSL entry point; returns a frozen `Definition` |
|
|
109
|
+
| `Definition` | Immutable container of the flow graph (nodes + start step) |
|
|
110
|
+
| `Node` | A single step: type, question, options/fields, transitions, visibility |
|
|
111
|
+
| `Transition` | A directed edge with an optional rule condition |
|
|
112
|
+
| `Rules::*` | AST nodes for conditional logic (`Contains`, `Equals`, `All`, etc.) |
|
|
113
|
+
| `Evaluator` | Evaluates rules against the current answer store |
|
|
114
|
+
| `Engine` | Stateful runtime: tracks current step, answers, and history |
|
|
115
|
+
| `Validation::Adapter` | Interface for pluggable validation (dry-validation, JSON Schema, etc.) |
|
|
116
|
+
| `Graph::MermaidExporter` | Exports the flow definition as a Mermaid diagram |
|
|
117
|
+
|
|
118
|
+
## The DSL
|
|
119
|
+
|
|
120
|
+
### Defining a Flow
|
|
121
|
+
|
|
122
|
+
Every flow starts with `FlowEngine.define`, which returns a **frozen, immutable** `Definition`:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
definition = FlowEngine.define do
|
|
126
|
+
start :first_step # Required: which node to begin at
|
|
127
|
+
|
|
128
|
+
step :first_step do
|
|
129
|
+
# step configuration...
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
step :second_step do
|
|
133
|
+
# step configuration...
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Step Configuration
|
|
139
|
+
|
|
140
|
+
Inside a `step` block, you have access to:
|
|
141
|
+
|
|
142
|
+
| Method | Purpose | Example |
|
|
143
|
+
|--------|---------|---------|
|
|
144
|
+
| `type` | The input type (for UI adapters) | `:text`, `:number`, `:single_select`, `:multi_select`, `:number_matrix` |
|
|
145
|
+
| `question` | The prompt shown to the user | `"What is your filing status?"` |
|
|
146
|
+
| `options` | Available choices (for select types) | `%w[W2 1099 Business]` |
|
|
147
|
+
| `fields` | Named fields (for matrix types) | `%w[RealEstate SCorp LLC]` |
|
|
148
|
+
| `transition` | Where to go next (with optional condition) | `transition to: :next_step, if_rule: equals(:field, "value")` |
|
|
149
|
+
| `visible_if` | Visibility rule (for DAG mode) | `visible_if contains(:income, "Rental")` |
|
|
150
|
+
|
|
151
|
+
### Step Types
|
|
152
|
+
|
|
153
|
+
Step types are semantic labels consumed by UI adapters. The engine itself is type-agnostic — it stores whatever value you pass to `engine.answer(value)`. The types communicate intent to renderers:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
step :filing_status do
|
|
157
|
+
type :single_select # One choice from a list
|
|
158
|
+
question "What is your filing status?"
|
|
159
|
+
options %w[single married_filing_jointly married_filing_separately head_of_household]
|
|
160
|
+
transition to: :dependents
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
step :income_types do
|
|
164
|
+
type :multi_select # Multiple choices from a list
|
|
165
|
+
question "Select all income types."
|
|
166
|
+
options %w[W2 1099 Business Investment Rental]
|
|
167
|
+
transition to: :business, if_rule: contains(:income_types, "Business")
|
|
168
|
+
transition to: :summary
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
step :dependents do
|
|
172
|
+
type :number # A numeric value
|
|
173
|
+
question "How many dependents?"
|
|
174
|
+
transition to: :income_types
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
step :business_details do
|
|
178
|
+
type :number_matrix # Multiple named numeric fields
|
|
179
|
+
question "How many of each business type?"
|
|
180
|
+
fields %w[RealEstate SCorp CCorp Trust LLC]
|
|
181
|
+
transition to: :summary
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
step :notes do
|
|
185
|
+
type :text # Free-form text
|
|
186
|
+
question "Any additional notes?"
|
|
187
|
+
transition to: :summary
|
|
188
|
+
end
|
|
61
189
|
```
|
|
62
190
|
|
|
63
|
-
|
|
191
|
+
### Transitions
|
|
64
192
|
|
|
65
|
-
|
|
193
|
+
Transitions define the edges of the flow graph. They are evaluated **in order** — the first matching transition wins:
|
|
66
194
|
|
|
67
|
-
|
|
195
|
+
```ruby
|
|
196
|
+
step :income_types do
|
|
197
|
+
type :multi_select
|
|
198
|
+
question "Select income types."
|
|
199
|
+
options %w[W2 1099 Business Investment Rental]
|
|
200
|
+
|
|
201
|
+
# Conditional transitions (checked in order)
|
|
202
|
+
transition to: :business_count, if_rule: contains(:income_types, "Business")
|
|
203
|
+
transition to: :investment_details, if_rule: contains(:income_types, "Investment")
|
|
204
|
+
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
|
|
205
|
+
|
|
206
|
+
# Unconditional fallback (always matches)
|
|
207
|
+
transition to: :state_filing
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**Key behavior:** Only the *first* matching transition fires. If the user selects `["Business", "Investment", "Rental"]`, the engine goes to `:business_count` first. The subsequent steps must themselves include transitions to eventually reach `:investment_details` and `:rental_details`.
|
|
212
|
+
|
|
213
|
+
A transition with no `if_rule:` always matches — use it as a fallback at the end of the list.
|
|
214
|
+
|
|
215
|
+
### Visibility Rules
|
|
216
|
+
|
|
217
|
+
Nodes can have visibility conditions for DAG-mode rendering, where a UI adapter shows/hides steps dynamically:
|
|
68
218
|
|
|
69
219
|
```ruby
|
|
220
|
+
step :spouse_income do
|
|
221
|
+
type :number
|
|
222
|
+
question "What is your spouse's annual income?"
|
|
223
|
+
visible_if equals(:filing_status, "married_filing_jointly")
|
|
224
|
+
transition to: :deductions
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The engine exposes this via `node.visible?(answers)`, which returns `true` when the rule is satisfied (or when no visibility rule is set).
|
|
229
|
+
|
|
230
|
+
## Rule System
|
|
231
|
+
|
|
232
|
+
Rules are **AST objects** — not hashes, not strings. They are immutable, composable, and evaluate polymorphically.
|
|
233
|
+
|
|
234
|
+
### Atomic Rules
|
|
235
|
+
|
|
236
|
+
| Rule | DSL Helper | Evaluates | String Representation |
|
|
237
|
+
|------|------------|-----------|----------------------|
|
|
238
|
+
| `Contains` | `contains(:field, "val")` | `Array(answers[:field]).include?("val")` | `val in field` |
|
|
239
|
+
| `Equals` | `equals(:field, "val")` | `answers[:field] == "val"` | `field == val` |
|
|
240
|
+
| `GreaterThan` | `greater_than(:field, 10)` | `answers[:field].to_i > 10` | `field > 10` |
|
|
241
|
+
| `LessThan` | `less_than(:field, 5)` | `answers[:field].to_i < 5` | `field < 5` |
|
|
242
|
+
| `NotEmpty` | `not_empty(:field)` | `answers[:field]` is not nil and not empty | `field is not empty` |
|
|
243
|
+
|
|
244
|
+
### Composite Rules
|
|
245
|
+
|
|
246
|
+
Combine atomic rules with boolean logic:
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
# AND — all conditions must be true
|
|
250
|
+
transition to: :special_review,
|
|
251
|
+
if_rule: all(
|
|
252
|
+
equals(:filing_status, "married_filing_jointly"),
|
|
253
|
+
contains(:income_types, "Business"),
|
|
254
|
+
greater_than(:business_count, 2)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# OR — at least one condition must be true
|
|
258
|
+
transition to: :alt_path,
|
|
259
|
+
if_rule: any(
|
|
260
|
+
contains(:income_types, "Investment"),
|
|
261
|
+
contains(:income_types, "Rental")
|
|
262
|
+
)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Composites nest arbitrarily:
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
transition to: :complex_review,
|
|
269
|
+
if_rule: all(
|
|
270
|
+
equals(:filing_status, "married_filing_jointly"),
|
|
271
|
+
any(
|
|
272
|
+
greater_than(:business_count, 3),
|
|
273
|
+
contains(:income_types, "Rental")
|
|
274
|
+
),
|
|
275
|
+
not_empty(:dependents)
|
|
276
|
+
)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### How Rules Evaluate
|
|
280
|
+
|
|
281
|
+
Every rule implements `evaluate(answers)` where `answers` is the engine's hash of `{ step_id => value }`:
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
rule = FlowEngine::Rules::Contains.new(:income_types, "Business")
|
|
285
|
+
rule.evaluate({ income_types: ["W2", "Business"] }) # => true
|
|
286
|
+
rule.evaluate({ income_types: ["W2"] }) # => false
|
|
287
|
+
|
|
288
|
+
rule = FlowEngine::Rules::All.new(
|
|
289
|
+
FlowEngine::Rules::Equals.new(:status, "married"),
|
|
290
|
+
FlowEngine::Rules::GreaterThan.new(:dependents, 0)
|
|
291
|
+
)
|
|
292
|
+
rule.evaluate({ status: "married", dependents: 2 }) # => true
|
|
293
|
+
rule.evaluate({ status: "single", dependents: 2 }) # => false
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## Engine API
|
|
297
|
+
|
|
298
|
+
### Creating and Running
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
definition = FlowEngine.define { ... }
|
|
70
302
|
engine = FlowEngine::Engine.new(definition)
|
|
303
|
+
```
|
|
71
304
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
305
|
+
### Methods
|
|
306
|
+
|
|
307
|
+
| Method | Returns | Description |
|
|
308
|
+
|--------|---------|-------------|
|
|
309
|
+
| `engine.current_step_id` | `Symbol` or `nil` | The ID of the current step |
|
|
310
|
+
| `engine.current_step` | `Node` or `nil` | The current Node object |
|
|
311
|
+
| `engine.answer(value)` | `nil` | Records the answer and advances |
|
|
312
|
+
| `engine.finished?` | `Boolean` | `true` when there are no more steps |
|
|
313
|
+
| `engine.answers` | `Hash` | All collected answers `{ step_id => value }` |
|
|
314
|
+
| `engine.history` | `Array<Symbol>` | Ordered list of visited step IDs |
|
|
315
|
+
| `engine.definition` | `Definition` | The immutable flow definition |
|
|
316
|
+
|
|
317
|
+
### Error Handling
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
# Answering after the flow is complete
|
|
321
|
+
engine.answer("extra")
|
|
322
|
+
# => raises FlowEngine::AlreadyFinishedError
|
|
323
|
+
|
|
324
|
+
# Referencing an unknown step in a definition
|
|
325
|
+
definition.step(:nonexistent)
|
|
326
|
+
# => raises FlowEngine::UnknownStepError
|
|
327
|
+
|
|
328
|
+
# Invalid definition (start step doesn't exist)
|
|
329
|
+
FlowEngine.define do
|
|
330
|
+
start :missing
|
|
331
|
+
step :other do
|
|
332
|
+
type :text
|
|
333
|
+
question "Hello"
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
# => raises FlowEngine::DefinitionError
|
|
76
337
|
```
|
|
77
338
|
|
|
78
|
-
|
|
339
|
+
## Validation
|
|
79
340
|
|
|
80
|
-
|
|
81
|
-
* answer hash
|
|
82
|
-
* evaluator
|
|
341
|
+
The engine accepts a pluggable validator via the adapter pattern. The core gem ships with a `NullAdapter` (always passes) and defines the interface for custom adapters:
|
|
83
342
|
|
|
84
|
-
|
|
343
|
+
```ruby
|
|
344
|
+
# The adapter interface
|
|
345
|
+
class FlowEngine::Validation::Adapter
|
|
346
|
+
def validate(node, input)
|
|
347
|
+
# Must return a FlowEngine::Validation::Result
|
|
348
|
+
raise NotImplementedError
|
|
349
|
+
end
|
|
350
|
+
end
|
|
85
351
|
|
|
86
|
-
|
|
352
|
+
# Result object
|
|
353
|
+
FlowEngine::Validation::Result.new(valid: true, errors: [])
|
|
354
|
+
FlowEngine::Validation::Result.new(valid: false, errors: ["must be a number"])
|
|
355
|
+
```
|
|
87
356
|
|
|
88
|
-
|
|
357
|
+
### Custom Validator Example
|
|
89
358
|
|
|
90
359
|
```ruby
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
360
|
+
class MyValidator < FlowEngine::Validation::Adapter
|
|
361
|
+
def validate(node, input)
|
|
362
|
+
errors = []
|
|
363
|
+
|
|
364
|
+
case node.type
|
|
365
|
+
when :number
|
|
366
|
+
errors << "must be a number" unless input.is_a?(Numeric)
|
|
367
|
+
when :single_select
|
|
368
|
+
errors << "invalid option" unless node.options&.include?(input)
|
|
369
|
+
when :multi_select
|
|
370
|
+
unless input.is_a?(Array) && input.all? { |v| node.options&.include?(v) }
|
|
371
|
+
errors << "invalid options"
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
FlowEngine::Validation::Result.new(valid: errors.empty?, errors: errors)
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
engine = FlowEngine::Engine.new(definition, validator: MyValidator.new)
|
|
380
|
+
engine.answer("not_a_number") # => raises FlowEngine::ValidationError
|
|
94
381
|
```
|
|
95
382
|
|
|
96
|
-
|
|
383
|
+
## Mermaid Diagram Export
|
|
384
|
+
|
|
385
|
+
Export any flow definition as a [Mermaid](https://mermaid.js.org/) flowchart:
|
|
97
386
|
|
|
98
387
|
```ruby
|
|
99
|
-
|
|
388
|
+
exporter = FlowEngine::Graph::MermaidExporter.new(definition)
|
|
389
|
+
puts exporter.export
|
|
100
390
|
```
|
|
101
391
|
|
|
102
|
-
|
|
392
|
+
Output:
|
|
103
393
|
|
|
104
|
-
|
|
394
|
+
```mermaid
|
|
395
|
+
flowchart TD
|
|
396
|
+
name["What is your name?"]
|
|
397
|
+
name --> age
|
|
398
|
+
age["How old are you?"]
|
|
399
|
+
age -->|"age > 20"| beverage
|
|
400
|
+
age --> thanks
|
|
401
|
+
beverage["Pick a drink."]
|
|
402
|
+
beverage --> thanks
|
|
403
|
+
thanks["Thank you for your responses!"]
|
|
404
|
+
```
|
|
105
405
|
|
|
106
|
-
|
|
406
|
+
## Complete Example: Tax Intake Flow
|
|
407
|
+
|
|
408
|
+
Here's a realistic 17-step tax intake wizard that demonstrates every feature of the DSL.
|
|
409
|
+
|
|
410
|
+
### Flow Definition
|
|
107
411
|
|
|
108
412
|
```ruby
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
413
|
+
tax_intake = FlowEngine.define do
|
|
414
|
+
start :filing_status
|
|
415
|
+
|
|
416
|
+
step :filing_status do
|
|
417
|
+
type :single_select
|
|
418
|
+
question "What is your filing status for 2025?"
|
|
419
|
+
options %w[single married_filing_jointly married_filing_separately head_of_household]
|
|
420
|
+
transition to: :dependents
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
step :dependents do
|
|
424
|
+
type :number
|
|
425
|
+
question "How many dependents do you have?"
|
|
426
|
+
transition to: :income_types
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
step :income_types do
|
|
430
|
+
type :multi_select
|
|
431
|
+
question "Select all income types that apply to you in 2025."
|
|
432
|
+
options %w[W2 1099 Business Investment Rental Retirement]
|
|
433
|
+
transition to: :business_count, if_rule: contains(:income_types, "Business")
|
|
434
|
+
transition to: :investment_details, if_rule: contains(:income_types, "Investment")
|
|
435
|
+
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
|
|
436
|
+
transition to: :state_filing
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
step :business_count do
|
|
440
|
+
type :number
|
|
441
|
+
question "How many total businesses do you own or are a partner in?"
|
|
442
|
+
transition to: :complex_business_info, if_rule: greater_than(:business_count, 2)
|
|
443
|
+
transition to: :business_details
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
step :complex_business_info do
|
|
447
|
+
type :text
|
|
448
|
+
question "With more than 2 businesses, please provide your primary EIN and a brief description of each entity."
|
|
449
|
+
transition to: :business_details
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
step :business_details do
|
|
453
|
+
type :number_matrix
|
|
454
|
+
question "How many of each business type do you own?"
|
|
455
|
+
fields %w[RealEstate SCorp CCorp Trust LLC]
|
|
456
|
+
transition to: :investment_details, if_rule: contains(:income_types, "Investment")
|
|
457
|
+
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
|
|
458
|
+
transition to: :state_filing
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
step :investment_details do
|
|
462
|
+
type :multi_select
|
|
463
|
+
question "What types of investments do you hold?"
|
|
464
|
+
options %w[Stocks Bonds Crypto RealEstate MutualFunds]
|
|
465
|
+
transition to: :crypto_details, if_rule: contains(:investment_details, "Crypto")
|
|
466
|
+
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
|
|
467
|
+
transition to: :state_filing
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
step :crypto_details do
|
|
471
|
+
type :text
|
|
472
|
+
question "Please describe your cryptocurrency transactions (exchanges used, approximate number of transactions)."
|
|
473
|
+
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
|
|
474
|
+
transition to: :state_filing
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
step :rental_details do
|
|
478
|
+
type :number_matrix
|
|
479
|
+
question "Provide details about your rental properties."
|
|
480
|
+
fields %w[Residential Commercial Vacation]
|
|
481
|
+
transition to: :state_filing
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
step :state_filing do
|
|
485
|
+
type :multi_select
|
|
486
|
+
question "Which states do you need to file in?"
|
|
487
|
+
options %w[California NewYork Texas Florida Illinois Other]
|
|
488
|
+
transition to: :foreign_accounts
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
step :foreign_accounts do
|
|
492
|
+
type :single_select
|
|
493
|
+
question "Do you have any foreign financial accounts (bank accounts, securities, or financial assets)?"
|
|
494
|
+
options %w[yes no]
|
|
495
|
+
transition to: :foreign_account_details, if_rule: equals(:foreign_accounts, "yes")
|
|
496
|
+
transition to: :deduction_types
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
step :foreign_account_details do
|
|
500
|
+
type :number
|
|
501
|
+
question "How many foreign accounts do you have?"
|
|
502
|
+
transition to: :deduction_types
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
step :deduction_types do
|
|
506
|
+
type :multi_select
|
|
507
|
+
question "Which additional deductions apply to you?"
|
|
508
|
+
options %w[Medical Charitable Education Mortgage None]
|
|
509
|
+
transition to: :charitable_amount, if_rule: contains(:deduction_types, "Charitable")
|
|
510
|
+
transition to: :contact_info
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
step :charitable_amount do
|
|
514
|
+
type :number
|
|
515
|
+
question "What is your total estimated charitable contribution amount for 2025?"
|
|
516
|
+
transition to: :charitable_documentation, if_rule: greater_than(:charitable_amount, 5000)
|
|
517
|
+
transition to: :contact_info
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
step :charitable_documentation do
|
|
521
|
+
type :text
|
|
522
|
+
question "For charitable contributions over $5,000, please list the organizations and amounts."
|
|
523
|
+
transition to: :contact_info
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
step :contact_info do
|
|
527
|
+
type :text
|
|
528
|
+
question "Please provide your contact information (name, email, phone)."
|
|
529
|
+
transition to: :review
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
step :review do
|
|
533
|
+
type :text
|
|
534
|
+
question "Thank you! Please review your information. Type 'confirm' to submit."
|
|
112
535
|
end
|
|
113
536
|
end
|
|
114
537
|
```
|
|
115
538
|
|
|
116
|
-
|
|
539
|
+
### Scenario 1: Maximum Path (17 steps visited)
|
|
540
|
+
|
|
541
|
+
A married filer with all income types, 4 businesses, crypto, rentals, foreign accounts, and high charitable giving:
|
|
117
542
|
|
|
118
543
|
```ruby
|
|
119
|
-
|
|
544
|
+
engine = FlowEngine::Engine.new(tax_intake)
|
|
545
|
+
|
|
546
|
+
engine.answer("married_filing_jointly")
|
|
547
|
+
engine.answer(3)
|
|
548
|
+
engine.answer(%w[W2 1099 Business Investment Rental Retirement])
|
|
549
|
+
engine.answer(4)
|
|
550
|
+
engine.answer("EIN: 12-3456789. Entities: Alpha LLC, Beta SCorp, Gamma LLC, Delta CCorp")
|
|
551
|
+
engine.answer({ "RealEstate" => 1, "SCorp" => 1, "CCorp" => 1, "Trust" => 0, "LLC" => 2 })
|
|
552
|
+
engine.answer(%w[Stocks Bonds Crypto RealEstate])
|
|
553
|
+
engine.answer("Coinbase and Kraken, approximately 150 transactions in 2025")
|
|
554
|
+
engine.answer({ "Residential" => 2, "Commercial" => 1, "Vacation" => 0 })
|
|
555
|
+
engine.answer(%w[California NewYork])
|
|
556
|
+
engine.answer("yes")
|
|
557
|
+
engine.answer(3)
|
|
558
|
+
engine.answer(%w[Medical Charitable Education Mortgage])
|
|
559
|
+
engine.answer(12_000)
|
|
560
|
+
engine.answer("Red Cross: $5,000; Habitat for Humanity: $4,000; Local Food Bank: $3,000")
|
|
561
|
+
engine.answer("Jane Smith, jane.smith@example.com, 555-123-4567")
|
|
562
|
+
engine.answer("confirm")
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
**Collected data:**
|
|
566
|
+
|
|
567
|
+
```json
|
|
568
|
+
{
|
|
569
|
+
"filing_status": "married_filing_jointly",
|
|
570
|
+
"dependents": 3,
|
|
571
|
+
"income_types": ["W2", "1099", "Business", "Investment", "Rental", "Retirement"],
|
|
572
|
+
"business_count": 4,
|
|
573
|
+
"complex_business_info": "EIN: 12-3456789. Entities: Alpha LLC, Beta SCorp, Gamma LLC, Delta CCorp",
|
|
574
|
+
"business_details": {
|
|
575
|
+
"RealEstate": 1,
|
|
576
|
+
"SCorp": 1,
|
|
577
|
+
"CCorp": 1,
|
|
578
|
+
"Trust": 0,
|
|
579
|
+
"LLC": 2
|
|
580
|
+
},
|
|
581
|
+
"investment_details": ["Stocks", "Bonds", "Crypto", "RealEstate"],
|
|
582
|
+
"crypto_details": "Coinbase and Kraken, approximately 150 transactions in 2025",
|
|
583
|
+
"rental_details": {
|
|
584
|
+
"Residential": 2,
|
|
585
|
+
"Commercial": 1,
|
|
586
|
+
"Vacation": 0
|
|
587
|
+
},
|
|
588
|
+
"state_filing": ["California", "NewYork"],
|
|
589
|
+
"foreign_accounts": "yes",
|
|
590
|
+
"foreign_account_details": 3,
|
|
591
|
+
"deduction_types": ["Medical", "Charitable", "Education", "Mortgage"],
|
|
592
|
+
"charitable_amount": 12000,
|
|
593
|
+
"charitable_documentation": "Red Cross: $5,000; Habitat for Humanity: $4,000; Local Food Bank: $3,000",
|
|
594
|
+
"contact_info": "Jane Smith, jane.smith@example.com, 555-123-4567",
|
|
595
|
+
"review": "confirm"
|
|
596
|
+
}
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
**Path taken** (all 17 steps):
|
|
600
|
+
|
|
601
|
+
```
|
|
602
|
+
filing_status -> dependents -> income_types -> business_count ->
|
|
603
|
+
complex_business_info -> business_details -> investment_details ->
|
|
604
|
+
crypto_details -> rental_details -> state_filing -> foreign_accounts ->
|
|
605
|
+
foreign_account_details -> deduction_types -> charitable_amount ->
|
|
606
|
+
charitable_documentation -> contact_info -> review
|
|
120
607
|
```
|
|
121
608
|
|
|
122
|
-
|
|
609
|
+
### Scenario 2: Minimum Path (8 steps visited)
|
|
123
610
|
|
|
124
|
-
|
|
611
|
+
A single filer, W2 income only, no special deductions:
|
|
612
|
+
|
|
613
|
+
```ruby
|
|
614
|
+
engine = FlowEngine::Engine.new(tax_intake)
|
|
615
|
+
|
|
616
|
+
engine.answer("single")
|
|
617
|
+
engine.answer(0)
|
|
618
|
+
engine.answer(["W2"])
|
|
619
|
+
engine.answer(["Texas"])
|
|
620
|
+
engine.answer("no")
|
|
621
|
+
engine.answer(["None"])
|
|
622
|
+
engine.answer("John Doe, john.doe@example.com, 555-987-6543")
|
|
623
|
+
engine.answer("confirm")
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
**Collected data:**
|
|
627
|
+
|
|
628
|
+
```json
|
|
629
|
+
{
|
|
630
|
+
"filing_status": "single",
|
|
631
|
+
"dependents": 0,
|
|
632
|
+
"income_types": ["W2"],
|
|
633
|
+
"state_filing": ["Texas"],
|
|
634
|
+
"foreign_accounts": "no",
|
|
635
|
+
"deduction_types": ["None"],
|
|
636
|
+
"contact_info": "John Doe, john.doe@example.com, 555-987-6543",
|
|
637
|
+
"review": "confirm"
|
|
638
|
+
}
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
**Path taken** (8 steps — skipped 9 steps):
|
|
642
|
+
|
|
643
|
+
```
|
|
644
|
+
filing_status -> dependents -> income_types -> state_filing ->
|
|
645
|
+
foreign_accounts -> deduction_types -> contact_info -> review
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
**Skipped:** `business_count`, `complex_business_info`, `business_details`, `investment_details`, `crypto_details`, `rental_details`, `foreign_account_details`, `charitable_amount`, `charitable_documentation`.
|
|
649
|
+
|
|
650
|
+
### Scenario 3: Medium Path (12 steps visited)
|
|
651
|
+
|
|
652
|
+
Married, with business + investment income, low charitable giving:
|
|
653
|
+
|
|
654
|
+
```ruby
|
|
655
|
+
engine = FlowEngine::Engine.new(tax_intake)
|
|
656
|
+
|
|
657
|
+
engine.answer("married_filing_separately")
|
|
658
|
+
engine.answer(1)
|
|
659
|
+
engine.answer(%w[W2 Business Investment])
|
|
660
|
+
engine.answer(2) # <= 2 businesses, no complex_business_info
|
|
661
|
+
engine.answer({ "RealEstate" => 0, "SCorp" => 1, "CCorp" => 0, "Trust" => 0, "LLC" => 1 })
|
|
662
|
+
engine.answer(%w[Stocks Bonds MutualFunds]) # no Crypto, no crypto_details
|
|
663
|
+
engine.answer(%w[California Illinois])
|
|
664
|
+
engine.answer("no") # no foreign accounts
|
|
665
|
+
engine.answer(%w[Charitable Mortgage])
|
|
666
|
+
engine.answer(3000) # <= 5000, no documentation needed
|
|
667
|
+
engine.answer("Alice Johnson, alice.j@example.com, 555-555-0100")
|
|
668
|
+
engine.answer("confirm")
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
**Collected data:**
|
|
672
|
+
|
|
673
|
+
```json
|
|
674
|
+
{
|
|
675
|
+
"filing_status": "married_filing_separately",
|
|
676
|
+
"dependents": 1,
|
|
677
|
+
"income_types": ["W2", "Business", "Investment"],
|
|
678
|
+
"business_count": 2,
|
|
679
|
+
"business_details": {
|
|
680
|
+
"RealEstate": 0,
|
|
681
|
+
"SCorp": 1,
|
|
682
|
+
"CCorp": 0,
|
|
683
|
+
"Trust": 0,
|
|
684
|
+
"LLC": 1
|
|
685
|
+
},
|
|
686
|
+
"investment_details": ["Stocks", "Bonds", "MutualFunds"],
|
|
687
|
+
"state_filing": ["California", "Illinois"],
|
|
688
|
+
"foreign_accounts": "no",
|
|
689
|
+
"deduction_types": ["Charitable", "Mortgage"],
|
|
690
|
+
"charitable_amount": 3000,
|
|
691
|
+
"contact_info": "Alice Johnson, alice.j@example.com, 555-555-0100",
|
|
692
|
+
"review": "confirm"
|
|
693
|
+
}
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
**Path taken** (12 steps):
|
|
697
|
+
|
|
698
|
+
```
|
|
699
|
+
filing_status -> dependents -> income_types -> business_count ->
|
|
700
|
+
business_details -> investment_details -> state_filing ->
|
|
701
|
+
foreign_accounts -> deduction_types -> charitable_amount ->
|
|
702
|
+
contact_info -> review
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
### Scenario 4: Rental + Foreign Accounts (10 steps visited)
|
|
706
|
+
|
|
707
|
+
Head of household, 1099 + rental income, foreign accounts, no charitable:
|
|
708
|
+
|
|
709
|
+
```ruby
|
|
710
|
+
engine = FlowEngine::Engine.new(tax_intake)
|
|
711
|
+
|
|
712
|
+
engine.answer("head_of_household")
|
|
713
|
+
engine.answer(2)
|
|
714
|
+
engine.answer(%w[1099 Rental])
|
|
715
|
+
engine.answer({ "Residential" => 1, "Commercial" => 0, "Vacation" => 1 })
|
|
716
|
+
engine.answer(["Florida"])
|
|
717
|
+
engine.answer("yes")
|
|
718
|
+
engine.answer(1)
|
|
719
|
+
engine.answer(%w[Medical Education])
|
|
720
|
+
engine.answer("Bob Lee, bob@example.com, 555-000-1111")
|
|
721
|
+
engine.answer("confirm")
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
**Collected data:**
|
|
725
|
+
|
|
726
|
+
```json
|
|
727
|
+
{
|
|
728
|
+
"filing_status": "head_of_household",
|
|
729
|
+
"dependents": 2,
|
|
730
|
+
"income_types": ["1099", "Rental"],
|
|
731
|
+
"rental_details": {
|
|
732
|
+
"Residential": 1,
|
|
733
|
+
"Commercial": 0,
|
|
734
|
+
"Vacation": 1
|
|
735
|
+
},
|
|
736
|
+
"state_filing": ["Florida"],
|
|
737
|
+
"foreign_accounts": "yes",
|
|
738
|
+
"foreign_account_details": 1,
|
|
739
|
+
"deduction_types": ["Medical", "Education"],
|
|
740
|
+
"contact_info": "Bob Lee, bob@example.com, 555-000-1111",
|
|
741
|
+
"review": "confirm"
|
|
742
|
+
}
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
**Path taken** (10 steps):
|
|
746
|
+
|
|
747
|
+
```
|
|
748
|
+
filing_status -> dependents -> income_types -> rental_details ->
|
|
749
|
+
state_filing -> foreign_accounts -> foreign_account_details ->
|
|
750
|
+
deduction_types -> contact_info -> review
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
### Comparing Collected Data Across Paths
|
|
754
|
+
|
|
755
|
+
The shape of the collected data depends entirely on which path the user takes through the graph. Here's a side-by-side of which keys appear in each scenario:
|
|
756
|
+
|
|
757
|
+
| Answer Key | Max (17) | Min (8) | Medium (12) | Rental (10) |
|
|
758
|
+
|---|:---:|:---:|:---:|:---:|
|
|
759
|
+
| `filing_status` | x | x | x | x |
|
|
760
|
+
| `dependents` | x | x | x | x |
|
|
761
|
+
| `income_types` | x | x | x | x |
|
|
762
|
+
| `business_count` | x | | x | |
|
|
763
|
+
| `complex_business_info` | x | | | |
|
|
764
|
+
| `business_details` | x | | x | |
|
|
765
|
+
| `investment_details` | x | | x | |
|
|
766
|
+
| `crypto_details` | x | | | |
|
|
767
|
+
| `rental_details` | x | | | x |
|
|
768
|
+
| `state_filing` | x | x | x | x |
|
|
769
|
+
| `foreign_accounts` | x | x | x | x |
|
|
770
|
+
| `foreign_account_details` | x | | | x |
|
|
771
|
+
| `deduction_types` | x | x | x | x |
|
|
772
|
+
| `charitable_amount` | x | | x | |
|
|
773
|
+
| `charitable_documentation` | x | | | |
|
|
774
|
+
| `contact_info` | x | x | x | x |
|
|
775
|
+
| `review` | x | x | x | x |
|
|
776
|
+
|
|
777
|
+
## Composing Complex Rules
|
|
778
|
+
|
|
779
|
+
### Example: Multi-Condition Branching
|
|
780
|
+
|
|
781
|
+
```ruby
|
|
782
|
+
definition = FlowEngine.define do
|
|
783
|
+
start :status
|
|
784
|
+
|
|
785
|
+
step :status do
|
|
786
|
+
type :single_select
|
|
787
|
+
question "Filing status?"
|
|
788
|
+
options %w[single married]
|
|
789
|
+
transition to: :dependents
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
step :dependents do
|
|
793
|
+
type :number
|
|
794
|
+
question "How many dependents?"
|
|
795
|
+
transition to: :income
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
step :income do
|
|
799
|
+
type :multi_select
|
|
800
|
+
question "Income types?"
|
|
801
|
+
options %w[W2 Business]
|
|
802
|
+
|
|
803
|
+
# All three conditions must be true
|
|
804
|
+
transition to: :special_review,
|
|
805
|
+
if_rule: all(
|
|
806
|
+
equals(:status, "married"),
|
|
807
|
+
contains(:income, "Business"),
|
|
808
|
+
not_empty(:income)
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
# At least one condition must be true
|
|
812
|
+
transition to: :alt_review,
|
|
813
|
+
if_rule: any(
|
|
814
|
+
less_than(:dependents, 2),
|
|
815
|
+
contains(:income, "W2")
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
# Unconditional fallback
|
|
819
|
+
transition to: :default_review
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
step :special_review do
|
|
823
|
+
type :text
|
|
824
|
+
question "Married with business income - special review required."
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
step :alt_review do
|
|
828
|
+
type :text
|
|
829
|
+
question "Alternative review path."
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
step :default_review do
|
|
833
|
+
type :text
|
|
834
|
+
question "Default review."
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
The four possible outcomes:
|
|
840
|
+
|
|
841
|
+
| Inputs | Path | Why |
|
|
842
|
+
|--------|------|-----|
|
|
843
|
+
| married, 0 deps, `[W2, Business]` | `:special_review` | `all()` satisfied: married + Business + not empty |
|
|
844
|
+
| single, 0 deps, `[W2]` | `:alt_review` | `all()` fails (not married); `any()` passes (has W2) |
|
|
845
|
+
| single, 1 dep, `[Business]` | `:alt_review` | `all()` fails; `any()` passes (deps < 2) |
|
|
846
|
+
| single, 3 deps, `[Business]` | `:default_review` | `all()` fails; `any()` fails (deps not < 2, no W2) |
|
|
847
|
+
|
|
848
|
+
## Mermaid Diagram of the Tax Intake Flow
|
|
125
849
|
|
|
126
850
|

|
|
127
851
|
|
|
128
852
|
<details>
|
|
129
|
-
<summary>Expand to
|
|
853
|
+
<summary>Expand to see Mermaid source</summary>
|
|
130
854
|
|
|
131
855
|
```mermaid
|
|
132
856
|
flowchart BT
|
|
@@ -163,3 +887,24 @@ flowchart BT
|
|
|
163
887
|
```
|
|
164
888
|
|
|
165
889
|
</details>
|
|
890
|
+
|
|
891
|
+
## Ecosystem
|
|
892
|
+
|
|
893
|
+
FlowEngine is the core of a three-gem architecture:
|
|
894
|
+
|
|
895
|
+
| Gem | Purpose |
|
|
896
|
+
|-----|---------|
|
|
897
|
+
| **`flowengine`** (this gem) | Core engine — pure Ruby, no Rails, no DB, no UI |
|
|
898
|
+
| **`flowengine-cli`** | Terminal wizard adapter using [TTY Toolkit](https://ttytoolkit.org/) + Dry::CLI |
|
|
899
|
+
| **`flowengine-rails`** | Rails Engine with ActiveRecord persistence and web views |
|
|
900
|
+
|
|
901
|
+
## Development
|
|
902
|
+
|
|
903
|
+
```bash
|
|
904
|
+
bundle install
|
|
905
|
+
bundle exec rspec
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
## License
|
|
909
|
+
|
|
910
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "yard"
|
|
7
7
|
|
|
8
8
|
def shell(*args)
|
|
9
|
-
puts "running: #{args.join(
|
|
10
|
-
system(args.join(
|
|
9
|
+
puts "running: #{args.join(" ")}"
|
|
10
|
+
system(args.join(" "))
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
task :clean do
|
|
14
|
-
shell(
|
|
14
|
+
shell("rm -rf pkg/ tmp/ coverage/ doc/ ")
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
task gem: [:build] do
|
|
18
|
-
shell(
|
|
18
|
+
shell("gem install pkg/*")
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
task permissions: [:clean] do
|
|
@@ -26,9 +26,9 @@ end
|
|
|
26
26
|
task build: :permissions
|
|
27
27
|
|
|
28
28
|
YARD::Rake::YardocTask.new(:doc) do |t|
|
|
29
|
-
t.files = %w
|
|
30
|
-
t.options.unshift(
|
|
31
|
-
t.after = -> { exec(
|
|
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
32
|
end
|
|
33
33
|
|
|
34
34
|
RSpec::Core::RakeTask.new(:spec)
|
|
Binary file
|
data/justfile
CHANGED
|
@@ -12,6 +12,7 @@ module FlowEngine
|
|
|
12
12
|
@fields = nil
|
|
13
13
|
@transitions = []
|
|
14
14
|
@visibility_rule = nil
|
|
15
|
+
@decorations = nil
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def type(value)
|
|
@@ -30,6 +31,10 @@ module FlowEngine
|
|
|
30
31
|
@fields = list
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
def decorations(decorations)
|
|
35
|
+
@decorations = decorations
|
|
36
|
+
end
|
|
37
|
+
|
|
33
38
|
def transition(to:, if_rule: nil)
|
|
34
39
|
@transitions << Transition.new(target: to, rule: if_rule)
|
|
35
40
|
end
|
|
@@ -46,7 +51,8 @@ module FlowEngine
|
|
|
46
51
|
options: @options,
|
|
47
52
|
fields: @fields,
|
|
48
53
|
transitions: @transitions,
|
|
49
|
-
visibility_rule: @visibility_rule
|
|
54
|
+
visibility_rule: @visibility_rule,
|
|
55
|
+
decorations: @decorations
|
|
50
56
|
)
|
|
51
57
|
end
|
|
52
58
|
end
|
data/lib/flowengine/engine.rb
CHANGED
|
@@ -33,8 +33,59 @@ module FlowEngine
|
|
|
33
33
|
advance_step
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
def to_state
|
|
37
|
+
{
|
|
38
|
+
current_step_id: @current_step_id,
|
|
39
|
+
answers: @answers,
|
|
40
|
+
history: @history
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.from_state(definition, state_hash, validator: Validation::NullAdapter.new)
|
|
45
|
+
state = symbolize_state(state_hash)
|
|
46
|
+
engine = allocate
|
|
47
|
+
engine.send(:restore_state, definition, state, validator)
|
|
48
|
+
engine
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.symbolize_state(hash)
|
|
52
|
+
return hash unless hash.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
55
|
+
sym_key = key.to_sym
|
|
56
|
+
result[sym_key] = case sym_key
|
|
57
|
+
when :current_step_id
|
|
58
|
+
value&.to_sym
|
|
59
|
+
when :history
|
|
60
|
+
Array(value).map { |v| v&.to_sym }
|
|
61
|
+
when :answers
|
|
62
|
+
symbolize_answers(value)
|
|
63
|
+
else
|
|
64
|
+
value
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.symbolize_answers(answers)
|
|
70
|
+
return {} unless answers.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
answers.each_with_object({}) do |(key, value), result|
|
|
73
|
+
result[key.to_sym] = value
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private_class_method :symbolize_state, :symbolize_answers
|
|
78
|
+
|
|
36
79
|
private
|
|
37
80
|
|
|
81
|
+
def restore_state(definition, state, validator)
|
|
82
|
+
@definition = definition
|
|
83
|
+
@validator = validator
|
|
84
|
+
@current_step_id = state[:current_step_id]
|
|
85
|
+
@answers = state[:answers] || {}
|
|
86
|
+
@history = state[:history] || []
|
|
87
|
+
end
|
|
88
|
+
|
|
38
89
|
def advance_step
|
|
39
90
|
node = definition.step(@current_step_id)
|
|
40
91
|
next_id = node.next_step_id(answers)
|
data/lib/flowengine/node.rb
CHANGED
|
@@ -4,10 +4,18 @@ module FlowEngine
|
|
|
4
4
|
class Node
|
|
5
5
|
attr_reader :id, :type, :question, :options, :fields, :transitions, :visibility_rule
|
|
6
6
|
|
|
7
|
-
def initialize(id:,
|
|
7
|
+
def initialize(id:, # rubocop:disable Metrics/ParameterLists
|
|
8
|
+
type:,
|
|
9
|
+
question:,
|
|
10
|
+
decorations: nil,
|
|
11
|
+
options: nil,
|
|
12
|
+
fields: nil,
|
|
13
|
+
transitions: [],
|
|
14
|
+
visibility_rule: nil)
|
|
8
15
|
@id = id
|
|
9
16
|
@type = type
|
|
10
17
|
@question = question
|
|
18
|
+
@decorations = decorations
|
|
11
19
|
@options = options&.freeze
|
|
12
20
|
@fields = fields&.freeze
|
|
13
21
|
@transitions = transitions.freeze
|
data/lib/flowengine/version.rb
CHANGED
data/lib/flowengine.rb
CHANGED
|
@@ -26,4 +26,14 @@ module FlowEngine
|
|
|
26
26
|
builder.instance_eval(&)
|
|
27
27
|
builder.build
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
def self.load_dsl(text)
|
|
31
|
+
# rubocop:disable Security/Eval
|
|
32
|
+
eval(text, TOPLEVEL_BINDING.dup, "(dsl)", 1)
|
|
33
|
+
# rubocop:enable Security/Eval
|
|
34
|
+
rescue SyntaxError => e
|
|
35
|
+
raise DefinitionError, "DSL syntax error: #{e.message}"
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
raise DefinitionError, "DSL evaluation error: #{e.message}"
|
|
38
|
+
end
|
|
29
39
|
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.1
|
|
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
|