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
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dogfood
|
|
4
|
+
module DSL
|
|
5
|
+
# Evaluates minimal ${...} expressions. Safe: it is a tiny hand-rolled
|
|
6
|
+
# recursive-descent parser, NOT eval. See SPEC.md 4.4 for the grammar.
|
|
7
|
+
module Evaluator
|
|
8
|
+
TOKEN = /\$\{([^}]*)\}/
|
|
9
|
+
|
|
10
|
+
# Evaluates a value that may contain ${...} tokens.
|
|
11
|
+
# Returns the raw bound value if the entire input is a single ${...};
|
|
12
|
+
# otherwise interpolates tokens into the surrounding string.
|
|
13
|
+
def self.eval(value, bindings:, state:, rng:, self_object: nil)
|
|
14
|
+
if value.is_a?(String) && (m = TOKEN.match(value)) && m[0] == value
|
|
15
|
+
return evaluate_expression(m[1], bindings: bindings, state: state, rng: rng, self_object: self_object)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
case value
|
|
19
|
+
when String
|
|
20
|
+
value.gsub(TOKEN) do
|
|
21
|
+
result = evaluate_expression(Regexp.last_match(1), bindings: bindings, state: state, rng: rng, self_object: self_object)
|
|
22
|
+
stringify(result)
|
|
23
|
+
end
|
|
24
|
+
when Hash
|
|
25
|
+
value.each_with_object({}) do |(k, v), acc|
|
|
26
|
+
acc[eval(k, bindings: bindings, state: state, rng: rng, self_object: self_object)] =
|
|
27
|
+
eval(v, bindings: bindings, state: state, rng: rng, self_object: self_object)
|
|
28
|
+
end
|
|
29
|
+
when Array
|
|
30
|
+
value.map { |v| eval(v, bindings: bindings, state: state, rng: rng, self_object: self_object) }
|
|
31
|
+
else
|
|
32
|
+
value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Parses and evaluates a single expression (without ${...} delimiters).
|
|
37
|
+
def self.evaluate_expression(expr, bindings:, state:, rng:, self_object: nil)
|
|
38
|
+
Parser.new(expr, bindings: bindings, state: state, rng: rng, self_object: self_object).parse
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.stringify(value)
|
|
42
|
+
case value
|
|
43
|
+
when nil then "null"
|
|
44
|
+
when true then "true"
|
|
45
|
+
when false then "false"
|
|
46
|
+
else value.to_s
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Recursive-descent parser for the expression grammar.
|
|
51
|
+
class Parser
|
|
52
|
+
def initialize(input, bindings:, state:, rng:, self_object: nil)
|
|
53
|
+
@input = input.strip
|
|
54
|
+
@pos = 0
|
|
55
|
+
@bindings = bindings
|
|
56
|
+
@state = state
|
|
57
|
+
@rng = rng
|
|
58
|
+
@self_object = self_object
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def parse
|
|
62
|
+
result = parse_comparison
|
|
63
|
+
skip_ws
|
|
64
|
+
raise_error("unexpected trailing input") unless eof?
|
|
65
|
+
result
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def parse_comparison
|
|
71
|
+
left = parse_primary
|
|
72
|
+
skip_ws
|
|
73
|
+
if peek("==") || peek("!=")
|
|
74
|
+
op = advance(2)
|
|
75
|
+
skip_ws
|
|
76
|
+
right = parse_primary
|
|
77
|
+
op == "==" ? left == right : left != right
|
|
78
|
+
else
|
|
79
|
+
left
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parse_primary
|
|
84
|
+
skip_ws
|
|
85
|
+
if peek("rand(")
|
|
86
|
+
parse_rand_call
|
|
87
|
+
elsif peek(":")
|
|
88
|
+
advance(1)
|
|
89
|
+
parse_identifier
|
|
90
|
+
elsif digit?(peek_char)
|
|
91
|
+
parse_number
|
|
92
|
+
else
|
|
93
|
+
parse_reference
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def parse_rand_call
|
|
98
|
+
advance(5) # "rand("
|
|
99
|
+
skip_ws
|
|
100
|
+
low = parse_number
|
|
101
|
+
skip_ws
|
|
102
|
+
advance(2) # ".."
|
|
103
|
+
skip_ws
|
|
104
|
+
high = parse_number
|
|
105
|
+
skip_ws
|
|
106
|
+
advance(1) # ")"
|
|
107
|
+
@rng.rand(low..high)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def parse_reference
|
|
111
|
+
parts = [parse_identifier]
|
|
112
|
+
while peek(".")
|
|
113
|
+
advance(1)
|
|
114
|
+
parts << parse_identifier
|
|
115
|
+
end
|
|
116
|
+
resolve_reference(parts)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def resolve_reference(parts)
|
|
120
|
+
first = parts.first
|
|
121
|
+
value = resolve_root(first)
|
|
122
|
+
parts[1..].each do |part|
|
|
123
|
+
value = access(value, part)
|
|
124
|
+
end
|
|
125
|
+
value
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def resolve_root(name)
|
|
129
|
+
if name == :state
|
|
130
|
+
@state
|
|
131
|
+
elsif name == :rng
|
|
132
|
+
@rng
|
|
133
|
+
elsif @bindings.key?(name)
|
|
134
|
+
@bindings[name]
|
|
135
|
+
elsif @self_object && @self_object.instance_variable_defined?("@#{name}")
|
|
136
|
+
@self_object.instance_variable_get("@#{name}")
|
|
137
|
+
else
|
|
138
|
+
raise_error("unknown variable #{name}")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def access(value, key)
|
|
143
|
+
if value.is_a?(Hash)
|
|
144
|
+
value[key.to_sym] || value[key]
|
|
145
|
+
else
|
|
146
|
+
value.public_send(key)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def parse_number
|
|
151
|
+
skip_ws
|
|
152
|
+
start = @pos
|
|
153
|
+
advance while digit?(peek_char)
|
|
154
|
+
skip_ws
|
|
155
|
+
if peek_char == "." && digit?(peek_char(1))
|
|
156
|
+
advance
|
|
157
|
+
advance while digit?(peek_char)
|
|
158
|
+
end
|
|
159
|
+
text = @input[start...@pos]
|
|
160
|
+
text.include?(".") ? text.to_f : text.to_i
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def parse_identifier
|
|
164
|
+
skip_ws
|
|
165
|
+
start = @pos
|
|
166
|
+
raise_error("expected identifier") unless letter?(peek_char)
|
|
167
|
+
advance while identifier_char?(peek_char)
|
|
168
|
+
@input[start...@pos].to_sym
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def letter?(c)
|
|
172
|
+
c && c.match?(/[a-zA-Z_]/)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def identifier_char?(c)
|
|
176
|
+
c && c.match?(/[a-zA-Z0-9_]/)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def digit?(c)
|
|
180
|
+
c && c.match?(/[0-9]/)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def skip_ws
|
|
184
|
+
advance while peek_char == " "
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def peek(str)
|
|
188
|
+
@input[@pos, str.length] == str
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def advance(len = 1)
|
|
192
|
+
chunk = @input[@pos, len]
|
|
193
|
+
@pos += len
|
|
194
|
+
chunk
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def peek_char(offset = 0)
|
|
198
|
+
@input[@pos + offset]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def eof?
|
|
202
|
+
@pos >= @input.length
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def raise_error(message)
|
|
206
|
+
raise ArgumentError, "Invalid expression #{@input.inspect}: #{message}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dogfood
|
|
4
|
+
module DSL
|
|
5
|
+
# Renders the compiled AST into a human-readable stage/decision tree.
|
|
6
|
+
# Does NOT resolve randomness or evaluate expressions. See SPEC.md 5.
|
|
7
|
+
class ExplainRenderer
|
|
8
|
+
def initialize(ast)
|
|
9
|
+
@ast = ast
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render
|
|
13
|
+
lines = []
|
|
14
|
+
lines << "Scenario: #{@ast[:name]}"
|
|
15
|
+
lines << "Title: #{@ast[:title]}"
|
|
16
|
+
lines << "Stages: #{@ast[:stages].map { |s| s[:stage] }.join(' → ')}"
|
|
17
|
+
lines << ""
|
|
18
|
+
|
|
19
|
+
@ast[:stages].each do |stage|
|
|
20
|
+
lines << "┌─ #{stage[:stage]}"
|
|
21
|
+
lines.concat(render_stage(stage))
|
|
22
|
+
lines << ""
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
lines << "Resumes: #{render_resume}"
|
|
26
|
+
lines << "Delays: #{(@ast[:delays] || []).join(', ')}"
|
|
27
|
+
|
|
28
|
+
lines.join("\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def render_stage(stage)
|
|
34
|
+
lines = []
|
|
35
|
+
branch_steps, merge_steps = partition_branch_steps(stage)
|
|
36
|
+
has_merge = merge_steps.any?
|
|
37
|
+
|
|
38
|
+
branch_steps.each_with_index do |step, idx|
|
|
39
|
+
last_branch = idx == branch_steps.length - 1
|
|
40
|
+
connector = (last_branch && !has_merge) ? "└─" : "├─"
|
|
41
|
+
lines.concat(render_step(step, "│ ", connector, stage))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if has_merge
|
|
45
|
+
lines << "│ ↓ (all branches merge)" if branch_steps.any?
|
|
46
|
+
merge_steps.each_with_index do |step, idx|
|
|
47
|
+
connector = idx == merge_steps.length - 1 ? "└─" : "├─"
|
|
48
|
+
lines.concat(render_step(step, "│ ", connector, stage))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
lines
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Steps carrying `when_branch` form the branch fan-out; the remaining
|
|
56
|
+
# steps run for all branches and are drawn after the merge marker.
|
|
57
|
+
def partition_branch_steps(stage)
|
|
58
|
+
steps = stage[:steps]
|
|
59
|
+
branch = steps.select { |s| s[:type] == :call && s[:when_branch] }
|
|
60
|
+
merge = steps.reject { |s| s[:type] == :call && s[:when_branch] }
|
|
61
|
+
[branch, merge]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def render_step(step, prefix, connector, stage)
|
|
65
|
+
case step[:type]
|
|
66
|
+
when :call then render_call(step, prefix, connector, stage)
|
|
67
|
+
when :when then render_when(step, prefix, connector)
|
|
68
|
+
when :maybe then render_maybe(step, prefix, connector)
|
|
69
|
+
else []
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def render_call(step, prefix, connector, stage)
|
|
74
|
+
lines = []
|
|
75
|
+
line = "#{prefix}#{connector} #{step[:name]}"
|
|
76
|
+
if step[:when_branch]
|
|
77
|
+
weight = weight_for(stage, step[:when_branch])
|
|
78
|
+
line += " [branch: #{step[:when_branch]}#{weight ? ", #{weight}" : ''}]"
|
|
79
|
+
end
|
|
80
|
+
line += " → out: #{format_out(step[:out])}" if step[:out]
|
|
81
|
+
lines << line
|
|
82
|
+
|
|
83
|
+
with = step[:with] || {}
|
|
84
|
+
arg_prefix = prefix + "│ " + " "
|
|
85
|
+
if with.key?(:wrong_part)
|
|
86
|
+
lines[-1] += " (with wrong_part)"
|
|
87
|
+
end
|
|
88
|
+
with.each do |k, v|
|
|
89
|
+
lines << "#{arg_prefix}#{k}: #{format_value(v)}"
|
|
90
|
+
end
|
|
91
|
+
lines
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def render_when(step, prefix, connector)
|
|
95
|
+
lines = ["#{prefix}#{connector} when #{step[:expr]}"]
|
|
96
|
+
child_prefix = prefix + "│ "
|
|
97
|
+
|
|
98
|
+
lines << "#{child_prefix} then:"
|
|
99
|
+
lines.concat(render_block(step[:then], child_prefix + " "))
|
|
100
|
+
|
|
101
|
+
unless step[:else].empty?
|
|
102
|
+
lines << "#{child_prefix} else:"
|
|
103
|
+
lines.concat(render_block(step[:else], child_prefix + " "))
|
|
104
|
+
end
|
|
105
|
+
lines
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def render_maybe(step, prefix, connector)
|
|
109
|
+
lines = ["#{prefix}#{connector} maybe #{step[:prob]}"]
|
|
110
|
+
child_prefix = prefix + "│ "
|
|
111
|
+
block_lines = render_block(step[:then], child_prefix + " ")
|
|
112
|
+
block_lines.each { |bl| lines << "#{child_prefix} #{bl}" }
|
|
113
|
+
lines
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Renders a flat block (calls and maybes) at the given indent.
|
|
117
|
+
def render_block(steps, indent)
|
|
118
|
+
steps.each_with_index.flat_map do |item, idx|
|
|
119
|
+
connector = idx == steps.length - 1 ? "└─" : "├─"
|
|
120
|
+
rendered = case item[:type]
|
|
121
|
+
when :maybe then render_maybe(item, indent, connector)
|
|
122
|
+
else render_call(item, indent, connector, nil)
|
|
123
|
+
end
|
|
124
|
+
rendered
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def weight_for(stage, branch)
|
|
129
|
+
weights = stage[:branch_weights]
|
|
130
|
+
weights && weights[branch]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def render_resume
|
|
134
|
+
resume = @ast[:resume]
|
|
135
|
+
resume ? "after_await → #{resume[:after_await]}" : "-"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def format_out(out)
|
|
139
|
+
out.is_a?(Array) ? out.join(", ") : out.to_s
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def format_value(value)
|
|
143
|
+
case value
|
|
144
|
+
when Hash then format_hash(value)
|
|
145
|
+
when Array then value.map { |v| format_value(v) }.join(", ")
|
|
146
|
+
when String then format_string(value)
|
|
147
|
+
when nil then "null"
|
|
148
|
+
else value.to_s
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def format_hash(hash)
|
|
153
|
+
nested = hash.values.any? { |v| v.is_a?(Hash) || v.is_a?(Array) }
|
|
154
|
+
referenced = hash.values.any? { |v| v.is_a?(String) && v.match?(/\$\{/) }
|
|
155
|
+
|
|
156
|
+
if !nested && !referenced
|
|
157
|
+
"{ #{hash.values.map { |v| format_value(v) }.join(' ')} }"
|
|
158
|
+
else
|
|
159
|
+
"{ #{hash.map { |k, v| "#{k}: #{format_value(v)}" }.join(', ')} }"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def format_string(str)
|
|
164
|
+
if str.match?(/\A\$\{[^}]*\}\z/) || str.match?(/\A[a-zA-Z0-9_]+\z/)
|
|
165
|
+
str
|
|
166
|
+
else
|
|
167
|
+
str.inspect
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dogfood
|
|
4
|
+
module DSL
|
|
5
|
+
# Hand-rolled structural validation of scenario YAML. See SPEC.md 4.10
|
|
6
|
+
# and 4.7 (the depth-1 nesting limit is enforced here).
|
|
7
|
+
module Schema
|
|
8
|
+
ValidationError = Class.new(StandardError)
|
|
9
|
+
|
|
10
|
+
def self.validate!(yaml)
|
|
11
|
+
raise ValidationError, "must be a Hash" unless yaml.is_a?(Hash)
|
|
12
|
+
|
|
13
|
+
require_keys!(yaml, "name", "title", "stages", "stages_def")
|
|
14
|
+
validate_name(yaml["name"])
|
|
15
|
+
validate_title(yaml["title"])
|
|
16
|
+
stages = validate_stages(yaml["stages"])
|
|
17
|
+
validate_stages_def(yaml["stages_def"], stages)
|
|
18
|
+
validate_branches(yaml["branches"], stages) if yaml["branches"]
|
|
19
|
+
validate_delays(yaml["delays"]) if yaml["delays"]
|
|
20
|
+
validate_resume(yaml["resume"]) if yaml["resume"]
|
|
21
|
+
validate_fleet_ratio(yaml["fleet_ratio"]) if yaml["fleet_ratio"]
|
|
22
|
+
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def require_keys!(yaml, *keys)
|
|
30
|
+
missing = keys.reject { |k| yaml.key?(k) }
|
|
31
|
+
unless missing.empty?
|
|
32
|
+
raise ValidationError, "missing required key(s): #{missing.join(', ')}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validate_name(name)
|
|
37
|
+
unless name.is_a?(String) && name.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
38
|
+
raise ValidationError, "name must be a lowercase symbol-like string"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def validate_title(title)
|
|
43
|
+
raise ValidationError, "title must be a String" unless title.is_a?(String)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_stages(stages)
|
|
47
|
+
unless stages.is_a?(Array) && !stages.empty? && stages.all? { |s| s.is_a?(String) }
|
|
48
|
+
raise ValidationError, "stages must be a non-empty array of strings"
|
|
49
|
+
end
|
|
50
|
+
stages
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_stages_def(stages_def, stages)
|
|
54
|
+
unless stages_def.is_a?(Hash)
|
|
55
|
+
raise ValidationError, "stages_def must be a map of stage -> step list"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
stages.each do |stage|
|
|
59
|
+
steps = stages_def[stage]
|
|
60
|
+
raise ValidationError, "stages_def missing stage: #{stage}" if steps.nil?
|
|
61
|
+
validate_step_list(steps, stage)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
unknown = stages_def.keys - stages
|
|
65
|
+
unless unknown.empty?
|
|
66
|
+
raise ValidationError, "stages_def has stage(s) not in stages: #{unknown.join(', ')}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_step_list(steps, stage)
|
|
71
|
+
raise ValidationError, "stages_def[#{stage}] must be an array" unless steps.is_a?(Array)
|
|
72
|
+
|
|
73
|
+
steps.each_with_index do |step, idx|
|
|
74
|
+
validate_step(step, stage, idx)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_step(step, stage, idx)
|
|
79
|
+
unless step.is_a?(Hash)
|
|
80
|
+
raise ValidationError, "stage #{stage}, step #{idx}: must be a map"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
keys = step.keys
|
|
84
|
+
if keys.include?("call")
|
|
85
|
+
validate_call(step, stage, idx)
|
|
86
|
+
elsif keys.include?("when")
|
|
87
|
+
validate_when(step, stage, idx)
|
|
88
|
+
elsif keys.include?("maybe")
|
|
89
|
+
validate_maybe(step, stage, idx)
|
|
90
|
+
else
|
|
91
|
+
raise ValidationError, "stage #{stage}, step #{idx}: must have call, when, or maybe"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def validate_call(step, stage, idx)
|
|
96
|
+
raise ValidationError, "stage #{stage}, step #{idx}: call name required" unless step["call"].is_a?(String)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_when(step, stage, idx)
|
|
100
|
+
unless step["when"].is_a?(String)
|
|
101
|
+
raise ValidationError, "stage #{stage}, step #{idx}: when must be an expression string"
|
|
102
|
+
end
|
|
103
|
+
require_then_block!(step, stage, idx, "when")
|
|
104
|
+
validate_when_block!(step["then"], stage, idx, "then")
|
|
105
|
+
if step.key?("else")
|
|
106
|
+
validate_when_block!(step["else"], stage, idx, "else")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_maybe(step, stage, idx)
|
|
111
|
+
maybe = step["maybe"]
|
|
112
|
+
unless maybe.is_a?(Numeric) && maybe >= 0 && maybe <= 1
|
|
113
|
+
raise ValidationError, "stage #{stage}, step #{idx}: maybe must be a float in 0..1"
|
|
114
|
+
end
|
|
115
|
+
require_then_block!(step, stage, idx, "maybe")
|
|
116
|
+
validate_flat_block!(step["then"], stage, idx, "then")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def require_then_block!(step, stage, idx, kind)
|
|
120
|
+
unless step["then"].is_a?(Array) && !step["then"].empty?
|
|
121
|
+
raise ValidationError, "stage #{stage}, step #{idx}: #{kind} requires a non-empty then list"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A `when` branch may contain `call` steps and (one level down) a
|
|
126
|
+
# `maybe` probability step — but NEVER another `when`. This is the
|
|
127
|
+
# depth-1 limit, and it is what the reference example relies on.
|
|
128
|
+
def validate_when_block!(list, stage, idx, label)
|
|
129
|
+
list.each_with_index do |item, item_idx|
|
|
130
|
+
unless item.is_a?(Hash)
|
|
131
|
+
raise ValidationError, "stage #{stage}, step #{idx} (#{label} item #{item_idx}): must be a map"
|
|
132
|
+
end
|
|
133
|
+
if item.key?("when")
|
|
134
|
+
raise ValidationError,
|
|
135
|
+
"nesting too deep at stage #{stage}, step #{idx} (#{label} item #{item_idx}): only call/maybe steps allowed"
|
|
136
|
+
end
|
|
137
|
+
if item.key?("maybe")
|
|
138
|
+
validate_maybe(item, stage, "#{idx}.#{item_idx}")
|
|
139
|
+
elsif !item.key?("call")
|
|
140
|
+
raise ValidationError, "stage #{stage}, step #{idx} (#{label} item #{item_idx}): must be call or maybe"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def validate_flat_block!(list, stage, idx, label)
|
|
146
|
+
list.each_with_index do |item, item_idx|
|
|
147
|
+
unless item.is_a?(Hash) && item.key?("call")
|
|
148
|
+
raise ValidationError,
|
|
149
|
+
"nesting too deep at stage #{stage}, step #{idx} (#{label} item #{item_idx}): only call steps allowed"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def validate_branches(branches, stages)
|
|
155
|
+
unless branches.is_a?(Hash)
|
|
156
|
+
raise ValidationError, "branches must be a map of stage -> weight map"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
branches.each do |stage, weights|
|
|
160
|
+
unless stages.include?(stage)
|
|
161
|
+
raise ValidationError, "branches references unknown stage: #{stage}"
|
|
162
|
+
end
|
|
163
|
+
validate_weights!(weights, "branches[#{stage}]")
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def validate_delays(delays)
|
|
168
|
+
unless delays.is_a?(Hash)
|
|
169
|
+
raise ValidationError, "delays must be a map of event -> weight map"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
delays.each do |event, weights|
|
|
173
|
+
validate_weights!(weights, "delays[#{event}]")
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def validate_weights!(weights, label)
|
|
178
|
+
unless weights.is_a?(Hash)
|
|
179
|
+
raise ValidationError, "#{label} must be a weight map"
|
|
180
|
+
end
|
|
181
|
+
unless weights.values.all? { |v| v.is_a?(Numeric) }
|
|
182
|
+
raise ValidationError, "#{label} weights must be numeric"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def validate_resume(resume)
|
|
187
|
+
unless resume.is_a?(Hash)
|
|
188
|
+
raise ValidationError, "resume must be a map"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def validate_fleet_ratio(ratio)
|
|
193
|
+
unless ratio.is_a?(Numeric) && ratio >= 0 && ratio <= 1
|
|
194
|
+
raise ValidationError, "fleet_ratio must be a float in 0..1"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dogfood
|
|
4
|
+
class Ledger
|
|
5
|
+
def initialize
|
|
6
|
+
@entries = {}
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def register(story)
|
|
10
|
+
@entries[story[:id]] = story
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def close(id)
|
|
14
|
+
@entries.delete(id)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def open_stories
|
|
18
|
+
@entries.values
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resumable_for(day_index)
|
|
22
|
+
@entries.values.select { |s| s[:resume_on] == day_index }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def find(id)
|
|
26
|
+
@entries[id]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/dogfood/pack.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dogfood
|
|
4
|
+
class Pack
|
|
5
|
+
UnknownScenario = Class.new(StandardError)
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
attr_accessor :current
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
attr_reader :name, :step_modules, :mixin_modules, :pools, :delay_distributions, :scenarios
|
|
12
|
+
|
|
13
|
+
def initialize(name)
|
|
14
|
+
@name = name
|
|
15
|
+
@step_modules = []
|
|
16
|
+
@mixin_modules = []
|
|
17
|
+
@pools = {}
|
|
18
|
+
@delay_distributions = {}
|
|
19
|
+
@scenarios = {}
|
|
20
|
+
yield self if block_given?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def include_steps(*mods)
|
|
24
|
+
@step_modules.concat(mods)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def include_mixins(*mods)
|
|
28
|
+
@mixin_modules.concat(mods)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def pools(*mods)
|
|
32
|
+
mods.each { |m| @pools[m.name.split("::").last.to_sym] = m }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def delays(hash)
|
|
36
|
+
@delay_distributions.merge!(hash)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def register(name, klass)
|
|
40
|
+
@scenarios[name.to_sym] = klass
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def scenario_keys
|
|
44
|
+
@scenarios.keys
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def find_scenario(name)
|
|
48
|
+
@scenarios[name.to_sym] or raise UnknownScenario, "Unknown scenario: #{name}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def load_dir(glob)
|
|
52
|
+
Dir[glob].each { |path| load_file(path) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def load_file(path)
|
|
56
|
+
require "yaml"
|
|
57
|
+
yaml = YAML.load_file(path)
|
|
58
|
+
require_relative "dsl/schema"
|
|
59
|
+
require_relative "dsl/compiler"
|
|
60
|
+
Dogfood::DSL::Schema.validate!(yaml)
|
|
61
|
+
klass = Dogfood::DSL::Compiler.compile(yaml, pack: self)
|
|
62
|
+
register(yaml["name"].to_sym, klass)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
|
|
5
|
+
module Dogfood
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
engine_name "dogfood"
|
|
8
|
+
|
|
9
|
+
initializer "dogfood.load_pack" do |app|
|
|
10
|
+
pack_file = app.root.join("config", "dogfood.rb")
|
|
11
|
+
next unless pack_file.exist?
|
|
12
|
+
|
|
13
|
+
Dogfood::Pack.current = nil
|
|
14
|
+
load pack_file.to_s
|
|
15
|
+
unless Dogfood::Pack.current
|
|
16
|
+
raise "config/dogfood.rb did not set Dogfood::Pack.current"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|