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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 249513128d6e8c8fcd501433415e9dc78976f1a94195dc45661a51157548ced3
4
- data.tar.gz: 3deeef88dae2957f36ed04afe2e001b5886ec17700989e7e9a95f5222f4d2731
3
+ metadata.gz: 50117723ca62a9851b0c8091e2619132d6e2f849e62b765dadd5c0d0d5c601ad
4
+ data.tar.gz: 0024ae08e3d6068d0ed913067368ddd4764a83f9ffe1f44e4d9bdcef4f544757
5
5
  SHA512:
6
- metadata.gz: b3f12e0ea863990914abe8ffad528ee53637148678c4d2fd044165b9d1c6a4454c4201bb9b208f4976db027a12380c0af786bd5a1e32fbb17109d679681276dc
7
- data.tar.gz: d06bfe3f5384a8f5e674ce95dd12a1f2104b287b5db7b0c33b5f56125a65be2553deb6308223889fd78b7d545782c9621e661e1b82640c3968eadb93f3d28ce2
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-02-27 03:50:14 UTC using RuboCop version 1.85.0.
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: 2
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
- # Flowengine
1
+ # FlowEngine
2
2
 
3
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
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.
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
- FlowEngine.define do
50
- start :earnings
22
+ gem "flowengine"
23
+ ```
51
24
 
52
- step :earnings do
53
- type :multi_select
54
- question "What are your main earnings?"
55
- options %w[W2 1099 BusinessOwnership]
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
- transition to: :business_details,
58
- if: contains(:earnings, "BusinessOwnership")
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
+ ![architecture](docs/floweingine-architecture.png)
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
- Definition compiles DSL → Node objects → AST transitions.
191
+ ### Transitions
64
192
 
65
- No runtime state.
193
+ Transitions define the edges of the flow graph. They are evaluated **in order** — the first matching transition wins:
66
194
 
67
- #### Engine (Pure Runtime)
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
- engine.current_step
73
- engine.answer(value)
74
- engine.finished?
75
- engine.answers
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
- Engine stores:
339
+ ## Validation
79
340
 
80
- * current node id
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
- No IO.
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
- #### Rule AST (Clean & Extensible)
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
- You want AST objects, not hash blobs.
357
+ ### Custom Validator Example
89
358
 
90
359
  ```ruby
91
- Contains.new(:earnings, "BusinessOwnership")
92
- All.new(rule1, rule2)
93
- Equals.new(:marital_status, "Married")
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
- Evaluator does polymorphic dispatch:
383
+ ## Mermaid Diagram Export
384
+
385
+ Export any flow definition as a [Mermaid](https://mermaid.js.org/) flowchart:
97
386
 
98
387
  ```ruby
99
- rule.evaluate(context)
388
+ exporter = FlowEngine::Graph::MermaidExporter.new(definition)
389
+ puts exporter.export
100
390
  ```
101
391
 
102
- Cleaner than giant case statements.
392
+ Output:
103
393
 
104
- #### Validation (Dry Integration)
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
- Adapter pattern:
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
- class DryValidationAdapter < Adapter
110
- def validate(step, input)
111
- step.schema.call(input)
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
- Core does:
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
- validator.validate(step, input)
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
- IMPORTANT: Core does not depend directly on dry-validation.
609
+ ### Scenario 2: Minimum Path (8 steps visited)
123
610
 
124
- ### Examples of Mermaid Charts
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
  ![Example](docs/flowengine-example.png)
127
851
 
128
852
  <details>
129
- <summary>Expand to See Mermaid Sources</summary>
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 'bundler/gem_tasks'
4
- require 'rspec/core/rake_task'
5
- require 'timeout'
6
- require 'yard'
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('rm -rf pkg/ tmp/ coverage/ doc/ ' )
14
+ shell("rm -rf pkg/ tmp/ coverage/ doc/ ")
15
15
  end
16
16
 
17
17
  task gem: [:build] do
18
- shell('gem install pkg/*')
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(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/
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
@@ -2,6 +2,10 @@ set shell := ["bash", "-c"]
2
2
 
3
3
  set dotenv-load
4
4
 
5
+ [no-exit-message]
6
+ recipes:
7
+ @just --choose
8
+
5
9
  test:
6
10
  @bundle exec rspec
7
11
  @bundle exec rubocop
@@ -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
@@ -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)
@@ -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:, type:, question:, options: nil, fields: nil, transitions: [], visibility_rule: nil)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowEngine
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
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.0
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