statifier 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/{LICENSE.txt → LICENSE} +1 -1
- data/README.md +601 -16
- data/lib/statifier/accepts.rb +167 -0
- data/lib/statifier/basichttp/decode.rb +142 -0
- data/lib/statifier/basichttp/encoding.rb +224 -0
- data/lib/statifier/basichttp/net_http_transport.rb +157 -0
- data/lib/statifier/basichttp/outbound.rb +87 -0
- data/lib/statifier/basichttp/processor.rb +170 -0
- data/lib/statifier/basichttp/request.rb +94 -0
- data/lib/statifier/basichttp/response.rb +34 -0
- data/lib/statifier/basichttp.rb +47 -0
- data/lib/statifier/compiler/content.rb +131 -0
- data/lib/statifier/compiler/error.rb +30 -0
- data/lib/statifier/compiler/expressions.rb +64 -0
- data/lib/statifier/compiler/numbering.rb +145 -0
- data/lib/statifier/compiler/parts.rb +144 -0
- data/lib/statifier/compiler/walk.rb +61 -0
- data/lib/statifier/compiler.rb +82 -0
- data/lib/statifier/datamodel/data_binding.rb +114 -0
- data/lib/statifier/datamodel/delay.rb +54 -0
- data/lib/statifier/datamodel/location.rb +145 -0
- data/lib/statifier/datamodel/program.rb +82 -0
- data/lib/statifier/datamodel/reason_value.rb +42 -0
- data/lib/statifier/datamodel/reasons.rb +88 -0
- data/lib/statifier/datamodel/stores.rb +169 -0
- data/lib/statifier/datamodel/system_variables.rb +69 -0
- data/lib/statifier/datamodel.rb +120 -0
- data/lib/statifier/document/data.rb +36 -0
- data/lib/statifier/document/executable.rb +69 -0
- data/lib/statifier/document/invoke.rb +17 -0
- data/lib/statifier/document/states.rb +40 -0
- data/lib/statifier/document.rb +51 -0
- data/lib/statifier/driver/calls.rb +183 -0
- data/lib/statifier/driver/checks.rb +110 -0
- data/lib/statifier/driver/delivery.rb +45 -0
- data/lib/statifier/driver/invocations.rb +129 -0
- data/lib/statifier/driver/mailbox.rb +120 -0
- data/lib/statifier/driver/routing.rb +193 -0
- data/lib/statifier/driver/session_id.rb +40 -0
- data/lib/statifier/driver/work.rb +196 -0
- data/lib/statifier/driver.rb +205 -0
- data/lib/statifier/effect.rb +208 -0
- data/lib/statifier/event.rb +67 -0
- data/lib/statifier/frozen.rb +22 -0
- data/lib/statifier/interpreter/blocks.rb +142 -0
- data/lib/statifier/interpreter/content.rb +228 -0
- data/lib/statifier/interpreter/entering.rb +141 -0
- data/lib/statifier/interpreter/exit_entry.rb +174 -0
- data/lib/statifier/interpreter/foreach.rb +83 -0
- data/lib/statifier/interpreter/invoke/arguments.rb +52 -0
- data/lib/statifier/interpreter/invoke/passes.rb +143 -0
- data/lib/statifier/interpreter/invoke.rb +145 -0
- data/lib/statifier/interpreter/loop.rb +170 -0
- data/lib/statifier/interpreter/machine_state.rb +152 -0
- data/lib/statifier/interpreter/macrostep.rb +185 -0
- data/lib/statifier/interpreter/name_match.rb +43 -0
- data/lib/statifier/interpreter/selection.rb +221 -0
- data/lib/statifier/interpreter/send.rb +223 -0
- data/lib/statifier/interpreter/step.rb +138 -0
- data/lib/statifier/interpreter/stop.rb +66 -0
- data/lib/statifier/interpreter/targets.rb +107 -0
- data/lib/statifier/interpreter.rb +57 -0
- data/lib/statifier/lowering/branches.rb +84 -0
- data/lib/statifier/lowering/data.rb +102 -0
- data/lib/statifier/lowering/error.rb +102 -0
- data/lib/statifier/lowering/executable.rb +106 -0
- data/lib/statifier/lowering/placement.rb +65 -0
- data/lib/statifier/lowering/reading.rb +53 -0
- data/lib/statifier/lowering/states.rb +111 -0
- data/lib/statifier/lowering.rb +129 -0
- data/lib/statifier/machine/content.rb +81 -0
- data/lib/statifier/machine/data.rb +42 -0
- data/lib/statifier/machine/states.rb +56 -0
- data/lib/statifier/machine/values.rb +37 -0
- data/lib/statifier/machine.rb +103 -0
- data/lib/statifier/position/rebuild.rb +84 -0
- data/lib/statifier/position/shape.rb +73 -0
- data/lib/statifier/position/text.rb +43 -0
- data/lib/statifier/position.rb +206 -0
- data/lib/statifier/validator/attributes.rb +35 -0
- data/lib/statifier/validator/context.rb +74 -0
- data/lib/statifier/validator/data.rb +136 -0
- data/lib/statifier/validator/error.rb +90 -0
- data/lib/statifier/validator/executable.rb +139 -0
- data/lib/statifier/validator/history.rb +81 -0
- data/lib/statifier/validator/messages.rb +142 -0
- data/lib/statifier/validator/states.rb +161 -0
- data/lib/statifier/validator.rb +94 -0
- data/lib/statifier/version.rb +4 -1
- data/lib/statifier/xml/character_data.rb +142 -0
- data/lib/statifier/xml/cursor.rb +131 -0
- data/lib/statifier/xml/markup.rb +153 -0
- data/lib/statifier/xml/nodes.rb +50 -0
- data/lib/statifier/xml/parser.rb +121 -0
- data/lib/statifier/xml/scope.rb +46 -0
- data/lib/statifier/xml/tags.rb +112 -0
- data/lib/statifier/xml.rb +85 -0
- data/lib/statifier.rb +386 -2
- metadata +129 -20
- data/.gitignore +0 -8
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -11
- data/Gemfile.lock +0 -33
- data/Rakefile +0 -10
- data/bin/console +0 -8
- data/bin/setup +0 -6
- data/statifier.gemspec +0 -34
data/README.md
CHANGED
|
@@ -1,43 +1,628 @@
|
|
|
1
1
|
# Statifier
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[![MIT License][badge_license_url]](LICENSE)
|
|
4
|
+
[![CI][badge_ci_url]](https://github.com/riddler/statifier-rb/actions/workflows/ci.yml)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
The interpreter core of an SCXML statechart engine, for Ruby. Give it a chart
|
|
7
|
+
and an event and it answers the chart's next configuration and what the host
|
|
8
|
+
should do about it, as plain data. The reference implementation is
|
|
9
|
+
[statifier-ex](https://github.com/riddler/statifier-ex), written in Elixir,
|
|
10
|
+
and this gem is checked against that implementation's conformance corpus.
|
|
6
11
|
|
|
7
|
-
##
|
|
12
|
+
## Why this package
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
A chart is authored once and should decide the same way wherever it runs. This
|
|
15
|
+
gem is the same algorithm as the reference, held case by case to the
|
|
16
|
+
reference's conformance corpus, with no process, no real clock, no I/O and no
|
|
17
|
+
storage of its own: state is an immutable value, a step is
|
|
18
|
+
`(state, event) -> (state, effects)`, and the effects are plain data. A host
|
|
19
|
+
keeps the state however it likes, delivers what a chart sends, and passes its
|
|
20
|
+
own processors and clock with each call. Persistence, a real clock and
|
|
21
|
+
delivering a send stay the host's.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Add `statifier` to your Gemfile:
|
|
10
26
|
|
|
11
27
|
```ruby
|
|
12
|
-
gem
|
|
28
|
+
gem "statifier", "~> 0.1.0"
|
|
13
29
|
```
|
|
14
30
|
|
|
15
31
|
And then execute:
|
|
16
32
|
|
|
17
|
-
$ bundle
|
|
33
|
+
$ bundle install
|
|
18
34
|
|
|
19
35
|
Or install it yourself as:
|
|
20
36
|
|
|
21
37
|
$ gem install statifier
|
|
22
38
|
|
|
23
|
-
|
|
39
|
+
The constraint matches the 0.1 series, which begins with the 0.1.0 release of
|
|
40
|
+
this rewrite. The 0.0.1 version on rubygems.org is a stub from 2019 that
|
|
41
|
+
carries no statechart code, and the constraint excludes it.
|
|
24
42
|
|
|
25
|
-
|
|
43
|
+
The gem has one runtime dependency, [`predicator`](https://github.com/riddler/predicator-rb)
|
|
44
|
+
(`>= 2.0`, `< 3`), which evaluates the conditions, expressions and script
|
|
45
|
+
bodies a chart carries.
|
|
26
46
|
|
|
27
|
-
##
|
|
47
|
+
## Basic usage
|
|
48
|
+
|
|
49
|
+
A host compiles a chart once and drives it with the other calls. The chart
|
|
50
|
+
below starts in `a`, where it arms a ten-second timer; the timer moves it to
|
|
51
|
+
`b`, `e.inc` counts, and `e.go` stops it in the final state `c`, answering the
|
|
52
|
+
count. Every Ruby example on this page is run against the code in this
|
|
53
|
+
repository, and a line ending in `# =>` is an assertion that the value on its
|
|
54
|
+
left equals the value on its right.
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
require "statifier"
|
|
58
|
+
|
|
59
|
+
source = <<~SCXML
|
|
60
|
+
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="a">
|
|
61
|
+
<datamodel><data id="count" expr="0"/></datamodel>
|
|
62
|
+
<state id="a">
|
|
63
|
+
<onentry><send id="t" event="e.timeout" delay="10s"/></onentry>
|
|
64
|
+
<onexit><cancel sendid="t"/></onexit>
|
|
65
|
+
<transition event="e.inc" target="a"><assign location="count" expr="count + 1"/></transition>
|
|
66
|
+
<transition event="e.timeout" target="b"/>
|
|
67
|
+
<transition event="e.go" target="c"/>
|
|
68
|
+
</state>
|
|
69
|
+
<state id="b"><transition event="e.go" target="c"/></state>
|
|
70
|
+
<final id="c"><donedata><param name="count" expr="count"/></donedata></final>
|
|
71
|
+
</scxml>
|
|
72
|
+
SCXML
|
|
73
|
+
|
|
74
|
+
# Compile once: the machine is a frozen value, shared by every execution
|
|
75
|
+
compiled = Statifier.compile(source)
|
|
76
|
+
compiled.ok? # => true
|
|
77
|
+
machine = compiled.value
|
|
78
|
+
|
|
79
|
+
# The host names the session; each call answers a result holding the state and its effects
|
|
80
|
+
started = Statifier.start(machine, session_id: "s1")
|
|
81
|
+
started.value.effects.map { |effect| effect.class.name.split("::").last } # => ["DatamodelInit", "DatamodelChange", "SendDelayed"]
|
|
82
|
+
Statifier.configuration(started.value.state).value # => ["a"]
|
|
83
|
+
|
|
84
|
+
counted = Statifier.step(started.value.state, "e.inc")
|
|
85
|
+
Statifier.configuration(counted.value.state).value # => ["a"]
|
|
86
|
+
|
|
87
|
+
# advance moves the virtual clock and fires every timer due by then
|
|
88
|
+
lapsed = Statifier.advance(counted.value.state, 10_000)
|
|
89
|
+
Statifier.configuration(lapsed.value.state).value # => ["b"]
|
|
90
|
+
|
|
91
|
+
stopped = Statifier.step(lapsed.value.state, "e.go")
|
|
92
|
+
Statifier.done?(stopped.value.state).value.done # => true
|
|
93
|
+
Statifier.done?(stopped.value.state).value.donedata # => { "count" => 1 }
|
|
94
|
+
Statifier.done?(stopped.value.state).value.configuration # => ["c"]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## The driver surface
|
|
98
|
+
|
|
99
|
+
Ten calls make up the public surface. Nine of them answer a
|
|
100
|
+
`Statifier::Result`, which has `ok?`, `value` and `error`. A successful result has `error == nil`
|
|
101
|
+
and a failed one has `value == nil`; read `ok?` and not the value.
|
|
102
|
+
|
|
103
|
+
| Call | Answers, in the result |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `Statifier.compile(source)` | the machine, or the refusals of the first stage that refused |
|
|
106
|
+
| `Statifier.start(machine, **options)` | a `Statifier::Driver::Moved`: the `state` and the `effects` of starting the chart |
|
|
107
|
+
| `Statifier.step(state, name, data:, processors:, clock:)` | a `Moved`, after one external event and every event the chart queues for itself; `:not_running` once the chart has stopped |
|
|
108
|
+
| `Statifier.advance(state, milliseconds, processors:, clock:)` | a `Moved`, after the virtual clock moves and every timer due fires, earliest first; `:invalid_duration` for a negative, infinite or non-numeric duration |
|
|
109
|
+
| `Statifier.report_send_failed(machine, state, send, **options)` | a `Moved`, after the chart takes `error.communication` for a send the host could not deliver; `:not_running` once the chart has stopped, `:halted` while a round budget has halted it |
|
|
110
|
+
| `Statifier.configuration(state)` | the active state ids, sorted, as a frozen list |
|
|
111
|
+
| `Statifier.done?(state)` | a `Statifier::Driver::DoneStatus`: `done`, and once the chart has stopped its `donedata` and `configuration` |
|
|
112
|
+
| `Statifier.export_position(state)` | the position, plain data ready for JSON, or a `Statifier::Position::Refusal` |
|
|
113
|
+
| `Statifier.import_position(machine, position, processors:)` | a state, or a `Position::Refusal` |
|
|
114
|
+
|
|
115
|
+
The tenth, `Statifier.check_accepts(machine, declared_events)`, refuses
|
|
116
|
+
nothing and answers a `Statifier::AcceptsCheck` directly (below).
|
|
117
|
+
|
|
118
|
+
`start` takes these options, each optional: `session_id:` (the id `_sessionid`
|
|
119
|
+
reads; the driver mints one when it is absent), `datamodel:` (starting values
|
|
120
|
+
for the datamodel), `processors:`, `max_macrostep_rounds:` (a positive Integer
|
|
121
|
+
or `:infinity`; 10000 when absent), `trace:` (whether the chart answers trace
|
|
122
|
+
effects; false when absent) and `clock:`. The state is an immutable value that
|
|
123
|
+
a call answers and the next call takes; a host reads it only through
|
|
124
|
+
`configuration` and `done?`, and its members are internal.
|
|
125
|
+
|
|
126
|
+
### Declared events
|
|
127
|
+
|
|
128
|
+
`Statifier.check_accepts` compares the event names a host declares a chart
|
|
129
|
+
accepts with the event descriptors the chart listens for, on its transitions
|
|
130
|
+
from every state some path can enter. A descriptor matches a name as
|
|
131
|
+
transition selection matches an event: `e`, `e.`, `e.*` and `*` all match
|
|
132
|
+
`e.go`, and `e.gone` does not. It answers two frozen lists:
|
|
133
|
+
`unreachable`, the declared names no descriptor matches, in the declaration's
|
|
134
|
+
order; and `undeclared`, the descriptors that match no declared name, in
|
|
135
|
+
transition order (a state's own transitions before its children's). With `nil` for the declaration both lists are empty.
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
machine = Statifier.compile(<<~SCXML).value
|
|
139
|
+
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0">
|
|
140
|
+
<state id="a"><transition event="e.go" target="b"/></state>
|
|
141
|
+
<state id="b"><transition event="e.back" target="a"/></state>
|
|
142
|
+
</scxml>
|
|
143
|
+
SCXML
|
|
144
|
+
|
|
145
|
+
check = Statifier.check_accepts(machine, ["e.go", "e.stop"])
|
|
146
|
+
check.unreachable # => ["e.stop"]
|
|
147
|
+
check.undeclared # => ["e.back"]
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Refusals
|
|
151
|
+
|
|
152
|
+
A call never raises for something a chart or a source does. A refusal is a
|
|
153
|
+
value:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
stopped = Statifier.step(lapsed.value.state, "e.go")
|
|
157
|
+
Statifier.step(stopped.value.state, "e.go").error # => :not_running
|
|
158
|
+
Statifier.advance(lapsed.value.state, -1).error # => :invalid_duration
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`Statifier.compile` answers the list of refusals of the first of four stages
|
|
162
|
+
that refused the source: the XML reader, the lowering, the validator and the
|
|
163
|
+
compiler (when predicator refuses an expression). Their classes are internal.
|
|
164
|
+
Each refusal carries a `reason` symbol, a `message` for a person and a
|
|
165
|
+
`location`, the span of the source it names (`start_line`, `start_column`,
|
|
166
|
+
`start_offset`, `end_line`, `end_column` and `end_offset`); all but the
|
|
167
|
+
reader's carry a `detail` Hash too:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
Statifier.compile("<scxml>").error.first.reason # => :unclosed_element
|
|
171
|
+
|
|
172
|
+
unresolved = Statifier.compile(<<~SCXML)
|
|
173
|
+
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0">
|
|
174
|
+
<state id="a"><transition event="e.go" target="zz"/></state>
|
|
175
|
+
</scxml>
|
|
176
|
+
SCXML
|
|
177
|
+
unresolved.error.first.reason # => :unresolved_target
|
|
178
|
+
|
|
179
|
+
badcond = Statifier.compile(<<~SCXML)
|
|
180
|
+
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0">
|
|
181
|
+
<state id="a"><transition event="e.go" cond="1 +" target="a"/></state>
|
|
182
|
+
</scxml>
|
|
183
|
+
SCXML
|
|
184
|
+
badcond.error.first.reason # => :expression_compile_error
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
A validator warning never refuses: a chart that compiles carries its warnings
|
|
188
|
+
on the machine, `machine.warnings`, in document order.
|
|
189
|
+
|
|
190
|
+
A bug in the call itself raises: a `TypeError` for an argument of the wrong
|
|
191
|
+
kind (a state that is not a driver state, an event name that is not a String)
|
|
192
|
+
and an `ArgumentError` for an option the call does not name or processors that
|
|
193
|
+
register other send types than the chart started with.
|
|
194
|
+
|
|
195
|
+
## Effects as data
|
|
196
|
+
|
|
197
|
+
Every effect a call answers is a frozen `Data` value under `Statifier::Effect`,
|
|
198
|
+
named for what the host does with it. The reference's vocabulary is ported
|
|
199
|
+
field for field, and each effect carries the `macrostep`, `microstep` and
|
|
200
|
+
`round` counters as they stood when it was made. Every effect the chart's
|
|
201
|
+
runs answered is among the call's effects, in the order made, whatever the
|
|
202
|
+
driver did with it:
|
|
203
|
+
|
|
204
|
+
| Effect | What the driver does | What the host does with it |
|
|
205
|
+
|---|---|---|
|
|
206
|
+
| `Send` of a type the host registered | hands it to that type's processor, `deliver` | delivers it, in its processor |
|
|
207
|
+
| `Send` of the SCXML type, or none | routes it by its `target`: to the chart's own external queue, its internal queue, its parent or a live child | reads it, if it wants a record |
|
|
208
|
+
| `SendDelayed` of a type the host registered | hands it to that type's processor, which holds it | schedules it, in its processor |
|
|
209
|
+
| `SendDelayed` of the SCXML type, or none | holds it as a pending timer until `advance` reaches it | learns of an event due later |
|
|
210
|
+
| a send of any other type | nothing: the chart refuses it with `error.execution`, and no effect is answered | nothing |
|
|
211
|
+
| `Cancel` | drops every pending timer under its `send_id`, and tells a processor holding a delayed send under it, once | learns that the delayed send is dropped |
|
|
212
|
+
| `Invoke`, `CancelInvoke`, `Autoforward` | runs an invocation of the SCXML type in process as a child session, stops it, or forwards an event to it; any other type raises `error.execution` | learns of an invocation the core starts, stops or forwards an event to |
|
|
213
|
+
| `Log` | nothing | records the label and the evaluated value |
|
|
214
|
+
| `DatamodelInit`, `DatamodelChange` | nothing | learns the datamodel as the chart starts, and each write after |
|
|
215
|
+
| `BudgetExhausted` | queues later external events without taking them; a fired timer to the chart itself joins its external queue, and any other is dropped | learns that a macrostep spent its round budget; the chart keeps running |
|
|
216
|
+
| `Done` | discards the pending timers and the queued external events | learns that the chart stopped, with the top-level final's donedata |
|
|
217
|
+
| `Child` | answers each effect an invoked child's execution answered, tagged with the child's `session_id`, a grandchild's with its own | reads the child's effects |
|
|
218
|
+
|
|
219
|
+
An error is never an effect: `error.execution` and `error.communication` are
|
|
220
|
+
events the chart takes from its internal queue, which a host sees in what the
|
|
221
|
+
chart does next, or as a `Trace::EventDequeued` effect for a chart started with
|
|
222
|
+
`trace: true`.
|
|
223
|
+
|
|
224
|
+
The `Trace` effects follow the interpreter step by step and are answered only
|
|
225
|
+
for a chart started with `trace: true`. An event is a `Statifier::Event`, the
|
|
226
|
+
one value every queue holds.
|
|
227
|
+
|
|
228
|
+
The surface is fixed: the ten calls, the values above and below, and, after
|
|
229
|
+
`require "statifier/basichttp"`, the Basic HTTP processor's. Every other
|
|
230
|
+
constant in the gem is private, and `test/surface_test.rb` lists the public
|
|
231
|
+
surface whole, failing on an addition or a removal. A value whose class is
|
|
232
|
+
private, such as the machine, a driver state or a compile refusal, is read by
|
|
233
|
+
its documented members.
|
|
234
|
+
|
|
235
|
+
The driver delivers nothing itself that leaves the process. A send of a type
|
|
236
|
+
the host registers is handed to the host's processor, an object that answers
|
|
237
|
+
`deliver(send, event, context)` (and, optionally, `cancel(cancel, context)`).
|
|
238
|
+
Processors are passed per call, by send type, and name the same types for the
|
|
239
|
+
whole life of a chart; they are never stored in the state:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
class Outbox
|
|
243
|
+
attr_reader :seen
|
|
244
|
+
|
|
245
|
+
def initialize = @seen = []
|
|
28
246
|
|
|
29
|
-
|
|
247
|
+
def deliver(send, event, context) = @seen << [send.target, event.name, context.session_id]
|
|
248
|
+
end
|
|
30
249
|
|
|
31
|
-
|
|
250
|
+
notify = Statifier.compile(<<~SCXML).value
|
|
251
|
+
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0">
|
|
252
|
+
<state id="a">
|
|
253
|
+
<transition event="e.go" target="b"><send type="outbox" event="e.out" target="t1"/></transition>
|
|
254
|
+
</state>
|
|
255
|
+
<state id="b"/>
|
|
256
|
+
</scxml>
|
|
257
|
+
SCXML
|
|
258
|
+
|
|
259
|
+
outbox = Outbox.new
|
|
260
|
+
processors = { "outbox" => outbox }
|
|
261
|
+
waiting = Statifier.start(notify, session_id: "s1", processors:)
|
|
262
|
+
sent = Statifier.step(waiting.value.state, "e.go", processors:)
|
|
263
|
+
outbox.seen # => [["t1", "e.out", "s1"]]
|
|
264
|
+
sent.value.effects.map { |effect| effect.class.name.split("::").last } # => ["Send"]
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
A send a processor cannot deliver comes back to the chart as
|
|
268
|
+
`error.communication`, its `sendid` the send's id whether or not the author
|
|
269
|
+
named it. A processor that knows while it is handed the send answers
|
|
270
|
+
`Statifier::Driver::DeliveryFailure.new(reason: ...)` from `deliver`, and the
|
|
271
|
+
chart takes the error within that call. A host that learns of the miss later
|
|
272
|
+
reports it with the send it was handed; the reason is the host's own and the
|
|
273
|
+
chart is not handed it:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
failed = Statifier.report_send_failed(notify, sent.value.state, sent.value.effects.first,
|
|
277
|
+
reason: "unreachable", processors:)
|
|
278
|
+
failed.ok? # => true
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Once the chart has stopped the report is refused with `:not_running`, and while
|
|
282
|
+
a round budget has halted it with `:halted`; nothing is written, and the miss is
|
|
283
|
+
the host's to record.
|
|
284
|
+
|
|
285
|
+
An `<invoke>` of the SCXML type runs in process: the driver compiles the
|
|
286
|
+
`<content>` markup and starts it as a child session on the parent's virtual
|
|
287
|
+
clock, its session id the parent's, a dot and the invoke id. `src` is never
|
|
288
|
+
fetched, and an invoke of any other type raises `error.execution` inside the
|
|
289
|
+
chart.
|
|
290
|
+
|
|
291
|
+
## Position export and import
|
|
292
|
+
|
|
293
|
+
A position is where an execution stands, written as plain data in the
|
|
294
|
+
reference's string-id vocabulary: a Hash with String keys, whose values are
|
|
295
|
+
Strings, Integers, booleans, and Arrays and Hashes of them. A host stores it as
|
|
296
|
+
JSON, and imports it into a machine, which may be a fresh compile of the same
|
|
297
|
+
source, to continue from there. Every datamodel value in it is text in
|
|
298
|
+
predicator's tagged-value encoding, so a date, a duration or an undefined value
|
|
299
|
+
survives JSON:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
position = Statifier.export_position(counted.value.state).value
|
|
303
|
+
position["configuration"] # => ["a"]
|
|
304
|
+
position["datamodel"]["count"] # => "1"
|
|
305
|
+
|
|
306
|
+
require "json"
|
|
307
|
+
|
|
308
|
+
stored = JSON.generate(position)
|
|
309
|
+
restored = Statifier.import_position(Statifier.compile(source).value, JSON.parse(stored), processors: {})
|
|
310
|
+
restored.ok? # => true
|
|
311
|
+
|
|
312
|
+
# The restored execution steps as the original does
|
|
313
|
+
from_original = Statifier.step(counted.value.state, "e.inc")
|
|
314
|
+
from_restored = Statifier.step(restored.value, "e.inc")
|
|
315
|
+
Statifier.export_position(from_restored.value.state).value == Statifier.export_position(from_original.value.state).value # => true
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
A position holds what the interpreter holds between two calls and nothing of
|
|
319
|
+
the driver's own: no pending timer, no queued external event, no send a
|
|
320
|
+
processor holds, no virtual time and no donedata. An import starts those as a
|
|
321
|
+
fresh start does, so a host that needs a timer keeps it itself. An export is
|
|
322
|
+
refused with a `Statifier::Position::Refusal` (`:internal_queue_not_empty`,
|
|
323
|
+
`:unnameable_states` or `:unencodable_value`) and an import with
|
|
324
|
+
`:malformed_export` or `:unknown_state_ids`:
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
Statifier.import_position(machine, {}).error.reason # => :malformed_export
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
The round trip is proved over the conformance corpus, not only by the example
|
|
331
|
+
above: the test suite takes a point after the start and after every step of
|
|
332
|
+
each scion case, exports the position there, carries it through JSON, imports
|
|
333
|
+
it into a fresh compile and drives the remaining steps, requiring the imported
|
|
334
|
+
execution to agree with the unbroken one at every later step
|
|
335
|
+
(`test/conformance/position_property.rb`).
|
|
336
|
+
|
|
337
|
+
## The Basic HTTP processor
|
|
338
|
+
|
|
339
|
+
The HTTP side of the gem is opt-in: `require "statifier/basichttp"` loads it,
|
|
340
|
+
and `require "statifier"` alone loads none of it and no HTTP library. It holds
|
|
341
|
+
the Basic HTTP Event I/O Processor of SCXML appendix C.2, the decoder for the
|
|
342
|
+
requests it sends, and the HTTP transport the processor makes its requests
|
|
343
|
+
through.
|
|
344
|
+
|
|
345
|
+
### Registering the processor
|
|
346
|
+
|
|
347
|
+
`Statifier::BasicHTTP::Processor.new` takes the address the host's own server
|
|
348
|
+
answers at (`base_url:`), a `report:` callable that takes each send the
|
|
349
|
+
processor could not deliver, and optionally a `transport:` (below; a
|
|
350
|
+
`NetHTTPTransport` when absent). Its `send_types` names it under the
|
|
351
|
+
processor's URI, `Statifier::BasicHTTP::EVENT_PROCESSOR`
|
|
352
|
+
(`http://www.w3.org/TR/scxml/#BasicHTTPEventProcessor`), and under the short
|
|
353
|
+
form `basichttp`, so a chart may name either; pass them as `processors:` on
|
|
354
|
+
every call. A session's `_ioprocessors` then carries an entry under each, both
|
|
355
|
+
with the same `location`: the base URL, `/` and the session's id.
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
require "statifier/basichttp"
|
|
359
|
+
|
|
360
|
+
misses = []
|
|
361
|
+
http = Statifier::BasicHTTP::Processor.new(base_url: "http://localhost:8080/scxml", report: misses.method(:<<))
|
|
362
|
+
processors = http.send_types
|
|
363
|
+
|
|
364
|
+
machine = Statifier.compile(<<~SCXML).value
|
|
365
|
+
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0">
|
|
366
|
+
<state id="a">
|
|
367
|
+
<onentry>
|
|
368
|
+
<send event="e.go" type="basichttp" target="http://localhost:8080/scxml/s2">
|
|
369
|
+
<param name="reply" expr="_ioprocessors['basichttp']['location']"/>
|
|
370
|
+
</send>
|
|
371
|
+
</onentry>
|
|
372
|
+
<transition event="error.communication" target="failed"/>
|
|
373
|
+
</state>
|
|
374
|
+
<state id="failed"/>
|
|
375
|
+
</scxml>
|
|
376
|
+
SCXML
|
|
377
|
+
|
|
378
|
+
moved = Statifier.start(machine, session_id: "s1", processors:).value
|
|
379
|
+
# The POST was made while the call ran; a miss is reported back after it returns
|
|
380
|
+
misses.each do |miss|
|
|
381
|
+
moved = Statifier.report_send_failed(machine, moved.state, miss.effect, reason: miss.reason, processors:).value
|
|
382
|
+
end
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### What a send becomes
|
|
386
|
+
|
|
387
|
+
Each send the processor is handed becomes one POST to its target, made once
|
|
388
|
+
through the transport while the call that handed it runs. The request is built
|
|
389
|
+
as the reference's processor builds it, by the shape of the send's data, with
|
|
390
|
+
one difference: names and keys always stand in byte order, which is the
|
|
391
|
+
reference's order for a map of 32 keys or fewer, while it lists a larger map in
|
|
392
|
+
an order of its own:
|
|
393
|
+
|
|
394
|
+
| The send's data | Body | Content type | The event name |
|
|
395
|
+
|---|---|---|---|
|
|
396
|
+
| a Hash (its `namelist` and `<param>` values) | each name and value as a form field, in byte order of the names | `application/x-www-form-urlencoded` | the form field `_scxmleventname`, first |
|
|
397
|
+
| none | the event name alone, as a form field | `application/x-www-form-urlencoded` | the form field `_scxmleventname` |
|
|
398
|
+
| any other value (a `<content>` body) | the value written as text | `text/plain` | `_scxmleventname` in the target's query string |
|
|
399
|
+
|
|
400
|
+
A send with no event name carries no `_scxmleventname`. A value is written as
|
|
401
|
+
text the same way wherever it goes:
|
|
402
|
+
|
|
403
|
+
| Value | Text |
|
|
404
|
+
|---|---|
|
|
405
|
+
| a String | as it is |
|
|
406
|
+
| `nil` | `null` |
|
|
407
|
+
| `Predicator::UNDEFINED` | the empty string |
|
|
408
|
+
| `true`, `false` | `true`, `false` |
|
|
409
|
+
| an Integer | its decimal digits |
|
|
410
|
+
| a Float | its shortest digits, as the reference spells them: `1.5`, `2.0`, `1.0e15`, `1.0e-7` |
|
|
411
|
+
| a list or a map | JSON text, `[1,2]` or `{"a":1}`, a map's members in byte order of their keys and `Predicator::UNDEFINED` inside as `null` |
|
|
412
|
+
| a date, a datetime, a duration, or a list or a map holding a value with no JSON form | predicator's tagged-value text, or the empty string when that encoder refuses the value |
|
|
413
|
+
|
|
414
|
+
Every request also carries the send's deduplication key in the
|
|
415
|
+
`scxml-send-key` header: the session's id, the send id, the macrostep, the
|
|
416
|
+
microstep, the round, the content index, the owner (`onentry.2.0`,
|
|
417
|
+
`transition.4`) and the ordinal, joined by `/`, the first two percent-encoded.
|
|
418
|
+
The same send carries the same key each time, so a receiver that deduplicates
|
|
419
|
+
on it takes each send once.
|
|
420
|
+
|
|
421
|
+
### When a send fails
|
|
422
|
+
|
|
423
|
+
A send with no target fails within the call that handed it, as appendix C.2.2
|
|
424
|
+
requires: the chart takes `error.communication` at the send's place and no
|
|
425
|
+
request is made. Any other miss is handed to `report:` as a
|
|
426
|
+
`Statifier::BasicHTTP::Failure` (its `session_id`, the send as `effect`, a
|
|
427
|
+
`reason` and a `detail`) before the processor returns, as the reference's
|
|
428
|
+
processor reports a miss to the sending session, which takes it after the step
|
|
429
|
+
it is in. The host
|
|
430
|
+
passes `effect` to `Statifier.report_send_failed` once the call has returned,
|
|
431
|
+
with the state that call answered:
|
|
432
|
+
|
|
433
|
+
| `reason` | When | `detail` |
|
|
434
|
+
|---|---|---|
|
|
435
|
+
| `:http_status` | the response's status is outside 2xx | the `Response` |
|
|
436
|
+
| `:transport_error` | the transport raised, or answered something other than a `Response` | the error |
|
|
437
|
+
| `:invalid_target` | the target is not a String | the target |
|
|
438
|
+
| `:no_timer` | the send has a delay | `nil` |
|
|
439
|
+
|
|
440
|
+
The processor holds no timer, so a delayed send is reported rather than held,
|
|
441
|
+
where the reference's processor holds it on a timer of its own.
|
|
442
|
+
|
|
443
|
+
### The receive side
|
|
444
|
+
|
|
445
|
+
`Statifier::BasicHTTP.decode` turns a request a host's own server took into the
|
|
446
|
+
event it carries, as the reference's decoder does; the gem runs no server. It
|
|
447
|
+
takes the method, the content type, the body, the query string and the
|
|
448
|
+
`scxml-send-key` header's value, and answers a `Statifier::Result` holding a
|
|
449
|
+
`Statifier::Event` or a `Statifier::BasicHTTP::Refusal`:
|
|
450
|
+
|
|
451
|
+
| Request | Event |
|
|
452
|
+
|---|---|
|
|
453
|
+
| a method other than POST | refused, `:method_not_allowed`: answer 405 with `Allow: POST` |
|
|
454
|
+
| a query string or body that does not decode to UTF-8 | refused, `:not_utf8`: answer 400 |
|
|
455
|
+
| an `scxml-send-key` that is not eight fields joined by `/` | refused, `:malformed_send_key`: answer 400 |
|
|
456
|
+
| a `_scxmleventname` field, in the query string or a form body | its first value, the query string's first, is the name |
|
|
457
|
+
| no `_scxmleventname` | the name is `HTTP.POST` |
|
|
458
|
+
| a form body | its other fields and the query string's are the data, a Hash, each value read as a `<content>` body's text is (`2` is the number 2), the last of a repeated name winning |
|
|
459
|
+
| a body of any other content type, JSON included | the body is the data, read the same way; the query string gives the name only |
|
|
460
|
+
|
|
461
|
+
The event's `origintype` is `EVENT_PROCESSOR`. A server resolves the request's
|
|
462
|
+
location to the session it holds, passes the event's `name` and `data` to
|
|
463
|
+
`Statifier.step`, and answers 204; deduplicating on the `scxml-send-key` value
|
|
464
|
+
is the server's. `Statifier.step` takes a name and data, so the chart reads no
|
|
465
|
+
`_event.origintype` for the event.
|
|
466
|
+
|
|
467
|
+
```ruby
|
|
468
|
+
decoded = Statifier::BasicHTTP.decode(method: "POST", content_type: "application/x-www-form-urlencoded",
|
|
469
|
+
body: "_scxmleventname=e.go&n=2", query: nil)
|
|
470
|
+
decoded.value.name # => "e.go"
|
|
471
|
+
decoded.value.data # => {"n" => 2}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### The HTTP transport
|
|
475
|
+
|
|
476
|
+
A transport is anything that responds to `call`: a lambda, a method object or
|
|
477
|
+
an object. It takes one request and makes it, and either answers the response
|
|
478
|
+
that came back or raises the transport error:
|
|
479
|
+
|
|
480
|
+
- **The request**, `Statifier::BasicHTTP::Request`, a frozen value with
|
|
481
|
+
`http_method` (an HTTP token such as `"POST"`), `url` (an absolute URL),
|
|
482
|
+
`headers` (name and value pairs, in order, a name allowed more than once)
|
|
483
|
+
and `body` (a String, already encoded, empty for none). Building one keeps
|
|
484
|
+
a string that is already frozen and copies any other, and refuses a method or header name that is not an
|
|
485
|
+
HTTP token and a header value holding a carriage return, a line feed or a
|
|
486
|
+
NUL.
|
|
487
|
+
- **The response**, `Statifier::BasicHTTP::Response`, a frozen value with
|
|
488
|
+
`status` (an Integer from 100 to 599), `headers` (name and value pairs) and
|
|
489
|
+
`body` (a String, empty for none). Every status is a response: a status
|
|
490
|
+
outside 2xx is answered, not raised.
|
|
491
|
+
- **The error**, `Statifier::BasicHTTP::TransportError`, the one error a
|
|
492
|
+
transport raises when no response came back: a URL it cannot request, a
|
|
493
|
+
connection refused or failed, a time bound passed, an exchange broken off.
|
|
494
|
+
The error that caused it, when there was one, is its `cause`. One edge: a
|
|
495
|
+
response the default transport reads but cannot build a `Response` from (a
|
|
496
|
+
header name that is not an HTTP token, a header value holding a carriage
|
|
497
|
+
return, a line feed or a NUL) is raised as a `TransportError` too, since no
|
|
498
|
+
usable response came back.
|
|
499
|
+
|
|
500
|
+
A transport makes one attempt; retrying is not its job.
|
|
501
|
+
|
|
502
|
+
The default transport, `Statifier::BasicHTTP::NetHTTPTransport`, uses
|
|
503
|
+
Net::HTTP from the standard library (the `net-http` default gem), so it adds
|
|
504
|
+
no runtime dependency. Each call opens a connection of its own, verifies an
|
|
505
|
+
`https` peer as Net::HTTP does by default, and bounds each of opening the
|
|
506
|
+
connection, writing the request and reading the response by `timeout:`
|
|
507
|
+
seconds, five when absent. A host that needs other Net::HTTP settings (a
|
|
508
|
+
proxy, a certificate file) passes `connect:`, a callable that answers the
|
|
509
|
+
connection for a URL; `NetHTTPTransport.connection(uri, timeout:)` is the
|
|
510
|
+
default one, for a host's own to start from.
|
|
511
|
+
|
|
512
|
+
```ruby
|
|
513
|
+
require "statifier/basichttp"
|
|
514
|
+
|
|
515
|
+
request = Statifier::BasicHTTP::Request.new(
|
|
516
|
+
http_method: "POST",
|
|
517
|
+
url: "http://localhost:8080/s1",
|
|
518
|
+
headers: [["content-type", "text/plain"]],
|
|
519
|
+
body: "e.go"
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
# A host's own transport, here one that answers every request in memory
|
|
523
|
+
echo = ->(req) { Statifier::BasicHTTP::Response.new(status: 200, body: req.body) }
|
|
524
|
+
echo.call(request).body # => "e.go"
|
|
525
|
+
|
|
526
|
+
# The default transport
|
|
527
|
+
transport = Statifier::BasicHTTP::NetHTTPTransport.new(timeout: 2)
|
|
528
|
+
begin
|
|
529
|
+
transport.call(request).status
|
|
530
|
+
rescue Statifier::BasicHTTP::TransportError => e
|
|
531
|
+
e.message # why no response came back
|
|
532
|
+
end
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
## Compatibility
|
|
536
|
+
|
|
537
|
+
- **Ruby 3.3 and later.** `required_ruby_version` in the gemspec is
|
|
538
|
+
`>= 3.3`, and CI runs the test suite on Ruby 3.3, 3.4 and 4.0.
|
|
539
|
+
- **One runtime dependency**, `predicator` (`>= 2.0`, `< 3`). The XML front
|
|
540
|
+
end is hand-written, so no XML gem is needed, and the default HTTP
|
|
541
|
+
transport uses Net::HTTP, a default gem.
|
|
542
|
+
- **No shared mutable state.** The machine and the state are immutable
|
|
543
|
+
values, there is no global configuration, and nothing is kept between calls
|
|
544
|
+
but the state a call answers. Processors and the clock are passed with each
|
|
545
|
+
call, so several executions can run side by side.
|
|
546
|
+
|
|
547
|
+
## Conformance
|
|
548
|
+
|
|
549
|
+
The gem is held to the conformance corpus of statifier-ex, vendored under
|
|
550
|
+
`conformance/statifier/` at the tag `v2.16.0` (commit
|
|
551
|
+
`76742af12138887c7f95125a80edf2b0a7dbaaf3`, corpus hash
|
|
552
|
+
`sha256:7440f9a34a84b0657cb3bde11f96cf1707db08fb05e202c1e0249ab965517cc8`,
|
|
553
|
+
recorded in `conformance/statifier.vendored.json`). The corpus is the arbiter:
|
|
554
|
+
a case this package answers differently from the corpus is this package's bug,
|
|
555
|
+
and no case is skipped. `conformance/registry.json` lists the cases this gem
|
|
556
|
+
claims to pass, and the gate fails if one of them does not.
|
|
557
|
+
|
|
558
|
+
| Suite | Claim | Cases | Claimed | Unclaimed |
|
|
559
|
+
|---|---|---|---|---|
|
|
560
|
+
| scion | `scion` | 119 | 119 | 0 |
|
|
561
|
+
| statifier | `statifier` | 36 | 36 | 0 |
|
|
562
|
+
| w3c | `w3c-mandatory` | 154 | 152 | 2 |
|
|
563
|
+
| w3c | `w3c-optional` | 14 | 13 | 1 |
|
|
564
|
+
| all | | 323 | 320 | 3 |
|
|
565
|
+
|
|
566
|
+
The registry holds 320 entries, which is the sum of the Claimed column. The
|
|
567
|
+
numbers are read from the registry and from the vendored corpus, not counted
|
|
568
|
+
by hand, and `test/conformance/readme_test.rb` fails when this section and
|
|
569
|
+
the registry disagree. A claim is the set of its entries, not a statement
|
|
570
|
+
that every case of its suite passes. The registry holds the same entries and
|
|
571
|
+
the same four claims as the reference's registry at that tag.
|
|
572
|
+
|
|
573
|
+
The optional w3c cases whose host needs the Basic HTTP Event I/O Processor
|
|
574
|
+
are driven through this gem's processor, delivering to an in-memory loopback
|
|
575
|
+
front in the test suite rather than to an HTTP server on a local port as the
|
|
576
|
+
reference's harness does: no socket is opened, so these cases prove the
|
|
577
|
+
processor and the core's event path, not a network round trip.
|
|
578
|
+
|
|
579
|
+
Three cases are not claimed, and the reference's registry does not claim any
|
|
580
|
+
of them either:
|
|
581
|
+
|
|
582
|
+
| Case | Claim | Why it is not claimed |
|
|
583
|
+
|---|---|---|
|
|
584
|
+
| `w3c/test201` | `w3c-optional` | The chart sends to itself over Basic HTTP and also sends its own timeout event; the chart's own event is taken before the delivered one, so it ends in `fail` |
|
|
585
|
+
| `w3c/test330` | `w3c-mandatory` | The guards compare `_event` fields against `undefined`, and a field the event does not carry (`sendid`, `origin`, `origintype`, `invokeid`, `data`) makes the comparison false, so it ends in `fail` |
|
|
586
|
+
| `w3c/test552` | `w3c-mandatory` | A `<data src>` is never fetched, so the datamodel value the case reads is never bound and it ends in `fail` |
|
|
587
|
+
|
|
588
|
+
Running the gate prints the gap list: every unclaimed case with the features it
|
|
589
|
+
needs and whether it passes or why it fails.
|
|
590
|
+
|
|
591
|
+
## Documentation
|
|
592
|
+
|
|
593
|
+
- Look up
|
|
594
|
+
- Every public method and class carries a YARD doc comment in `lib/`:
|
|
595
|
+
the `Statifier` module's ten calls, `Statifier::Result` and
|
|
596
|
+
`Statifier::AcceptsCheck`, the driver's values (`Statifier::Driver::Moved`,
|
|
597
|
+
`DoneStatus`, `Context` and `DeliveryFailure`), `Statifier::Effect`,
|
|
598
|
+
`Statifier::Event` and `Statifier::Position`, and under
|
|
599
|
+
`Statifier::BasicHTTP` the processor, its failure, the decoder and its
|
|
600
|
+
refusal, and the HTTP transport's request, response, error and default
|
|
601
|
+
transport.
|
|
602
|
+
- [The changelog](CHANGELOG.md): what changed in each version.
|
|
603
|
+
- [The conformance apparatus](conformance/README.md): what is vendored, from
|
|
604
|
+
where, and how the check, the runner and the ratchet work.
|
|
605
|
+
- Understand
|
|
606
|
+
- [The reference implementation](https://github.com/riddler/statifier-ex),
|
|
607
|
+
whose records decide the statechart semantics this gem implements.
|
|
608
|
+
|
|
609
|
+
## Development
|
|
610
|
+
|
|
611
|
+
```bash
|
|
612
|
+
mise install # the Ruby that mise.toml pins
|
|
613
|
+
mise exec -- bundle install # the dev tools
|
|
614
|
+
mise exec -- bundle exec rake # the gate: rubocop, corpus:check, registry, then the tests
|
|
615
|
+
```
|
|
32
616
|
|
|
33
617
|
## Contributing
|
|
34
618
|
|
|
35
|
-
Bug reports and pull requests are welcome on GitHub at
|
|
619
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
620
|
+
https://github.com/riddler/statifier-rb.
|
|
36
621
|
|
|
37
622
|
## License
|
|
38
623
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
## Code of Conduct
|
|
624
|
+
MIT; see `LICENSE`.
|
|
42
625
|
|
|
43
|
-
|
|
626
|
+
<!-- BADGES -->
|
|
627
|
+
[badge_license_url]: https://img.shields.io/badge/license-MIT-brightgreen.svg?cacheSeconds=3600?style=flat-square
|
|
628
|
+
[badge_ci_url]: https://github.com/riddler/statifier-rb/actions/workflows/ci.yml/badge.svg
|