statecraft 0.1.3 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f809e9a1769e457d33d4b526703e4c3c7beb4ca09f8a7550cbbd31c97b07ba12
4
- data.tar.gz: 8193837939f39dd0de633330b7bfc36d7adbb821221072ce1cdf30890f3488d0
3
+ metadata.gz: 6000e276c5fbe7a0ba8b33724c8cf6deea30dc0e8378931166e5123f799ee2a4
4
+ data.tar.gz: 40ab4510c265cb13a811faac8a3c04003222f1f57644233860a6f5d6031f8b12
5
5
  SHA512:
6
- metadata.gz: a5da582f65eb60cd2f76fef2ec213ad6c09166b288f44d80f7065a943894a66c5f36134f6c094fd873e50562976a447d0183b3e0e47b5545bce707d63f965d83
7
- data.tar.gz: f1247259b3bc97de6663d9545e00015e1ea443b9e9f0ac33af6a179c09ba534140b0037863ddd2fa64e4992234a5fe14ea0d43e4a9ddc30d1018927c0d3158c5
6
+ metadata.gz: 7067033a04b74f602366186bbf5536070d21ab8f2151cc955529db3a4d522cfbbd07d1586f7ed50c2ab766081ead669a490d0f76a4e20c61bc939d9d6a1669b6
7
+ data.tar.gz: 808b7b6a2c2d790636954a2da78a884355568487e43470885db340f75459cea23bc98e72839e3aa072fa29e45dbccc3b6c44e4efddfea27445405b8a71125b96
data/README.md CHANGED
@@ -29,6 +29,7 @@ the generator as a progressive enhancement.
29
29
 
30
30
  ## Installation
31
31
 
32
+ <!-- illustrative -->
32
33
  ```ruby
33
34
  gem "statecraft"
34
35
  ```
@@ -48,6 +49,7 @@ log table with a cascade FK — and a CHECK constraint when the table is
48
49
  freshly created), the machine class, the readonly log model, and mounts the
49
50
  machine into the model. By hand it looks like this:
50
51
 
52
+ <!-- illustrative -->
51
53
  ```ruby
52
54
  class OrderFlow < ApplicationMachine
53
55
  state :pending, initial: true
@@ -86,6 +88,13 @@ Mounting options: `log:` (defaults to the `<Model>Transition` convention),
86
88
  the same CAS update), `helpers:` and `scopes:` (both off by default; the
87
89
  generator turns them on for new code).
88
90
 
91
+ ## Example app
92
+
93
+ A complete, working web store lives in [`example/`](example/): a storefront
94
+ in human words over a two-zone Rails app, with the gem's full mechanics on
95
+ the operator side and an e2e suite on top. Inside it:
96
+ `bundle install && bin/rails db:setup && bin/rails s` — PostgreSQL only.
97
+
89
98
  ## The transition pipeline
90
99
 
91
100
  `transition_to!` / `fire!` run one strict order:
@@ -126,6 +135,16 @@ one event, `from` is unique — an event is a partial function from state to
126
135
  edge, so `fire!` is structurally deterministic. Branching by outcome means
127
136
  two events (`pay` and `fail_payment`), not one event with two branches.
128
137
 
138
+ A guard also declares its nature. `guard:` judges the input: it receives
139
+ `(record, metadata)` and belongs to execution and prediction. `record_guard:`
140
+ judges the record alone: its handler must take exactly one argument — the
141
+ compiler refuses any other arity — so it physically cannot read the input.
142
+ Execution runs both layers, record first; the split exists for the offering
143
+ introspection below, which may ask the record layer before any input exists.
144
+ A good `record_guard:` is a one-line delegation to a domain predicate on the
145
+ model (`def customer_cancellable?(record) = record.customer_cancellable?`):
146
+ the machine keeps the registry "event → predicate", the model keeps the fact.
147
+
129
148
  Calling `transition_to!` directly over an edge that carries event guards is
130
149
  refused — the guards would be silently skipped. The escape hatch is explicit:
131
150
  `transition_to!(:paid, bypass_events: true)` skips event guards (edge guards
@@ -193,23 +212,39 @@ retry policy belongs to whoever chose the isolation level.
193
212
 
194
213
  ## Introspection
195
214
 
215
+ <!-- illustrative -->
196
216
  ```ruby
197
217
  order.can_fire?(:pay, metadata: { amount: 100 }) # would the guards pass right now?
