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.
@@ -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
@@ -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