dogfood 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/bin/dogfood +5 -0
- data/lib/dogfood/cli.rb +174 -0
- data/lib/dogfood/day_generator.rb +95 -0
- data/lib/dogfood/dsl/compiler.rb +253 -0
- data/lib/dogfood/dsl/evaluator.rb +211 -0
- data/lib/dogfood/dsl/explain_renderer.rb +172 -0
- data/lib/dogfood/dsl/schema.rb +200 -0
- data/lib/dogfood/ledger.rb +29 -0
- data/lib/dogfood/pack.rb +65 -0
- data/lib/dogfood/railtie.rb +20 -0
- data/lib/dogfood/randomness.rb +37 -0
- data/lib/dogfood/renderer.rb +40 -0
- data/lib/dogfood/scheduler.rb +47 -0
- data/lib/dogfood/step.rb +19 -0
- data/lib/dogfood/story_base.rb +66 -0
- data/lib/dogfood/story_builder.rb +23 -0
- data/lib/dogfood/templates/day_log.md.erb +44 -0
- data/lib/dogfood/test/smoke.rb +20 -0
- data/lib/dogfood/version.rb +5 -0
- data/lib/dogfood.rb +26 -0
- data/lib/generators/dogfood/install/install_generator.rb +30 -0
- metadata +110 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d8f5e84e866312bd228f0fa94068f9a1553c52ef15221e541f933f3713c6ca84
|
|
4
|
+
data.tar.gz: 382acac5d2fddd967522af9f7c97a7a2dde1ab73a12dc67655a31ce3ee7108cc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e3876f850dcd5bcc59b58728ad4df5090400ae2fd6ad7391edd9a2f26db9a48104f416c7300810a252d4907ae9eadb1aa0bf5fc160c737eb95189739784e55c2
|
|
7
|
+
data.tar.gz: b1f219bb17809b21fbb1ddcaaf44941bb47c30497513c27341a24a366d453f5eec8c293702a9ea6ffaf39613b255e37dec6df22376a5b3128bd37f97c8087db2
|
data/bin/dogfood
ADDED
data/lib/dogfood/cli.rb
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Dogfood
|
|
6
|
+
class CLI
|
|
7
|
+
def initialize(argv)
|
|
8
|
+
@argv = argv
|
|
9
|
+
@options = {
|
|
10
|
+
output: default_output_path,
|
|
11
|
+
days: 1,
|
|
12
|
+
wo_per_day: (3..6),
|
|
13
|
+
fleet_ratio: 0.25,
|
|
14
|
+
seed: nil,
|
|
15
|
+
scenario: nil
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :options
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
parse!
|
|
23
|
+
|
|
24
|
+
if @options[:help]
|
|
25
|
+
print_help
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
unless @options[:pack]
|
|
30
|
+
warn "Error: --pack PATH is required"
|
|
31
|
+
print_help
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
load_pack(@options[:pack])
|
|
36
|
+
|
|
37
|
+
if @options[:list]
|
|
38
|
+
list_scenarios
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if @options[:explain]
|
|
43
|
+
explain_file(@options[:explain])
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if @options[:dry_run] && @options[:scenario]
|
|
48
|
+
explain_scenario(@options[:scenario])
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
generator = Dogfood::DayGenerator.new(
|
|
53
|
+
pack: Dogfood::Pack.current,
|
|
54
|
+
days: @options[:days],
|
|
55
|
+
wo_per_day: @options[:wo_per_day],
|
|
56
|
+
fleet_ratio: @options[:fleet_ratio],
|
|
57
|
+
seed: @options[:seed],
|
|
58
|
+
scenario: @options[:scenario]
|
|
59
|
+
)
|
|
60
|
+
result = generator.generate
|
|
61
|
+
|
|
62
|
+
output = Dogfood::Renderer.new(result).render
|
|
63
|
+
FileUtils.mkdir_p(File.dirname(@options[:output]))
|
|
64
|
+
File.write(@options[:output], output)
|
|
65
|
+
|
|
66
|
+
puts "Generated simulation: #{@options[:output]}"
|
|
67
|
+
puts "Days: #{result[:days].length}"
|
|
68
|
+
puts "Seed: #{result[:seed]}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def parse!
|
|
72
|
+
i = 0
|
|
73
|
+
while i < @argv.length
|
|
74
|
+
arg = @argv[i]
|
|
75
|
+
case arg
|
|
76
|
+
when "--pack"
|
|
77
|
+
@options[:pack] = @argv[i + 1]
|
|
78
|
+
i += 1
|
|
79
|
+
when "--scenario"
|
|
80
|
+
@options[:scenario] = @argv[i + 1]
|
|
81
|
+
i += 1
|
|
82
|
+
when "--list", "-l"
|
|
83
|
+
@options[:list] = true
|
|
84
|
+
when "--explain"
|
|
85
|
+
@options[:explain] = @argv[i + 1]
|
|
86
|
+
i += 1
|
|
87
|
+
when "--dry-run"
|
|
88
|
+
@options[:dry_run] = true
|
|
89
|
+
when "--random", "-r"
|
|
90
|
+
@options[:seed] = Random.new_seed
|
|
91
|
+
when /^--days(?:=(.+))?$/
|
|
92
|
+
@options[:days] = ($1 || @argv[i + 1]).to_i
|
|
93
|
+
i += 1 unless $1
|
|
94
|
+
when /^--wo-per-day(?:=(.+))?$/
|
|
95
|
+
@options[:wo_per_day] = parse_wo_per_day($1 || @argv[i + 1])
|
|
96
|
+
i += 1 unless $1
|
|
97
|
+
when /^--fleet-ratio(?:=(.+))?$/
|
|
98
|
+
@options[:fleet_ratio] = ($1 || @argv[i + 1]).to_f
|
|
99
|
+
i += 1 unless $1
|
|
100
|
+
when /^--seed(?:=(.+))?$/
|
|
101
|
+
@options[:seed] = ($1 || @argv[i + 1]).to_i
|
|
102
|
+
i += 1 unless $1
|
|
103
|
+
when /^--output(?:=(.+))?$/, "-o"
|
|
104
|
+
@options[:output] = ($1 || @argv[i + 1])
|
|
105
|
+
i += 1 unless $1
|
|
106
|
+
when "--help", "-h"
|
|
107
|
+
@options[:help] = true
|
|
108
|
+
end
|
|
109
|
+
i += 1
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def load_pack(path)
|
|
116
|
+
Dogfood::Pack.current = nil
|
|
117
|
+
load File.expand_path(path)
|
|
118
|
+
raise "Pack file did not set Dogfood::Pack.current" unless Dogfood::Pack.current
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def parse_wo_per_day(value)
|
|
122
|
+
if value.include?("-")
|
|
123
|
+
min, max = value.split("-").map(&:to_i)
|
|
124
|
+
(min..max)
|
|
125
|
+
else
|
|
126
|
+
value.to_i
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def list_scenarios
|
|
131
|
+
puts "Available scenarios:"
|
|
132
|
+
Dogfood::Pack.current.scenario_keys.each do |key|
|
|
133
|
+
puts " - #{key}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def explain_file(path)
|
|
138
|
+
require "yaml"
|
|
139
|
+
yaml = YAML.load_file(path)
|
|
140
|
+
Dogfood::DSL::Schema.validate!(yaml)
|
|
141
|
+
klass = Dogfood::DSL::Compiler.compile(yaml, pack: Dogfood::Pack.current)
|
|
142
|
+
puts Dogfood::DSL::ExplainRenderer.new(klass.compiled_ast).render
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def explain_scenario(name)
|
|
146
|
+
klass = Dogfood::Pack.current.find_scenario(name)
|
|
147
|
+
puts Dogfood::DSL::ExplainRenderer.new(klass.compiled_ast).render
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def default_output_path
|
|
151
|
+
timestamp = Time.now.strftime("%Y-%m-%d-%H%M%S")
|
|
152
|
+
"tmp/simulations/#{timestamp}-simulation.md"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def print_help
|
|
156
|
+
puts <<~HELP
|
|
157
|
+
Usage: dogfood [options]
|
|
158
|
+
|
|
159
|
+
--pack PATH Ruby file that registers a Dogfood::Pack (required)
|
|
160
|
+
--scenario NAME Run a specific named scenario (otherwise: random mix)
|
|
161
|
+
--days N Number of simulated days (default: 1)
|
|
162
|
+
--wo-per-day N or M-N Work orders per day (default: 3-6)
|
|
163
|
+
--fleet-ratio RATIO Fraction of fleet stories (default: 0.25)
|
|
164
|
+
--seed N Set random seed for reproducibility
|
|
165
|
+
--random Use a random seed (prints it)
|
|
166
|
+
--output PATH, -o PATH Output file path (default: tmp/simulations/<timestamp>.md)
|
|
167
|
+
--list, -l List available scenarios
|
|
168
|
+
--explain FILE Compile FILE and print the stage/decision tree, no RNG
|
|
169
|
+
--dry-run --scenario N Same as --explain but takes a registered name
|
|
170
|
+
--help, -h Show help
|
|
171
|
+
HELP
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Dogfood
|
|
6
|
+
class DayGenerator
|
|
7
|
+
DEFAULTS = { days: 1, wo_per_day: (3..6), fleet_ratio: 0.25, seed: nil }.freeze
|
|
8
|
+
|
|
9
|
+
def initialize(pack:, days: nil, wo_per_day: nil, fleet_ratio: nil, seed: nil, scenario: nil)
|
|
10
|
+
@pack = pack
|
|
11
|
+
@config = DEFAULTS.dup
|
|
12
|
+
@config[:days] = days if days
|
|
13
|
+
@config[:wo_per_day] = wo_per_day if wo_per_day
|
|
14
|
+
@config[:fleet_ratio] = fleet_ratio if fleet_ratio
|
|
15
|
+
@config[:seed] = seed if seed
|
|
16
|
+
@scenario = scenario&.to_sym
|
|
17
|
+
@rng = Dogfood::Randomness.new(seed: @config[:seed])
|
|
18
|
+
@ledger = Dogfood::Ledger.new
|
|
19
|
+
@builder = Dogfood::StoryBuilder.new(pack: @pack, rng: @rng)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def generate
|
|
23
|
+
days = @config[:days].times.map { |i| build_day(i) }
|
|
24
|
+
{ days: days, seed: @rng.seed }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def build_day(day_index)
|
|
30
|
+
carry_overs = @ledger.resumable_for(day_index)
|
|
31
|
+
new_stories = build_new_stories(day_index, carry_overs.length)
|
|
32
|
+
active = carry_overs + new_stories
|
|
33
|
+
|
|
34
|
+
morning, afternoon, evening = [], [], []
|
|
35
|
+
active.shuffle(random: @rng).each_with_index do |entry, i|
|
|
36
|
+
story = entry.is_a?(Hash) ? entry[:story] : entry
|
|
37
|
+
slot = time_slot_for(i, active.length)
|
|
38
|
+
result = story.advance(day_index: day_index)
|
|
39
|
+
case slot
|
|
40
|
+
when :morning then morning << result
|
|
41
|
+
when :afternoon then afternoon << result
|
|
42
|
+
when :evening then evening << result
|
|
43
|
+
end
|
|
44
|
+
update_ledger(result)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
index: day_index,
|
|
49
|
+
date: Date.today + day_index,
|
|
50
|
+
morning: morning, afternoon: afternoon, evening: evening,
|
|
51
|
+
carry_overs: carry_overs.map { |s| summarize(s) },
|
|
52
|
+
end_of_day_board: @ledger.open_stories.map { |s| summarize(s) }
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_new_stories(day_index, carry_over_count)
|
|
57
|
+
target = wo_count_for_day
|
|
58
|
+
count = [target - carry_over_count, 0].max
|
|
59
|
+
count.times.map do
|
|
60
|
+
story = @builder.build(fleet_ratio: @config[:fleet_ratio], include: @scenario)
|
|
61
|
+
@ledger.register({ story: story, id: story.id, status: :estimate })
|
|
62
|
+
story
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def update_ledger(result)
|
|
67
|
+
if result[:terminal]
|
|
68
|
+
@ledger.close(result[:id])
|
|
69
|
+
else
|
|
70
|
+
entry = @ledger.find(result[:id])
|
|
71
|
+
if entry
|
|
72
|
+
entry[:status] = result[:state][:status]
|
|
73
|
+
entry[:substatus] = result[:state][:substatus]
|
|
74
|
+
entry[:resume_on] = result[:resume_on]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def wo_count_for_day
|
|
80
|
+
range = @config[:wo_per_day]
|
|
81
|
+
range.is_a?(Range) ? @rng.rand(range) : range
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def time_slot_for(index, total)
|
|
85
|
+
return :morning if index < (total * 0.5).ceil
|
|
86
|
+
return :afternoon if index < (total * 0.85).ceil
|
|
87
|
+
:evening
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def summarize(story_or_hash)
|
|
91
|
+
story = story_or_hash.is_a?(Hash) ? story_or_hash[:story] : story_or_hash
|
|
92
|
+
{ id: story.id, status: story.state[:status], substatus: story.state[:substatus] }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dogfood
|
|
4
|
+
module DSL
|
|
5
|
+
# Compiles a validated scenario YAML hash into an anonymous
|
|
6
|
+
# Class(Dogfood::StoryBase). See SPEC.md 4.9 and 4.7.
|
|
7
|
+
module Compiler
|
|
8
|
+
TERMINAL_STATUSES = %i[paid declined voided].freeze
|
|
9
|
+
|
|
10
|
+
def self.compile(yaml, pack:)
|
|
11
|
+
ast = parse_ast(yaml)
|
|
12
|
+
validate_calls(ast, pack)
|
|
13
|
+
|
|
14
|
+
klass = Class.new(Dogfood::StoryBase) do
|
|
15
|
+
pack.step_modules.each { |m| include(m) }
|
|
16
|
+
pack.mixin_modules.each { |m| include(m) }
|
|
17
|
+
define_singleton_method(:compiled_ast) { ast }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
klass.define_method(:title) { yaml["title"] }
|
|
21
|
+
|
|
22
|
+
klass.define_method(:advance) do |day_index:|
|
|
23
|
+
@current_day = day_index
|
|
24
|
+
@bindings ||= {}
|
|
25
|
+
@branch_for ||= {}
|
|
26
|
+
@next_stage ||= nil
|
|
27
|
+
|
|
28
|
+
stages.each do |stage_spec|
|
|
29
|
+
next unless @next_stage.nil? || @next_stage == stage_spec[:stage]
|
|
30
|
+
run_stage(stage_spec)
|
|
31
|
+
@next_stage = next_stage_after(stage_spec[:stage])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
terminal = TERMINAL_STATUSES.include?(@state[:status])
|
|
35
|
+
{
|
|
36
|
+
id: id,
|
|
37
|
+
steps: @steps,
|
|
38
|
+
state: @state,
|
|
39
|
+
terminal: terminal,
|
|
40
|
+
resume_on: terminal ? nil : next_resume_day
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
klass.define_method(:stages) { self.class.compiled_ast[:stages] }
|
|
45
|
+
|
|
46
|
+
klass.define_method(:next_stage_after) do |stage|
|
|
47
|
+
idx = self.class.compiled_ast[:stages].index { |s| s[:stage] == stage }
|
|
48
|
+
nxt = self.class.compiled_ast[:stages][idx + 1]
|
|
49
|
+
nxt && nxt[:stage]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
klass.define_method(:run_stage) do |stage_spec|
|
|
53
|
+
branch = resolve_branch(stage_spec)
|
|
54
|
+
stage_spec[:steps].each do |step_spec|
|
|
55
|
+
run_step(step_spec, branch)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
klass.define_method(:resolve_branch) do |stage_spec|
|
|
60
|
+
weights = stage_spec[:branch_weights]
|
|
61
|
+
return nil unless weights
|
|
62
|
+
@branch_for[stage_spec[:stage]] ||= @rng.branch(weights)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
klass.define_method(:run_step) do |step_spec, branch|
|
|
66
|
+
case step_spec[:type]
|
|
67
|
+
when :call
|
|
68
|
+
return if step_spec[:when_branch] && step_spec[:when_branch] != branch
|
|
69
|
+
call_step(step_spec)
|
|
70
|
+
when :when
|
|
71
|
+
list = eval_expr(step_spec[:expr]) ? step_spec[:then] : step_spec[:else]
|
|
72
|
+
(list || []).each { |c| run_step(c, nil) }
|
|
73
|
+
when :maybe
|
|
74
|
+
step_spec[:then].each { |c| run_step(c, nil) } if @rng.rand < step_spec[:prob]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
klass.define_method(:call_step) do |call_spec|
|
|
79
|
+
if call_spec[:name] == :update_state
|
|
80
|
+
args = eval_with(call_spec[:with])
|
|
81
|
+
update_state(status: args[:status].to_sym, substatus: args[:substatus])
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
args = eval_with(call_spec[:with])
|
|
86
|
+
ret = send(call_spec[:name], **args)
|
|
87
|
+
bind_out(call_spec[:out], ret)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
klass.define_method(:eval_with) do |with_hash|
|
|
91
|
+
(with_hash || {}).each_with_object({}) do |(k, v), acc|
|
|
92
|
+
acc[k.to_sym] = Dogfood::DSL::Evaluator.eval(v, bindings: @bindings, state: @state, rng: @rng, self_object: self)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
klass.define_method(:eval_expr) do |expr|
|
|
97
|
+
Dogfood::DSL::Evaluator.eval(expr, bindings: @bindings, state: @state, rng: @rng, self_object: self)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
klass.define_method(:bind_out) do |out, ret|
|
|
101
|
+
return unless out
|
|
102
|
+
if out.is_a?(Array)
|
|
103
|
+
if ret.is_a?(Hash)
|
|
104
|
+
out.each { |name| @bindings[name.to_sym] = ret[name.to_sym] }
|
|
105
|
+
else
|
|
106
|
+
out.each_with_index { |name, i| @bindings[name.to_sym] = ret[i] }
|
|
107
|
+
end
|
|
108
|
+
else
|
|
109
|
+
@bindings[out.to_sym] = ret
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
klass.define_method(:next_resume_day) do
|
|
114
|
+
resume = self.class.compiled_ast[:resume]
|
|
115
|
+
resume && resume[:after_await] == :same_day ? @current_day : @current_day + 1
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
klass
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.parse_ast(yaml)
|
|
122
|
+
stages = yaml["stages"].map(&:to_sym)
|
|
123
|
+
branches = (yaml["branches"] || {}).each_with_object({}) do |(stage, weights), acc|
|
|
124
|
+
acc[stage.to_sym] = weights.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
stage_list = stages.map do |stage|
|
|
128
|
+
steps = yaml["stages_def"][stage.to_s].map { |s| parse_step(s, stage.to_s) }
|
|
129
|
+
{
|
|
130
|
+
stage: stage,
|
|
131
|
+
branch_weights: branches[stage],
|
|
132
|
+
steps: steps
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
resume = if yaml["resume"] && yaml["resume"]["after_await"]
|
|
137
|
+
{ after_await: yaml["resume"]["after_await"].to_sym }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
name: yaml["name"].to_sym,
|
|
142
|
+
title: yaml["title"],
|
|
143
|
+
stages: stage_list,
|
|
144
|
+
delays: (yaml["delays"] || {}).keys.map(&:to_sym),
|
|
145
|
+
resume: resume
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def self.parse_step(step, stage)
|
|
150
|
+
if step.key?("call")
|
|
151
|
+
out = step["out"]
|
|
152
|
+
{
|
|
153
|
+
type: :call,
|
|
154
|
+
name: step["call"].to_sym,
|
|
155
|
+
when_branch: step["when_branch"]&.to_sym,
|
|
156
|
+
with: step["with"] || {},
|
|
157
|
+
out: normalize_out(out)
|
|
158
|
+
}
|
|
159
|
+
elsif step.key?("when")
|
|
160
|
+
{
|
|
161
|
+
type: :when,
|
|
162
|
+
expr: step["when"],
|
|
163
|
+
then: step["then"].map { |c| parse_block_step(c, stage) },
|
|
164
|
+
else: step["else"] ? step["else"].map { |c| parse_block_step(c, stage) } : []
|
|
165
|
+
}
|
|
166
|
+
elsif step.key?("maybe")
|
|
167
|
+
{
|
|
168
|
+
type: :maybe,
|
|
169
|
+
prob: step["maybe"],
|
|
170
|
+
then: step["then"].map { |c| parse_call(c, stage) }
|
|
171
|
+
}
|
|
172
|
+
else
|
|
173
|
+
raise Dogfood::NestingTooDeep, "invalid step in stage #{stage}"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def self.parse_call(step, stage)
|
|
178
|
+
unless step.key?("call")
|
|
179
|
+
raise Dogfood::NestingTooDeep, "nesting too deep at stage #{stage}: only call steps allowed in blocks"
|
|
180
|
+
end
|
|
181
|
+
{
|
|
182
|
+
type: :call,
|
|
183
|
+
name: step["call"].to_sym,
|
|
184
|
+
when_branch: nil,
|
|
185
|
+
with: step["with"] || {},
|
|
186
|
+
out: normalize_out(step["out"])
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A step inside a `when`/`then`/`else` block may be a `call` or a
|
|
191
|
+
# `maybe` probability step, but never another `when`. This is the
|
|
192
|
+
# depth-1 limit (SPEC.md 4.7).
|
|
193
|
+
def self.parse_block_step(step, stage)
|
|
194
|
+
if step.key?("when")
|
|
195
|
+
raise Dogfood::NestingTooDeep, "nesting too deep at stage #{stage}: when blocks may not nest"
|
|
196
|
+
end
|
|
197
|
+
if step.key?("maybe")
|
|
198
|
+
{
|
|
199
|
+
type: :maybe,
|
|
200
|
+
prob: step["maybe"],
|
|
201
|
+
then: step["then"].map { |c| parse_call(c, stage) }
|
|
202
|
+
}
|
|
203
|
+
else
|
|
204
|
+
parse_call(step, stage)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def self.normalize_out(out)
|
|
209
|
+
case out
|
|
210
|
+
when Array then out.map(&:to_sym)
|
|
211
|
+
when String then out.to_sym
|
|
212
|
+
else nil
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def self.validate_calls(ast, pack)
|
|
217
|
+
names = collect_call_names(ast).uniq
|
|
218
|
+
names.each do |name|
|
|
219
|
+
next if name == :update_state
|
|
220
|
+
found = pack.step_modules.any? { |m| m.instance_methods.include?(name) } ||
|
|
221
|
+
pack.mixin_modules.any? { |m| m.instance_methods.include?(name) }
|
|
222
|
+
raise Dogfood::Pack::UnknownScenario, "call :#{name} does not resolve to a pack step module" unless found
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def self.collect_call_names(ast)
|
|
227
|
+
ast[:stages].flat_map { |s| collect_stage_names(s[:steps]) }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def self.collect_stage_names(steps)
|
|
231
|
+
steps.flat_map do |step|
|
|
232
|
+
case step[:type]
|
|
233
|
+
when :call then [step[:name]]
|
|
234
|
+
when :when
|
|
235
|
+
collect_block_names(step[:then]) + collect_block_names(step[:else])
|
|
236
|
+
when :maybe then step[:then].map { |c| c[:name] }
|
|
237
|
+
else []
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def self.collect_block_names(steps)
|
|
243
|
+
steps.flat_map do |step|
|
|
244
|
+
if step[:type] == :maybe
|
|
245
|
+
step[:then].map { |c| c[:name] }
|
|
246
|
+
else
|
|
247
|
+
[step[:name]]
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|