198
218
  order.may_pay?(metadata: { amount: 100 }) # alias, with helpers: true
199
219
  order.available_events(metadata: { amount: 100 }) # => [:pay]
200
220
  order.available_transitions(metadata: {}) # => [#<to: :cancelled, via: [:direct]>]
221
+ order.offerable_events # => [:pay, :cancel] — graph × record layer
222
+ order.refusals_for(:cancel) # => [#<event: :cancel, guard: :customer_cancellable?, layer: :event_record>]
201
223
  order.transitioned_to?(:paid) # strictly log-based
224
+ OrderFlow.transitions_from(:pending) # => [{ to: :paid, events: [:pay] }, ...]
202
225
  ```
203
226
 
227
+ `transitions_from` is class-level and answers the graph's shape, not a
228
+ prediction: no guards are consulted, a bare edge carries an empty `events`
229
+ list, and a state outside the graph owns no edges. Pair it with
230
+ `available_transitions` for the prediction.
231
+
204
232
  `available_transitions` tells you not only *where* you can go but *how*:
205
233
  `via` lists the events whose guards pass, plus `:direct` when the edge is
206
234
  free of event guards and its edge guards pass. Every answer is a snapshot —
207
235
  CAS may still reject the transition a moment later.
208
236
 
209
- A guard that reads metadata makes `may_*?` depend on the metadata you pass.
210
- For a UI "is this button available" question, either do not hang input
211
- validation on a guard, or pass the same metadata to `may_*?` that you will
212
- collect for `fire!`.
237
+ For a UI "is this button available" question, ask `offerable_events`: the
238
+ graph filtered by the record layer only. An input-reading `guard:` never
239
+ hides a button hiding it would hide the form its input arrives through
240
+ while a `record_guard:` honestly strips an event this record is not offered.
241
+ `refusals_for(:event)` returns the refusing record-layer guards as frozen
242
+ structures (event, guard name, layer) and answers `[]` for an unknown event
243
+ or a missing branch. It carries names, never words: human-readable reasons
244
+ belong to the application's presentation layer, not to the machine.
245
+
246
+ A guard that reads metadata makes `may_*?` depend on the metadata you pass —
247
+ pass the same metadata to `may_*?` that you will collect for `fire!`.
213
248
 
214
249
  ## Metadata
215
250
 
@@ -334,6 +369,7 @@ that also fails the check, correctly.
334
369
  No railties at runtime — the hygiene is enforced by a test, not a promise.
335
370
  Without the generator, create the schema by hand; the reference shape:
336
371
 
372
+ <!-- illustrative -->
337
373
  ```ruby
338
374
  create_table :orders do |t|
339
375
  t.string :state, null: false, default: "pending", index: true
@@ -363,6 +399,219 @@ degrades — the locking clause is dropped, the reload still runs, and
363
399
  statecraft warns once per machine per process that row-locking guarantees
364
400
  require PostgreSQL.
365
401
 
402
+ ## Example app patterns
403
+
404
+ These blocks are copied verbatim from the example store and locked by
405
+ `example/script/readme_drift_check.rb` — the code is right, the README
406
+ catches up by hand. The machine behind an order:
407
+
408
+ <!-- readme: machine-skeleton -->
409
+ ```ruby
410
+ class OrderFlow
411
+ include Statecraft::Machine
412
+
413
+ state :pending, initial: true
414
+ state :paid
415
+ state :refunded
416
+ state :cancelled
417
+
418
+ event :pay, from: :pending, to: :paid
419
+ event :refund, from: :paid, to: :refunded, record_guard: :refundable?
420
+
421
+ # One edge, the whole event layer: a guarded event, an unguarded privileged
422
+ # event and the bypass path all share pending -> cancelled — the log
423
+ # records HOW, not only WHAT. The cancel guards split by nature: the
424
+ # record layer judges the order (and the offering may ask it), the input
425
+ # layer judges what the operator typed (only fire! and the panel see it).
426
+ event :cancel, from: :pending, to: :cancelled,
427
+ record_guard: :customer_cancellable?, guard: :reason_present?
428
+ event :admin_override, from: :pending, to: :cancelled
429
+
430
+ private
431
+
432
+ # The machine keeps the registry "event -> predicate" and delegates the
433
+ # domain facts to the record.
434
+ def refundable?(record) = record.refundable?
435
+
436
+ def customer_cancellable?(record) = record.customer_cancellable?
437
+
438
+ def reason_present?(_record, metadata)
439
+ metadata["reason"].to_s.strip.present?
440
+ end
441
+ end
442
+ ```
443
+
444
+ The operator order desk, whole: authorize! on entry and per event, thin
445
+ actions over services, guard refusals local to their form:
446
+
447
+ <!-- readme: order-controller -->
448
+ ```ruby
449
+ # The operator's order desk — bang everywhere: an operator wants the gem's
450
+ # message for the flash, and a non-bang false carries no text. Staleness
451
+ # heals in ApplicationController; a guard refusal is local to this form.
452
+ class OrdersController < BaseController
453
+ def index
454
+ @orders = OrdersQuery.call(state: params[:state])
455
+ @active_state = params[:state].to_s
456
+ end
457
+
458
+ def show
459
+ @order = Order.find(params[:id])
460
+ @metadata = {}
461
+ end
462
+
463
+ def pay
464
+ fire(:pay)
465
+ end
466
+
467
+ def cancel
468
+ fire(:cancel)
469
+ end
470
+
471
+ def refund
472
+ fire(:refund)
473
+ end
474
+
475
+ # The non-mutating submit of the SAME fields: the panel recomputes from
476
+ # exactly the metadata a real fire would carry. Nothing is written.
477
+ def preview
478
+ @order = Order.find(params[:id])
479
+ @metadata = submitted_metadata
480
+ flash.now[:notice] = "Preview only — nothing was written."
481
+ render :show
482
+ end
483
+
484
+ # The privileged event: a SECOND event on the same edge, without a
485
+ # guard — the log will name it admin_override.
486
+ def admin_override
487
+ order = Order.find(params[:id])
488
+ authorize! :admin_override, order
489
+ order.admin_override!(metadata: { "reason" => "admin override" })
490
+ redirect_to admin_order_path(order),
491
+ notice: "admin_override fired: the order is now #{order[:state]}."
492
+ end
493
+
494
+ # The bypass: the same edge with the event layer skipped — the log
495
+ # writes event: nil and the history renders the muted
496
+ # "direct (bypassed events)".
497
+ def bypass_cancel
498
+ order = Order.find(params[:id])
499
+ authorize! :bypass_cancel, order
500
+ order.transition_to!(:cancelled, bypass_events: true,
501
+ metadata: { "reason" => "bypassed by admin" })
502
+ redirect_to admin_order_path(order),
503
+ notice: "bypassed: the order is now #{order[:state]}."
504
+ end
505
+
506
+ def create_shipment
507
+ order = Order.find(params[:id])
508
+ authorize! :create_shipment, order
509
+ shipment = CreateShipment.call(order: order)
510
+ redirect_to admin_shipment_path(shipment), notice: "Shipment created."
511
+ rescue ArgumentError => error
512
+ redirect_to admin_order_path(order), alert: error.message.capitalize + "."
513
+ end
514
+
515
+ private
516
+
517
+ def fire(event_name)
518
+ @order = Order.find(params[:id])
519
+ authorize! event_name, @order
520
+ @metadata = submitted_metadata
521
+ @order.fire!(event_name, metadata: @metadata)
522
+ redirect_to admin_order_path(@order),
523
+ notice: "#{event_name} fired: the order is now #{@order[:state]}."
524
+ rescue Statecraft::GuardFailed => error
525
+ # Local to the form: re-render THIS card with the panel computed from
526
+ # the metadata that were actually submitted — a guard refusal belongs
527
+ # to the scene, not to a global handler.
528
+ flash.now[:alert] = "Refused: #{error.message}"
529
+ render :show, status: :unprocessable_entity
530
+ end
531
+
532
+ def submitted_metadata
533
+ params.fetch(:metadata, {}).permit(:reason).to_h
534
+ end
535
+ end
536
+ ```
537
+
538
+ The preview button — a non-mutating submit of the same fields the
539
+ guard-aware panel predicts with, next to buttons that render only in the
540
+ possibility-times-permission intersection:
541
+
542
+ <!-- readme: preview-pattern -->
543
+ ```erb
544
+ <%= form_with url: preview_admin_order_path(@order), method: :post, local: true do %>
545
+ <fieldset>
546
+ <legend>Metadata for the next action</legend>
547
+ <label>
548
+ reason
549
+ <input type="text" name="metadata[reason]" value="<%= @metadata["reason"] %>">
550
+ </label>
551
+ </fieldset>
552
+
553
+ <%= render "shared/transition_buttons",
554
+ record: @order,
555
+ fire_url: ->(event_name) { public_send("#{event_name}_admin_order_path", @order) } %>
556
+
557
+ <button type="submit" class="preview-button">preview</button>
558
+ <% end %>
559
+ ```
560
+
561
+ Subscribing to the telemetry — the five-argument form is the one that
562
+ publish-style events actually deliver to:
563
+
564
+ <!-- readme: telemetry-subscriber -->
565
+ ```ruby
566
+ # The one executable example of subscribing to statecraft's telemetry: the
567
+ # Operations log feed is written here, with create!, into an ordinary table.
568
+ # The gem publishes with explicit start/finish, so subscribers take the
569
+ # five-argument block form. Payloads never carry metadata (the gem's PII
570
+ # decision) — whoever needs it reads the transition log record instead.
571
+ ActiveSupport::Notifications.subscribe("transition.statecraft") do |_name, _started, _finished, _id, payload|
572
+ OperationEntry.create!(
573
+ record_class: payload[:record_class],
574
+ record_id: payload[:record_id].to_s,
575
+ from_state: payload[:from],
576
+ to_state: payload[:to],
577
+ event_name: payload[:event],
578
+ outcome: "transition"
579
+ )
580
+ end
581
+
582
+ ActiveSupport::Notifications.subscribe("transition_failed.statecraft") do |_name, _started, _finished, _id, payload|
583
+ OperationEntry.create!(
584
+ record_class: payload[:record_class],
585
+ record_id: payload[:record_id].to_s,
586
+ from_state: payload[:from],
587
+ to_state: payload[:to],
588
+ event_name: payload[:event],
589
+ outcome: "refused",
590
+ reason: payload[:reason].to_s
591
+ )
592
+ end
593
+ ```
594
+
595
+ Seeding through the honest pipeline, refusal narrative included:
596
+
597
+ <!-- readme: seed-pattern -->
598
+ ```ruby
599
+ # The refusal scenario WITH its narrative: the rescue is part of the plot —
600
+ # a cancellation attempt without a reason lands in the operations feed as a
601
+ # refusal, then the reasoned retry succeeds.
602
+ def seed_disputed_order
603
+ order = place_order(number: "ORD-1009", customer: "Ivy Chen",
604
+ items: { "Ceramic vase" => 2 })
605
+ begin
606
+ order.cancel!(metadata: {})
607
+ rescue Statecraft::GuardFailed
608
+ # the refusal is the point: the feed keeps it
609
+ end
610
+ order.cancel!(metadata: { "reason" => "dispute resolved in the customer's favor" })
611
+ order
612
+ end
613
+ ```
614
+
366
615
  ## Running the tests
367
616
 
368
617
  Natively, against SQLite:
@@ -3,14 +3,21 @@
3
3
  class <%= class_name %>Flow < ApplicationMachine
4
4
  state :pending, initial: true
5
5
  # state :paid
6
+ # state :refunded
6
7
  #
7
8
  # event :pay, from: :pending, to: :paid, guard: :payable?
9
+ # event :refund, from: :paid, to: :refunded, record_guard: :refundable?
8
10
  #
9
- # Guards and callbacks resolve to instance methods of this class:
11
+ # Guards and callbacks resolve to instance methods of this class, and a
12
+ # guard declares its nature: guard: judges the input and receives
13
+ # (record, metadata); record_guard: judges the record alone (arity 1,
14
+ # compiler-enforced) — delegate it to a domain predicate on the model:
10
15
  #
11
16
  # private
12
17
  #
13
18
  # def payable?(record, metadata)
14
19
  # metadata["amount"].to_i.positive?
15
20
  # end
21
+ #
22
+ # def refundable?(record) = record.refundable?
16
23
  end
@@ -9,6 +9,7 @@ module Statecraft
9
9
  # reject the transition later.
10
10
  module Introspection
11
11
  Availability = Struct.new(:to, :via, keyword_init: true)
12
+ Refusal = Struct.new(:event, :guard, :layer, keyword_init: true)
12
13
 
13
14
  def can_fire?(event_name, metadata: {})
14
15
  graph = statecraft_graph
@@ -45,8 +46,48 @@ module Statecraft
45
46
  history.where(to_state: state_name.to_s).exists?
46
47
  end
47
48
 
49
+ # The offering: which events the graph AND this record allow from here —
50
+ # the record layer alone, so an input-reading guard never hides the form
51
+ # its input arrives through. A snapshot, like every question here.
52
+ def offerable_events
53
+ statecraft_graph.events.filter_map do |event_name, branches|
54
+ edge = branches[statecraft_current_state]
55
+ next unless edge
56
+
57
+ event_name if statecraft_record_refusals(edge, event_name).empty?
58
+ end
59
+ end
60
+
61
+ # The structured "why not": every record-layer guard refusing the event
62
+ # right now. Guard handlers by name, no words — the words belong to the
63
+ # presentation. An unknown event or a missing branch answers [].
64
+ def refusals_for(event_name)
65
+ branches = statecraft_graph.events[event_name.to_sym]
66
+ return [].freeze unless branches
67
+
68
+ edge = branches[statecraft_current_state]
69
+ return [].freeze unless edge
70
+
71
+ statecraft_record_refusals(edge, event_name.to_sym)
72
+ end
73
+
48
74
  private
49
75
 
76
+ def statecraft_record_refusals(edge, event_name)
77
+ machine_instance = self.class.statecraft_mounting.machine_class.new
78
+ layers = {
79
+ edge_record: edge.edge_record_guards,
80
+ event_record: edge.event_record_guards.fetch(event_name, [])
81
+ }
82
+ layers.flat_map do |layer, guards|
83
+ guards.filter_map do |guard|
84
+ next if Machine::Handlers.invoke(machine_instance, guard, self, nil)
85
+
86
+ Refusal.new(event: event_name, guard: guard, layer: layer).freeze
87
+ end
88
+ end.freeze
89
+ end
90
+
50
91
  def statecraft_passable_via(edge, normalized_metadata)
51
92
  via = edge.event_names.select do |event_name|
52
93
  statecraft_guards_pass?(edge, event_name, normalized_metadata)
@@ -7,7 +7,12 @@ module Statecraft
7
7
  # Guard and callback symbols resolve to instance methods of the machine
8
8
  # class; callables are honored with a plain `call` and arity dispatch.
9
9
  module Machine
10
- Edge = Struct.new(:from, :to, :lock, :edge_guards, :event_names, :event_guards, keyword_init: true)
10
+ # edge_guards / event_guards carry the FULL lists of both layers in
11
+ # record-then-input order — execution and the prediction run them as one
12
+ # sequence. The parallel *_record_guards fields hold only the record
13
+ # layer; nothing but the offering introspection reads them.
14
+ Edge = Struct.new(:from, :to, :lock, :edge_guards, :edge_record_guards,
15
+ :event_names, :event_guards, :event_record_guards, keyword_init: true)
11
16
  Callback = Struct.new(:handler, :from, :to, :event, keyword_init: true)
12
17
  CompiledGraph = Struct.new(
13
18
  :states, :initial_state, :edges, :events, :callbacks,
@@ -53,14 +58,16 @@ module Statecraft
53
58
  declared_states << { name: name.to_sym, initial: initial }
54
59
  end
55
60
 
56
- def transition(from:, to:, guard: nil, lock: false)
61
+ def transition(from:, to:, guard: nil, record_guard: nil, lock: false)
57
62
  declared_edges << {
58
- from: from.to_sym, to: to.to_sym, guards: Array(guard), lock: lock || current_event_lock,
63
+ from: from.to_sym, to: to.to_sym,
64
+ guards: Array(guard), record_guards: Array(record_guard),
65
+ lock: lock || current_event_lock,
59
66
  event: current_event_name
60
67
  }
61
68
  end
62
69
 
63
- def event(name, from: nil, to: nil, guard: nil, lock: false, &declarations)
70
+ def event(name, from: nil, to: nil, guard: nil, record_guard: nil, lock: false, &declarations)
64
71
  name = name.to_sym
65
72
  declared_event_names << name
66
73
  if declarations
@@ -79,7 +86,7 @@ module Statecraft
79
86
 
80
87
  @statecraft_current_event = { name: name, lock: false }
81
88
  begin
82
- transition(from: from, to: to, guard: guard, lock: lock)
89
+ transition(from: from, to: to, guard: guard, record_guard: record_guard, lock: lock)
83
90
  ensure
84
91
  @statecraft_current_event = nil
85
92
  end
@@ -108,6 +115,18 @@ module Statecraft
108
115
  compiled_graph.events.keys
109
116
  end
110
117
 
118
+ # The graph's shape from one state: a frozen descriptor per outgoing
119
+ # edge, `events` empty for a bare edge. Shape, not a prediction — no
120
+ # guards are consulted, and a state outside the graph honestly owns no
121
+ # edges. Class-level on purpose: the answer is a property of the graph,
122
+ # never of a record.
123
+ def transitions_from(state)
124
+ normalized = state.to_sym
125
+ compiled_graph.edges.filter_map do |(from, _to), edge|
126
+ { to: edge.to, events: edge.event_names }.freeze if from == normalized
127
+ end.freeze
128
+ end
129
+
111
130
  def initial_state
112
131
  compiled_graph.initial_state
113
132
  end
@@ -176,6 +195,7 @@ module Statecraft
176
195
  edges = compile_edges(states)
177
196
  events = compile_events(edges)
178
197
  resolve_symbols(edges)
198
+ assert_record_guards_unary(edges)
179
199
  CompiledGraph.new(
180
200
  states: states.freeze,
181
201
  initial_state: initial,
@@ -228,7 +248,9 @@ module Statecraft
228
248
  def build_edge(declaration)
229
249
  Edge.new(
230
250
  from: declaration[:from], to: declaration[:to], lock: declaration[:lock],
231
- edge_guards: declaration[:guards], event_names: [], event_guards: {}
251
+ edge_guards: declaration[:record_guards] + declaration[:guards],
252
+ edge_record_guards: declaration[:record_guards],
253
+ event_names: [], event_guards: {}, event_record_guards: {}
232
254
  )
233
255
  end
234
256
 
@@ -236,14 +258,16 @@ module Statecraft
236
258
  event_name = declaration[:event]
237
259
  edge ||= Edge.new(
238
260
  from: declaration[:from], to: declaration[:to], lock: false,
239
- edge_guards: [], event_names: [], event_guards: {}
261
+ edge_guards: [], edge_record_guards: [],
262
+ event_names: [], event_guards: {}, event_record_guards: {}
240
263
  )
241
264
  if edge.event_names.include?(event_name)
242
265
  raise CompilationError, "event #{event_name.inspect} declares edge #{pair_name(pair)} twice"
243
266
  end
244
267
 
245
268
  edge.event_names << event_name
246
- edge.event_guards[event_name] = declaration[:guards]
269
+ edge.event_guards[event_name] = declaration[:record_guards] + declaration[:guards]
270
+ edge.event_record_guards[event_name] = declaration[:record_guards]
247
271
  edge.lock ||= declaration[:lock]
248
272
  edge
249
273
  end
@@ -286,12 +310,38 @@ module Statecraft
286
310
  (from_edges + from_callbacks).grep(Symbol)
287
311
  end
288
312
 
313
+ # A record guard promises to judge the record alone, and the promise is
314
+ # held by shape: it must accept exactly one argument, so it physically
315
+ # cannot read the input it claims not to need.
316
+ def assert_record_guards_unary(edges)
317
+ edges.each_value do |edge|
318
+ record_guards = edge.edge_record_guards + edge.event_record_guards.values.flatten
319
+ record_guards.each do |guard|
320
+ arity = record_guard_arity(guard)
321
+ next if arity == 1
322
+
323
+ label = guard.is_a?(Symbol) ? guard.inspect : "the callable"
324
+ raise CompilationError,
325
+ "record_guard #{label} must take exactly the record (arity 1), got arity #{arity}"
326
+ end
327
+ end
328
+ end
329
+
330
+ def record_guard_arity(guard)
331
+ return machine_class.instance_method(guard).arity if guard.is_a?(Symbol)
332
+
333
+ guard.respond_to?(:arity) ? guard.arity : guard.method(:call).arity
334
+ end
335
+
289
336
  def deep_freeze_edges(edges)
290
337
  edges.each_value do |edge|
291
338
  edge.edge_guards.freeze
339
+ edge.edge_record_guards.freeze
292
340
  edge.event_names.freeze
293
341
  edge.event_guards.each_value(&:freeze)
294
342
  edge.event_guards.freeze
343
+ edge.event_record_guards.each_value(&:freeze)
344
+ edge.event_record_guards.freeze
295
345
  edge.freeze
296
346
  end
297
347
  edges.freeze
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statecraft
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: statecraft
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Pugachev