dogfood 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f3da6c7dfbeb9f09325d741ae81a835d65da453a17f5ca6deeb3cfde8826f73
4
- data.tar.gz: 258a2ad0661d1aa5234e18305f75a39a0a696f8108fdef39988299db8e07d709
3
+ metadata.gz: 340e952c9eb89332501cff612428e4b2bf34a0a75af4f2d73efb0461cc8e8180
4
+ data.tar.gz: 0c7edf6a45de18e1827522185f418ec1058c29d03f9fcdf410b0bbf127d080b4
5
5
  SHA512:
6
- metadata.gz: a6f7f694fbad5f13ff0d058eb819b005bc0cbd3464a3a692d022273759ec5f55bdc38e2206d99d2221fd458db732f948929d47949feccb44ddf2a210191fa624
7
- data.tar.gz: 5a23dcbaf64043a23160180459ec1cf73fb39ac83adb630bd010affb36e42303c6f1edc7609c5193e7a30e31583ae8bd6fab845b4ed323385a9c31195de72d69
6
+ metadata.gz: e2dcfd39797bbb4c1951cf6aa5e046f104fa4da301c6e2ae96d72dd414a7374973fb3a01118eeb59418ee9381345e11e3d7853d9ec5e938e9084eea0a5076914
7
+ data.tar.gz: c7a8c24b61eac1aa38186411b6ff7a43ad2e2f7cdacf4223596fc158ce53ad6b5736dc1c46f0c7b3297990f90313c0ad8adf1a8d42ce69dc3d640577bce8681d
data/lib/dogfood/cli.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "pathname"
4
5
 
5
6
  module Dogfood
6
7
  class CLI
@@ -13,7 +14,8 @@ module Dogfood
13
14
  days: 1,
14
15
  stories_per_day: (3..6),
15
16
  seed: nil,
16
- scenario: nil
17
+ scenario: nil,
18
+ format: "text"
17
19
  }
18
20
  end
19
21
 
@@ -105,6 +107,9 @@ module Dogfood
105
107
  when /^--output(?:=(.+))?$/, "-o"
106
108
  @options[:output] = ($1 || @argv[i + 1])
107
109
  i += 1 unless $1
110
+ when "--format"
111
+ @options[:format] = @argv[i + 1]
112
+ i += 1
108
113
  when "--help", "-h"
109
114
  @options[:help] = true
110
115
  else
@@ -130,6 +135,7 @@ module Dogfood
130
135
  --explain [NAME|FILE] Print the stage/decision tree for a scenario name
131
136
  or YAML file, no RNG
132
137
  --dry-run --scenario NAME Same as --explain NAME
138
+ --format text|markdown|json Output format for --explain (default: text)
133
139
  --help, -h Show help
134
140
 
135
141
  Examples:
@@ -143,11 +149,27 @@ module Dogfood
143
149
  private
144
150
 
145
151
  def load_pack(path)
152
+ ensure_rails_root_shim
146
153
  Dogfood::Pack.current = nil
147
154
  load File.expand_path(path)
148
155
  raise "Pack file did not set Dogfood::Pack.current" unless Dogfood::Pack.current
149
156
  end
150
157
 
158
+ # A pack file may reference Rails.root to locate scenarios. When run as a
159
+ # bare `dogfood` executable from a Rails app root, Rails isn't booted. If we
160
+ # detect a Rails app (config/application.rb in the current directory) and
161
+ # Rails isn't already defined, provide a minimal shim exposing `Rails.root`
162
+ # pointing at the app root so the pack loads without a full app boot.
163
+ def ensure_rails_root_shim
164
+ return if defined?(Rails)
165
+ return unless File.exist?(File.expand_path("config/application.rb", Dir.pwd))
166
+
167
+ root = Pathname.new(Dir.pwd)
168
+ shim = Module.new
169
+ shim.define_singleton_method(:root) { root }
170
+ Object.const_set(:Rails, shim)
171
+ end
172
+
151
173
  def default_pack_path
152
174
  path = File.expand_path("config/dogfood.rb", Dir.pwd)
153
175
  File.exist?(path) ? path : nil
@@ -194,14 +216,16 @@ module Dogfood
194
216
  yaml = YAML.load_file(path)
195
217
  Dogfood::DSL::Schema.validate!(yaml)
196
218
  klass = Dogfood::DSL::Compiler.compile(yaml, pack: Dogfood::Pack.current)
197
- puts Dogfood::DSL::AuditRenderer.new(klass.compiled_ast, pack: Dogfood::Pack.current).render
219
+ puts Dogfood::DSL::AuditRenderer.new(klass.compiled_ast, pack: Dogfood::Pack.current)
220
+ .render(format: @options[:format].to_sym)
198
221
  end
199
222
 
200
223
  def explain_scenario(name)
201
224
  klass = Dogfood::Pack.current.find_scenario(name)
202
225
 
203
226
  if klass.respond_to?(:compiled_ast)
204
- puts Dogfood::DSL::AuditRenderer.new(klass.compiled_ast, pack: Dogfood::Pack.current).render
227
+ puts Dogfood::DSL::AuditRenderer.new(klass.compiled_ast, pack: Dogfood::Pack.current)
228
+ .render(format: @options[:format].to_sym)
205
229
  else
206
230
  puts render_ruby_story(name, klass)
207
231
  end
@@ -2,395 +2,23 @@
2
2
 
3
3
  module Dogfood
4
4
  module DSL
5
- # Produces a five-section audit report for `--explain`:
6
- # A. Stage/decision tree (delegates to ExplainRenderer)
7
- # B. Branch weight table
8
- # C. Path enumeration (full step sequences per path)
9
- # D. Edge-case gap detection
10
- # E. Step resolution check
5
+ # Produces the `--explain` audit report. This is a thin facade over
6
+ # AuditReport, which owns the computation (IR) and the text/markdown/json
7
+ # formatters. The default `render` output is byte-identical to the
8
+ # pre-refactor five-section report; the regression target is
9
+ # EXAMPLES/explain_output.txt.
11
10
  class AuditRenderer
12
- MAX_PATHS = 50
11
+ MAX_PATHS = AuditReport::MAX_PATHS
13
12
 
14
13
  def initialize(ast, pack: nil)
15
14
  @ast = ast
16
15
  @pack = pack
17
16
  end
18
17
 
19
- def render
20
- lines = []
21
- lines.concat(section_a)
22
- lines << ""
23
- lines.concat(section_b)
24
- lines << ""
25
- lines.concat(section_c)
26
- lines << ""
27
- lines.concat(section_d)
28
- lines << ""
29
- lines.concat(section_e)
30
- lines.join("\n") + "\n"
31
- end
32
-
33
- private
34
-
35
- # ── Section A: Stage/decision tree ──
36
-
37
- def section_a
38
- ExplainRenderer.new(@ast).render.split("\n")
39
- end
40
-
41
- # ── Section B: Branch weight table ──
42
-
43
- def section_b
44
- lines = ["## Branch weights"]
45
- any = false
46
-
47
- @ast[:stages].each do |stage|
48
- weights = stage[:branch_weights]
49
- next unless weights
50
-
51
- any = true
52
- lines << ""
53
- lines << "Stage: #{stage[:stage]}"
54
- max_label = weights.keys.map(&:length).max
55
- weights.each do |label, weight|
56
- bar = bar_for(weight, weights.values.max)
57
- lines << " %-#{max_label}s %.2f %s" % [label, weight, bar]
58
- end
59
- end
60
-
61
- # Surface when/maybe probabilities
62
- when_probs = collect_conditional_probs
63
- unless when_probs.empty?
64
- any = true
65
- lines << ""
66
- lines << "Conditionals:"
67
- when_probs.each do |desc|
68
- lines << " #{desc}"
69
- end
70
- end
71
-
72
- lines << "" unless any
73
- lines << " (no branches or conditionals)" unless any
74
- lines
75
- end
76
-
77
- def bar_for(weight, max_weight)
78
- scale = (weight.to_f / max_weight * 20).round
79
- "█" * scale
80
- end
81
-
82
- def collect_conditional_probs
83
- probs = []
84
- @ast[:stages].each do |stage|
85
- stage[:steps].each do |step|
86
- case step[:type]
87
- when :maybe
88
- probs << "maybe #{step[:prob]} in stage #{stage[:stage]}"
89
- when :when
90
- # Try to find a chance_decline or similar in the preceding call's with:
91
- expr = step[:expr]
92
- probs << "when #{expr} in stage #{stage[:stage]}"
93
- end
94
- end
95
- end
96
- probs
97
- end
98
-
99
- # ── Section C: Path enumeration ──
100
-
101
- def section_c
102
- lines = ["## Path enumeration"]
103
- paths = enumerate_paths
104
-
105
- if paths.length > MAX_PATHS
106
- lines << ""
107
- lines << " ⚠ #{paths.length} paths total (showing first #{MAX_PATHS})"
108
- paths = paths.first(MAX_PATHS)
109
- end
110
-
111
- paths.each_with_index do |path, idx|
112
- lines << ""
113
- label = path[:branches].empty? ? "(linear)" : path[:branches].join(" → ")
114
- lines << "Path #{idx + 1}: #{label}"
115
- path[:steps].each_with_index do |step, s_idx|
116
- lines << " #{s_idx + 1}. #{step}"
117
- end
118
- terminal = path[:terminal] || "unknown"
119
- lines << " Terminal: #{terminal}"
120
- end
121
-
122
- lines
123
- end
124
-
125
- def enumerate_paths
126
- paths = []
127
- walk_stages(@ast[:stages], 0, [], [], { status: :estimate, substatus: nil }, paths)
128
- paths
129
- end
130
-
131
- # Walks stages recursively. state tracks known @state for when-condition pruning.
132
- def walk_stages(stages, stage_idx, current_steps, branch_labels, state, paths)
133
- if stage_idx >= stages.length
134
- paths << { branches: branch_labels.dup, steps: current_steps.dup, terminal: "#{state[:status]} / #{state[:substatus] || '-'}" }
135
- return
136
- end
137
-
138
- stage = stages[stage_idx]
139
- weights = stage[:branch_weights]
140
-
141
- if weights
142
- weights.each_key do |branch_label|
143
- filtered = filter_branch_steps(stage[:steps], branch_label)
144
- new_branch_labels = branch_labels + [branch_label]
145
- expand_steps(filtered, stage, stages, stage_idx, current_steps, new_branch_labels, state, paths)
146
- end
147
- else
148
- expand_steps(stage[:steps], stage, stages, stage_idx, current_steps, branch_labels, state, paths)
149
- end
150
- end
151
-
152
- def expand_steps(steps, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
153
- fork_expansion(steps, 0, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
154
- end
155
-
156
- def fork_expansion(steps, step_idx, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
157
- if step_idx >= steps.length
158
- walk_stages(stages, stage_idx + 1, current_steps, branch_labels, state, paths)
159
- return
160
- end
161
-
162
- step = steps[step_idx]
163
- case step[:type]
164
- when :call
165
- new_steps = current_steps + [format_path_step(step)]
166
- new_state = step[:name] == :update_state ? merge_state(state, step[:with]) : state
167
- fork_expansion(steps, step_idx + 1, stage, stages, stage_idx, new_steps, branch_labels, new_state, paths)
168
- when :when
169
- result = eval_when_static(step[:expr], state)
170
- if result == true
171
- expand_block(step[:then], steps, step_idx + 1, stage, stages, stage_idx, current_steps, branch_labels + ["then"], state, paths)
172
- elsif result == false
173
- expand_block(step[:else] || [], steps, step_idx + 1, stage, stages, stage_idx, current_steps, branch_labels + ["else"], state, paths)
174
- else
175
- expand_block(step[:then], steps, step_idx + 1, stage, stages, stage_idx, current_steps, branch_labels + ["then"], state, paths)
176
- expand_block(step[:else] || [], steps, step_idx + 1, stage, stages, stage_idx, current_steps, branch_labels + ["else"], state, paths)
177
- end
178
- when :maybe
179
- expand_block(step[:then], steps, step_idx + 1, stage, stages, stage_idx, current_steps, branch_labels + ["maybe"], state, paths)
180
- fork_expansion(steps, step_idx + 1, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
181
- end
182
- end
183
-
184
- # Expands a block (then/else/maybe) which may contain :call and :maybe steps.
185
- # After the block, continues with the remaining steps in the parent list.
186
- def expand_block(block_steps, parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
187
- if block_steps.empty?
188
- fork_expansion(parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
189
- return
190
- end
191
-
192
- block_steps.each_with_index do |bstep, bidx|
193
- case bstep[:type]
194
- when :call
195
- current_steps = current_steps + [format_path_step(bstep)]
196
- new_state = bstep[:name] == :update_state ? merge_state(state, bstep[:with]) : state
197
- if bidx == block_steps.length - 1
198
- fork_expansion(parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels, new_state, paths)
199
- else
200
- state = new_state
201
- end
202
- when :maybe
203
- # Fork: with maybe and without
204
- if bidx == block_steps.length - 1
205
- # Last item in block — fork then continue parent
206
- expand_block(bstep[:then], parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels + ["maybe"], state, paths)
207
- fork_expansion(parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
208
- else
209
- # Not last — need to fork and continue remaining block steps
210
- # with maybe
211
- expand_block_remainder(bstep[:then], block_steps, bidx + 1, parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels + ["maybe"], state, paths)
212
- # without maybe
213
- expand_block_remainder([], block_steps, bidx + 1, parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
214
- end
215
- return
216
- end
217
- end
218
- end
219
-
220
- # Expands remainder of a block after a fork, then continues parent.
221
- def expand_block_remainder(extra_steps, block_steps, start_idx, parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
222
- remaining = extra_steps + block_steps[start_idx..]
223
- expand_block(remaining, parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
224
- end
225
-
226
- def merge_state(state, with_hash)
227
- state.merge(
228
- status: with_hash["status"]&.to_sym || state[:status],
229
- substatus: with_hash.key?("substatus") ? with_hash["substatus"] : state[:substatus]
230
- )
231
- end
232
-
233
- # Returns true/false if the when condition can be statically resolved, nil if unknown.
234
- def eval_when_static(expr, state)
235
- # Match ${state.status == :symbol} or ${state.status != :symbol}
236
- return nil unless expr.is_a?(String)
237
-
238
- if expr =~ /\$\{\s*state\.status\s*==\s*:(\w+)\s*\}/
239
- return state[:status].to_s == $1
240
- elsif expr =~ /\$\{\s*state\.status\s*!=\s*:(\w+)\s*\}/
241
- return state[:status].to_s != $1
242
- end
243
- nil
244
- end
245
-
246
- def filter_branch_steps(steps, branch_label)
247
- # Steps with when_branch matching the label, plus steps with no when_branch (merge steps)
248
- steps.select do |s|
249
- case s[:type]
250
- when :call
251
- s[:when_branch].nil? || s[:when_branch] == branch_label
252
- else
253
- true
254
- end
255
- end
256
- end
257
-
258
- def format_path_step(step)
259
- args = step[:with] || {}
260
- arg_str = args.empty? ? "" : " (#{args.map { |k, v| "#{k}: #{format_val(v)}" }.join(', ')})"
261
- out_str = step[:out] ? " → out: #{step[:out]}" : ""
262
- "#{step[:name]}#{arg_str}#{out_str}"
263
- end
264
-
265
- def format_val(v)
266
- case v
267
- when Hash then "{...}"
268
- when Array then "[...]"
269
- else v.to_s
270
- end
271
- end
272
-
273
- # ── Section D: Edge-case gap detection ──
274
-
275
- def section_d
276
- lines = ["## Coverage gaps"]
277
- gaps = detect_gaps
278
-
279
- if gaps.empty?
280
- lines << ""
281
- lines << " (no gaps detected)"
282
- else
283
- gaps.each { |g| lines << " ⚠ #{g}" }
284
- end
285
-
286
- lines
287
- end
288
-
289
- def detect_gaps
290
- gaps = []
291
-
292
- @ast[:stages].each do |stage|
293
- has_branches = !!stage[:branch_weights]
294
- has_when = stage[:steps].any? { |s| s[:type] == :when }
295
- has_maybe = stage[:steps].any? { |s| s[:type] == :maybe } ||
296
- stage[:steps].any? { |s| s[:type] == :when && s[:then].any? { |c| c[:type] == :maybe } }
297
-
298
- unless has_branches || has_when || has_maybe
299
- gaps << "stage \"#{stage[:stage]}\" has no branches or conditionals (linear)"
300
- end
301
-
302
- unless has_maybe
303
- gaps << "stage \"#{stage[:stage]}\" has no maybe/error path"
304
- end
305
-
306
- # when blocks with no else
307
- stage[:steps].each do |step|
308
- if step[:type] == :when && (step[:else].nil? || step[:else].empty?)
309
- gaps << "stage \"#{stage[:stage]}\" has a when block with no else"
310
- end
311
- end
312
- end
313
-
314
- # Check if all paths terminate in the same status
315
- paths = enumerate_paths
316
- terminals = paths.map { |p| p[:terminal] }.uniq
317
- if terminals.length == 1 && terminals.first != "unknown"
318
- gaps << "all paths terminate in \"#{terminals.first}\" — no alternative outcomes"
319
- end
320
-
321
- gaps
322
- end
323
-
324
- # ── Section E: Step resolution check ──
325
-
326
- def section_e
327
- lines = ["## Step resolution"]
328
- names = collect_all_call_names.uniq
329
-
330
- if names.empty?
331
- lines << ""
332
- lines << " (no calls)"
333
- return lines
334
- end
335
-
336
- builtins = Dogfood::DSL::Compiler::BUILTIN_NAMES
337
- max_name = names.map(&:length).max
338
-
339
- names.each do |name|
340
- if builtins.include?(name)
341
- lines << " %-#{max_name}s ✓ builtin" % name
342
- elsif @pack && resolves_in_pack?(name)
343
- mod = find_pack_module(name)
344
- lines << " %-#{max_name}s ✓ pack (%s)" % [name, mod]
345
- else
346
- lines << " %-#{max_name}s ✗ missing" % name
347
- end
348
- end
349
-
350
- if @pack && !@pack.pools.empty?
351
- lines << ""
352
- lines << "Pools:"
353
- @pack.pools.sort_by { |name, _| name.to_s }.each do |name, pool|
354
- lines << " #{name} (#{pool.all.length} entries)"
355
- end
356
- end
357
-
358
- lines
359
- end
360
-
361
- def resolves_in_pack?(name)
362
- return false unless @pack
363
- @pack.step_modules.any? { |m| m.instance_methods.include?(name) } ||
364
- @pack.mixin_modules.any? { |m| m.instance_methods.include?(name) }
365
- end
366
-
367
- def find_pack_module(name)
368
- return "unknown" unless @pack
369
- mod = (@pack.step_modules + @pack.mixin_modules).find { |m| m.instance_methods.include?(name) }
370
- mod&.name || "anonymous"
371
- end
372
-
373
- def collect_all_call_names
374
- names = []
375
- @ast[:stages].each do |stage|
376
- stage[:steps].each do |step|
377
- collect_step_names(step, names)
378
- end
379
- end
380
- names
381
- end
382
-
383
- def collect_step_names(step, names)
384
- case step[:type]
385
- when :call
386
- names << step[:name]
387
- when :when
388
- step[:then].each { |c| collect_step_names(c, names) }
389
- (step[:else] || []).each { |c| collect_step_names(c, names) }
390
- when :maybe
391
- step[:then].each { |c| collect_step_names(c, names) }
392
- end
18
+ def render(format: :text)
19
+ report = AuditReport.build(@ast, pack: @pack)
20
+ AuditReport.render(report, format: format)
393
21
  end
394
22
  end
395
23
  end
396
- end
24
+ end
@@ -0,0 +1,777 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Dogfood
6
+ module DSL
7
+ # Builds a pure-data audit report from a compiled AST and renders it in one
8
+ # of three formats:
9
+ # :text -> the classic box-drawing tree + tables (default; regression
10
+ # target is EXAMPLES/explain_output.txt)
11
+ # :markdown -> GitHub-flavored Markdown (headings, bullets, tables)
12
+ # :json -> machine-readable; Ruby symbols tagged as {"__sym__": "..."}
13
+ #
14
+ # The computation (path enumeration, gap detection, branch-weight and step
15
+ # resolution collection) runs once in AuditReport.build; formatters only
16
+ # read the resulting Hash and emit strings.
17
+ class AuditReport
18
+ MAX_PATHS = 50
19
+
20
+ def self.build(ast, pack: nil, max_paths: MAX_PATHS)
21
+ new(ast, pack: pack, max_paths: max_paths).build
22
+ end
23
+
24
+ def self.render(report, format: :text)
25
+ case format.to_sym
26
+ when :text then TextFormatter.render(report)
27
+ when :markdown then MarkdownFormatter.render(report)
28
+ when :json then JsonFormatter.render(report)
29
+ else
30
+ raise ArgumentError, "unknown format: #{format.inspect}"
31
+ end
32
+ end
33
+
34
+ def initialize(ast, pack: nil, max_paths: MAX_PATHS)
35
+ @ast = ast
36
+ @pack = pack
37
+ @max_paths = max_paths
38
+ end
39
+
40
+ def build
41
+ paths = enumerate_paths
42
+ {
43
+ scenario: {
44
+ name: @ast[:name],
45
+ title: @ast[:title],
46
+ stages: @ast[:stages].map { |s| s[:stage] },
47
+ resume: @ast[:resume],
48
+ delays: @ast[:delays]
49
+ },
50
+ tree: build_tree,
51
+ tree_text: ExplainRenderer.new(@ast).render,
52
+ branches: build_branches,
53
+ conditionals: collect_conditional_probs,
54
+ paths: paths,
55
+ paths_truncated: paths.length > @max_paths,
56
+ paths_total: paths.length,
57
+ max_paths: @max_paths,
58
+ gaps: detect_gaps,
59
+ resolution: build_resolution
60
+ }
61
+ end
62
+
63
+ private
64
+
65
+ # ── Section A: stage/decision tree (as data nodes) ──
66
+
67
+ def build_tree
68
+ @ast[:stages].map do |stage|
69
+ { stage: stage[:stage], nodes: stage_nodes(stage) }
70
+ end
71
+ end
72
+
73
+ def stage_nodes(stage)
74
+ branch_steps, merge_steps = partition_branch_steps(stage)
75
+ nodes = branch_steps.map { |s| build_node(s, stage) }
76
+ if branch_steps.any? && merge_steps.any?
77
+ nodes << { type: :merge }
78
+ end
79
+ nodes.concat(merge_steps.map { |s| build_node(s, stage) })
80
+ end
81
+
82
+ def partition_branch_steps(stage)
83
+ steps = stage[:steps]
84
+ branch = steps.select { |s| s[:type] == :call && s[:when_branch] }
85
+ merge = steps.reject { |s| s[:type] == :call && s[:when_branch] }
86
+ [branch, merge]
87
+ end
88
+
89
+ def build_node(step, stage)
90
+ case step[:type]
91
+ when :call
92
+ {
93
+ type: :call,
94
+ name: step[:name],
95
+ when_branch: step[:when_branch],
96
+ branch_weight: weight_for(stage, step[:when_branch]),
97
+ out: step[:out] ? format_out(step[:out]) : nil,
98
+ with_lines: build_with_lines(step[:with]),
99
+ with_wrong_part: !!(step[:with] && step[:with].key?(:wrong_part))
100
+ }
101
+ when :when
102
+ {
103
+ type: :when,
104
+ expr: step[:expr],
105
+ then_nodes: step[:then].map { |c| build_node(c, stage) },
106
+ else_nodes: (step[:else] || []).map { |c| build_node(c, stage) }
107
+ }
108
+ when :maybe
109
+ {
110
+ type: :maybe,
111
+ prob: step[:prob],
112
+ then_nodes: step[:then].map { |c| build_node(c, stage) }
113
+ }
114
+ else
115
+ { type: :unknown }
116
+ end
117
+ end
118
+
119
+ def build_with_lines(with)
120
+ (with || {}).map { |k, v| "#{k}: #{format_value(v)}" }
121
+ end
122
+
123
+ def weight_for(stage, branch)
124
+ weights = stage[:branch_weights]
125
+ weights && weights[branch]
126
+ end
127
+
128
+ # ── Section B: branch weights + conditional probabilities ──
129
+
130
+ def build_branches
131
+ @ast[:stages].each_with_object([]) do |stage, acc|
132
+ weights = stage[:branch_weights]
133
+ next unless weights
134
+
135
+ acc << {
136
+ stage: stage[:stage],
137
+ labels: weights.map { |label, weight| { label: label, weight: weight } }
138
+ }
139
+ end
140
+ end
141
+
142
+ def collect_conditional_probs
143
+ probs = []
144
+ @ast[:stages].each do |stage|
145
+ stage[:steps].each do |step|
146
+ case step[:type]
147
+ when :maybe
148
+ probs << "maybe #{step[:prob]} in stage #{stage[:stage]}"
149
+ when :when
150
+ probs << "when #{step[:expr]} in stage #{stage[:stage]}"
151
+ end
152
+ end
153
+ end
154
+ probs
155
+ end
156
+
157
+ # ── Section C: path enumeration ──
158
+
159
+ def enumerate_paths
160
+ paths = []
161
+ walk_stages(@ast[:stages], 0, [], [], { status: :estimate, substatus: nil }, paths)
162
+ paths
163
+ end
164
+
165
+ def walk_stages(stages, stage_idx, current_steps, branch_labels, state, paths)
166
+ if stage_idx >= stages.length
167
+ paths << { branches: branch_labels.dup, steps: current_steps.dup, terminal: "#{state[:status]} / #{state[:substatus] || '-'}" }
168
+ return
169
+ end
170
+
171
+ stage = stages[stage_idx]
172
+ weights = stage[:branch_weights]
173
+
174
+ if weights
175
+ weights.each_key do |branch_label|
176
+ filtered = filter_branch_steps(stage[:steps], branch_label)
177
+ new_branch_labels = branch_labels + [branch_label]
178
+ expand_steps(filtered, stage, stages, stage_idx, current_steps, new_branch_labels, state, paths)
179
+ end
180
+ else
181
+ expand_steps(stage[:steps], stage, stages, stage_idx, current_steps, branch_labels, state, paths)
182
+ end
183
+ end
184
+
185
+ def expand_steps(steps, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
186
+ fork_expansion(steps, 0, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
187
+ end
188
+
189
+ def fork_expansion(steps, step_idx, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
190
+ if step_idx >= steps.length
191
+ walk_stages(stages, stage_idx + 1, current_steps, branch_labels, state, paths)
192
+ return
193
+ end
194
+
195
+ step = steps[step_idx]
196
+ case step[:type]
197
+ when :call
198
+ new_steps = current_steps + [format_path_step(step)]
199
+ new_state = step[:name] == :update_state ? merge_state(state, step[:with]) : state
200
+ fork_expansion(steps, step_idx + 1, stage, stages, stage_idx, new_steps, branch_labels, new_state, paths)
201
+ when :when
202
+ result = eval_when_static(step[:expr], state)
203
+ if result == true
204
+ expand_block(step[:then], steps, step_idx + 1, stage, stages, stage_idx, current_steps, branch_labels + ["then"], state, paths)
205
+ elsif result == false
206
+ expand_block(step[:else] || [], steps, step_idx + 1, stage, stages, stage_idx, current_steps, branch_labels + ["else"], state, paths)
207
+ else
208
+ expand_block(step[:then], steps, step_idx + 1, stage, stages, stage_idx, current_steps, branch_labels + ["then"], state, paths)
209
+ expand_block(step[:else] || [], steps, step_idx + 1, stage, stages, stage_idx, current_steps, branch_labels + ["else"], state, paths)
210
+ end
211
+ when :maybe
212
+ expand_block(step[:then], steps, step_idx + 1, stage, stages, stage_idx, current_steps, branch_labels + ["maybe"], state, paths)
213
+ fork_expansion(steps, step_idx + 1, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
214
+ end
215
+ end
216
+
217
+ def expand_block(block_steps, parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
218
+ if block_steps.empty?
219
+ fork_expansion(parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
220
+ return
221
+ end
222
+
223
+ block_steps.each_with_index do |bstep, bidx|
224
+ case bstep[:type]
225
+ when :call
226
+ current_steps = current_steps + [format_path_step(bstep)]
227
+ new_state = bstep[:name] == :update_state ? merge_state(state, bstep[:with]) : state
228
+ if bidx == block_steps.length - 1
229
+ fork_expansion(parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels, new_state, paths)
230
+ else
231
+ state = new_state
232
+ end
233
+ when :maybe
234
+ if bidx == block_steps.length - 1
235
+ expand_block(bstep[:then], parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels + ["maybe"], state, paths)
236
+ fork_expansion(parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
237
+ else
238
+ expand_block_remainder(bstep[:then], block_steps, bidx + 1, parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels + ["maybe"], state, paths)
239
+ expand_block_remainder([], block_steps, bidx + 1, parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
240
+ end
241
+ return
242
+ end
243
+ end
244
+ end
245
+
246
+ def expand_block_remainder(extra_steps, block_steps, start_idx, parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
247
+ remaining = extra_steps + block_steps[start_idx..]
248
+ expand_block(remaining, parent_steps, parent_idx, stage, stages, stage_idx, current_steps, branch_labels, state, paths)
249
+ end
250
+
251
+ def merge_state(state, with_hash)
252
+ state.merge(
253
+ status: with_hash["status"]&.to_sym || state[:status],
254
+ substatus: with_hash.key?("substatus") ? with_hash["substatus"] : state[:substatus]
255
+ )
256
+ end
257
+
258
+ def eval_when_static(expr, state)
259
+ return nil unless expr.is_a?(String)
260
+
261
+ if expr =~ /\$\{\s*state\.status\s*==\s*:(\w+)\s*\}/
262
+ return state[:status].to_s == $1
263
+ elsif expr =~ /\$\{\s*state\.status\s*!=\s*:(\w+)\s*\}/
264
+ return state[:status].to_s != $1
265
+ end
266
+ nil
267
+ end
268
+
269
+ def filter_branch_steps(steps, branch_label)
270
+ steps.select do |s|
271
+ case s[:type]
272
+ when :call
273
+ s[:when_branch].nil? || s[:when_branch] == branch_label
274
+ else
275
+ true
276
+ end
277
+ end
278
+ end
279
+
280
+ def format_path_step(step)
281
+ args = step[:with] || {}
282
+ arg_str = args.empty? ? "" : " (#{args.map { |k, v| "#{k}: #{format_val(v)}" }.join(', ')})"
283
+ out_str = step[:out] ? " → out: #{step[:out]}" : ""
284
+ "#{step[:name]}#{arg_str}#{out_str}"
285
+ end
286
+
287
+ def format_val(v)
288
+ case v
289
+ when Hash then "{...}"
290
+ when Array then "[...]"
291
+ else v.to_s
292
+ end
293
+ end
294
+
295
+ # ── Section D: edge-case gap detection ──
296
+
297
+ def detect_gaps
298
+ gaps = []
299
+
300
+ @ast[:stages].each do |stage|
301
+ has_branches = !!stage[:branch_weights]
302
+ has_when = stage[:steps].any? { |s| s[:type] == :when }
303
+ has_maybe = stage[:steps].any? { |s| s[:type] == :maybe } ||
304
+ stage[:steps].any? { |s| s[:type] == :when && s[:then].any? { |c| c[:type] == :maybe } }
305
+
306
+ unless has_branches || has_when || has_maybe
307
+ gaps << "stage \"#{stage[:stage]}\" has no branches or conditionals (linear)"
308
+ end
309
+
310
+ unless has_maybe
311
+ gaps << "stage \"#{stage[:stage]}\" has no maybe/error path"
312
+ end
313
+
314
+ stage[:steps].each do |step|
315
+ if step[:type] == :when && (step[:else].nil? || step[:else].empty?)
316
+ gaps << "stage \"#{stage[:stage]}\" has a when block with no else"
317
+ end
318
+ end
319
+ end
320
+
321
+ terminals = enumerate_paths.map { |p| p[:terminal] }.uniq
322
+ if terminals.length == 1 && terminals.first != "unknown"
323
+ gaps << "all paths terminate in \"#{terminals.first}\" — no alternative outcomes"
324
+ end
325
+
326
+ gaps
327
+ end
328
+
329
+ # ── Section E: step resolution ──
330
+
331
+ def build_resolution
332
+ names = collect_all_call_names.uniq
333
+ calls = names.map do |name|
334
+ if Compiler::BUILTIN_NAMES.include?(name)
335
+ { name: name, status: :builtin, module: nil }
336
+ elsif @pack && resolves_in_pack?(name)
337
+ { name: name, status: :pack, module: find_pack_module(name) }
338
+ else
339
+ { name: name, status: :missing, module: nil }
340
+ end
341
+ end
342
+
343
+ pools = if @pack && !@pack.pools.empty?
344
+ @pack.pools.sort_by { |name, _| name.to_s }.map { |name, pool| { name: name, count: pool.all.length } }
345
+ else
346
+ []
347
+ end
348
+
349
+ { calls: calls, pools: pools }
350
+ end
351
+
352
+ def resolves_in_pack?(name)
353
+ return false unless @pack
354
+ @pack.step_modules.any? { |m| m.instance_methods.include?(name) } ||
355
+ @pack.mixin_modules.any? { |m| m.instance_methods.include?(name) }
356
+ end
357
+
358
+ def find_pack_module(name)
359
+ return "unknown" unless @pack
360
+ mod = (@pack.step_modules + @pack.mixin_modules).find { |m| m.instance_methods.include?(name) }
361
+ mod&.name || "anonymous"
362
+ end
363
+
364
+ def collect_all_call_names
365
+ names = []
366
+ @ast[:stages].each do |stage|
367
+ stage[:steps].each do |step|
368
+ collect_step_names(step, names)
369
+ end
370
+ end
371
+ names
372
+ end
373
+
374
+ def collect_step_names(step, names)
375
+ case step[:type]
376
+ when :call
377
+ names << step[:name]
378
+ when :when
379
+ step[:then].each { |c| collect_step_names(c, names) }
380
+ (step[:else] || []).each { |c| collect_step_names(c, names) }
381
+ when :maybe
382
+ step[:then].each { |c| collect_step_names(c, names) }
383
+ end
384
+ end
385
+
386
+ # ── Shared value formatting (ported from ExplainRenderer) ──
387
+
388
+ def format_out(out)
389
+ out.is_a?(Array) ? out.join(", ") : out.to_s
390
+ end
391
+
392
+ def format_value(value)
393
+ case value
394
+ when Hash then format_hash(value)
395
+ when Array then value.map { |v| format_value(v) }.join(", ")
396
+ when String then format_string(value)
397
+ when nil then "null"
398
+ else value.to_s
399
+ end
400
+ end
401
+
402
+ def format_hash(hash)
403
+ nested = hash.values.any? { |v| v.is_a?(Hash) || v.is_a?(Array) }
404
+ referenced = hash.values.any? { |v| v.is_a?(String) && v.match?(/\$\{/) }
405
+
406
+ if !nested && !referenced
407
+ "{ #{hash.values.map { |v| format_value(v) }.join(' ')} }"
408
+ else
409
+ "{ #{hash.map { |k, v| "#{k}: #{format_value(v)}" }.join(', ')} }"
410
+ end
411
+ end
412
+
413
+ def format_string(str)
414
+ if str.match?(/\A\$\{[^}]*\}\z/) || str.match?(/\A[a-zA-Z0-9_]+\z/)
415
+ str
416
+ else
417
+ str.inspect
418
+ end
419
+ end
420
+
421
+ # ── Text formatter (byte-identical to the pre-refactor AuditRenderer) ──
422
+
423
+ class TextFormatter
424
+ def self.render(report)
425
+ new(report).render
426
+ end
427
+
428
+ def initialize(report)
429
+ @report = report
430
+ end
431
+
432
+ def render
433
+ lines = []
434
+ lines.concat(@report[:tree_text].split("\n"))
435
+ lines << ""
436
+ lines.concat(section_b)
437
+ lines << ""
438
+ lines.concat(section_c)
439
+ lines << ""
440
+ lines.concat(section_d)
441
+ lines << ""
442
+ lines.concat(section_e)
443
+ lines.join("\n") + "\n"
444
+ end
445
+
446
+ private
447
+
448
+ def section_b
449
+ lines = ["## Branch weights"]
450
+ any = false
451
+
452
+ @report[:branches].each do |b|
453
+ any = true
454
+ lines << ""
455
+ lines << "Stage: #{b[:stage]}"
456
+ max_label = b[:labels].map { |l| l[:label].to_s.length }.max
457
+ max_weight = b[:labels].map { |l| l[:weight] }.max
458
+ b[:labels].each do |l|
459
+ lines << " %-#{max_label}s %.2f %s" % [l[:label], l[:weight], bar_for(l[:weight], max_weight)]
460
+ end
461
+ end
462
+
463
+ conditionals = @report[:conditionals]
464
+ unless conditionals.empty?
465
+ any = true
466
+ lines << ""
467
+ lines << "Conditionals:"
468
+ conditionals.each { |c| lines << " #{c}" }
469
+ end
470
+
471
+ lines << "" unless any
472
+ lines << " (no branches or conditionals)" unless any
473
+ lines
474
+ end
475
+
476
+ def bar_for(weight, max_weight)
477
+ scale = (weight.to_f / max_weight * 20).round
478
+ "█" * scale
479
+ end
480
+
481
+ def section_c
482
+ lines = ["## Path enumeration"]
483
+ paths = @report[:paths]
484
+
485
+ if @report[:paths_truncated]
486
+ lines << ""
487
+ lines << " ⚠ #{@report[:paths_total]} paths total (showing first #{@report[:max_paths]})"
488
+ paths = paths.first(@report[:max_paths])
489
+ end
490
+
491
+ paths.each_with_index do |path, idx|
492
+ lines << ""
493
+ label = path[:branches].empty? ? "(linear)" : path[:branches].join(" → ")
494
+ lines << "Path #{idx + 1}: #{label}"
495
+ path[:steps].each_with_index do |step, s_idx|
496
+ lines << " #{s_idx + 1}. #{step}"
497
+ end
498
+ lines << " Terminal: #{path[:terminal]}"
499
+ end
500
+
501
+ lines
502
+ end
503
+
504
+ def section_d
505
+ lines = ["## Coverage gaps"]
506
+ gaps = @report[:gaps]
507
+
508
+ if gaps.empty?
509
+ lines << ""
510
+ lines << " (no gaps detected)"
511
+ else
512
+ gaps.each { |g| lines << " ⚠ #{g}" }
513
+ end
514
+
515
+ lines
516
+ end
517
+
518
+ def section_e
519
+ lines = ["## Step resolution"]
520
+ calls = @report[:resolution][:calls]
521
+
522
+ if calls.empty?
523
+ lines << ""
524
+ lines << " (no calls)"
525
+ return lines
526
+ end
527
+
528
+ max_name = calls.map { |c| c[:name].to_s.length }.max
529
+ calls.each do |c|
530
+ case c[:status]
531
+ when :builtin
532
+ lines << " %-#{max_name}s ✓ builtin" % c[:name]
533
+ when :pack
534
+ lines << " %-#{max_name}s ✓ pack (%s)" % [c[:name], c[:module]]
535
+ else
536
+ lines << " %-#{max_name}s ✗ missing" % c[:name]
537
+ end
538
+ end
539
+
540
+ pools = @report[:resolution][:pools]
541
+ if pools.any?
542
+ lines << ""
543
+ lines << "Pools:"
544
+ pools.each { |p| lines << " #{p[:name]} (#{p[:count]} entries)" }
545
+ end
546
+
547
+ lines
548
+ end
549
+ end
550
+
551
+ # ── Markdown formatter ──
552
+
553
+ class MarkdownFormatter
554
+ def self.render(report)
555
+ new(report).render
556
+ end
557
+
558
+ def initialize(report)
559
+ @report = report
560
+ end
561
+
562
+ def render
563
+ lines = []
564
+ s = @report[:scenario]
565
+
566
+ lines << "# Scenario: #{s[:name]}"
567
+ lines << ""
568
+ lines << "**Title:** #{s[:title]}"
569
+ lines << "**Stages:** #{s[:stages].join(' → ')}"
570
+ lines << "**Resumes:** #{render_resume(s[:resume])}"
571
+ lines << "**Delays:** #{s[:delays].join(', ')}"
572
+ lines << ""
573
+
574
+ @report[:tree].each do |stage|
575
+ lines << "## Stage: #{stage[:stage]}"
576
+ lines << ""
577
+ stage[:nodes].each do |node|
578
+ lines.concat(md_node(node, 0))
579
+ end
580
+ lines << ""
581
+ end
582
+
583
+ lines.concat(section_b)
584
+ lines << ""
585
+ lines.concat(section_c)
586
+ lines << ""
587
+ lines.concat(section_d)
588
+ lines << ""
589
+ lines.concat(section_e)
590
+
591
+ lines.join("\n") + "\n"
592
+ end
593
+
594
+ private
595
+
596
+ def render_resume(resume)
597
+ resume ? "after_await → #{resume[:after_await]}" : "-"
598
+ end
599
+
600
+ def md_node(node, indent)
601
+ pad = " " * indent
602
+ case node[:type]
603
+ when :call
604
+ md_call(node, pad)
605
+ when :when
606
+ md_when(node, indent)
607
+ when :maybe
608
+ md_maybe(node, indent)
609
+ when :merge
610
+ ["#{pad}- *↓ all branches merge*"]
611
+ else
612
+ []
613
+ end
614
+ end
615
+
616
+ def md_call(node, pad)
617
+ parts = ["`#{node[:name]}`"]
618
+ if node[:when_branch]
619
+ parts << "· **branch:** #{node[:when_branch]}(#{node[:branch_weight]})"
620
+ end
621
+ parts << "· → out: `#{node[:out]}`" if node[:out]
622
+ parts << "(with wrong_part)" if node[:with_wrong_part]
623
+
624
+ lines = ["#{pad}- #{parts.join(' ')}"]
625
+ if node[:with_lines].any?
626
+ child_pad = pad + " "
627
+ node[:with_lines].each { |wl| lines << "#{child_pad}- `#{wl}`" }
628
+ end
629
+ lines
630
+ end
631
+
632
+ def md_when(node, indent)
633
+ pad = " " * indent
634
+ child = indent + 1
635
+ lines = ["#{pad}- **when** `#{node[:expr]}`"]
636
+ if node[:then_nodes].any?
637
+ lines << "#{pad} - **then:**"
638
+ node[:then_nodes].each { |c| lines.concat(md_node(c, child + 1)) }
639
+ end
640
+ if node[:else_nodes].any?
641
+ lines << "#{pad} - **else:**"
642
+ node[:else_nodes].each { |c| lines.concat(md_node(c, child + 1)) }
643
+ end
644
+ lines
645
+ end
646
+
647
+ def md_maybe(node, indent)
648
+ pad = " " * indent
649
+ lines = ["#{pad}- **maybe** #{node[:prob]}"]
650
+ node[:then_nodes].each { |c| lines.concat(md_node(c, indent + 1)) }
651
+ lines
652
+ end
653
+
654
+ def section_b
655
+ lines = ["## Branch weights"]
656
+ branches = @report[:branches]
657
+ conditionals = @report[:conditionals]
658
+
659
+ if branches.empty? && conditionals.empty?
660
+ lines << ""
661
+ lines << "*No branches or conditionals.*"
662
+ return lines
663
+ end
664
+
665
+ if branches.any?
666
+ lines << ""
667
+ lines << "| Stage | Label | Weight | Bar |"
668
+ lines << "|-------|-------|--------|-----|"
669
+ branches.each do |b|
670
+ max_weight = b[:labels].map { |l| l[:weight] }.max
671
+ b[:labels].each do |l|
672
+ scale = (l[:weight].to_f / max_weight * 20).round
673
+ lines << "| #{b[:stage]} | #{l[:label]} | #{l[:weight]} | #{"█" * scale} |"
674
+ end
675
+ end
676
+ end
677
+
678
+ unless conditionals.empty?
679
+ lines << ""
680
+ lines << "**Conditionals:**"
681
+ conditionals.each { |c| lines << "- `#{c}`" }
682
+ end
683
+
684
+ lines
685
+ end
686
+
687
+ def section_c
688
+ lines = ["## Path enumeration"]
689
+ paths = @report[:paths]
690
+
691
+ if @report[:paths_truncated]
692
+ lines << ""
693
+ lines << "> ⚠ #{@report[:paths_total]} paths total (showing first #{@report[:max_paths]})"
694
+ paths = paths.first(@report[:max_paths])
695
+ end
696
+
697
+ paths.each_with_index do |path, idx|
698
+ lines << ""
699
+ label = path[:branches].empty? ? "(linear)" : path[:branches].join(" → ")
700
+ lines << "### Path #{idx + 1}: #{label}"
701
+ path[:steps].each_with_index do |step, s_idx|
702
+ lines << "#{s_idx + 1}. `#{step}`"
703
+ end
704
+ lines << ""
705
+ lines << "**Terminal:** #{path[:terminal]}"
706
+ end
707
+
708
+ lines
709
+ end
710
+
711
+ def section_d
712
+ lines = ["## Coverage gaps"]
713
+ gaps = @report[:gaps]
714
+
715
+ if gaps.empty?
716
+ lines << ""
717
+ lines << "**No gaps detected.**"
718
+ else
719
+ gaps.each { |g| lines << "- ⚠ #{g}" }
720
+ end
721
+
722
+ lines
723
+ end
724
+
725
+ def section_e
726
+ lines = ["## Step resolution"]
727
+ calls = @report[:resolution][:calls]
728
+
729
+ if calls.empty?
730
+ lines << ""
731
+ lines << "*No calls.*"
732
+ return lines
733
+ end
734
+
735
+ lines << ""
736
+ lines << "| Call | Status | Module |"
737
+ lines << "|------|--------|--------|"
738
+ calls.each do |c|
739
+ status = case c[:status]
740
+ when :builtin then "✓ builtin"
741
+ when :pack then "✓ pack"
742
+ else "✗ missing"
743
+ end
744
+ mod = c[:module] || ""
745
+ lines << "| `#{c[:name]}` | #{status} | #{mod} |"
746
+ end
747
+
748
+ pools = @report[:resolution][:pools]
749
+ if pools.any?
750
+ lines << ""
751
+ lines << "**Pools:**"
752
+ pools.each { |p| lines << "- `#{p[:name]}` (#{p[:count]} entries)" }
753
+ end
754
+
755
+ lines
756
+ end
757
+ end
758
+
759
+ # ── JSON formatter ──
760
+
761
+ class JsonFormatter
762
+ def self.render(report)
763
+ JSON.pretty_generate(serialize(report)) + "\n"
764
+ end
765
+
766
+ def self.serialize(obj)
767
+ case obj
768
+ when Symbol then { "__sym__" => obj.to_s }
769
+ when Hash then obj.transform_keys(&:to_s).transform_values { |v| serialize(v) }
770
+ when Array then obj.map { |v| serialize(v) }
771
+ else obj
772
+ end
773
+ end
774
+ end
775
+ end
776
+ end
777
+ end
@@ -74,7 +74,11 @@ module Dogfood
74
74
  list = eval_expr(step_spec[:expr]) ? step_spec[:then] : step_spec[:else]
75
75
  (list || []).each { |c| run_step(c, nil) }
76
76
  when :maybe
77
- step_spec[:then].each { |c| run_step(c, nil) } if @rng.rand < step_spec[:prob]
77
+ if @rng.rand < step_spec[:prob]
78
+ step_spec[:then].each { |c| run_step(c, nil) }
79
+ else
80
+ step_spec[:else].each { |c| run_step(c, nil) }
81
+ end
78
82
  end
79
83
  end
80
84
 
@@ -166,7 +170,8 @@ module Dogfood
166
170
  {
167
171
  type: :maybe,
168
172
  prob: step["maybe"],
169
- then: step["then"].map { |c| parse_call(c, stage) }
173
+ then: step["then"].map { |c| parse_call(c, stage) },
174
+ else: step["else"] ? step["else"].map { |c| parse_call(c, stage) } : []
170
175
  }
171
176
  else
172
177
  raise Dogfood::NestingTooDeep, "invalid step in stage #{stage}"
@@ -197,7 +202,8 @@ module Dogfood
197
202
  {
198
203
  type: :maybe,
199
204
  prob: step["maybe"],
200
- then: step["then"].map { |c| parse_call(c, stage) }
205
+ then: step["then"].map { |c| parse_call(c, stage) },
206
+ else: step["else"] ? step["else"].map { |c| parse_call(c, stage) } : []
201
207
  }
202
208
  else
203
209
  parse_call(step, stage)
@@ -234,7 +240,7 @@ module Dogfood
234
240
  when :call then [step[:name]]
235
241
  when :when
236
242
  collect_block_names(step[:then]) + collect_block_names(step[:else])
237
- when :maybe then step[:then].map { |c| c[:name] }
243
+ when :maybe then collect_block_names(step[:then]) + collect_block_names(step[:else])
238
244
  else []
239
245
  end
240
246
  end
@@ -243,7 +249,7 @@ module Dogfood
243
249
  def self.collect_block_names(steps)
244
250
  steps.flat_map do |step|
245
251
  if step[:type] == :maybe
246
- step[:then].map { |c| c[:name] }
252
+ collect_block_names(step[:then]) + collect_block_names(step[:else])
247
253
  else
248
254
  [step[:name]]
249
255
  end
@@ -113,6 +113,9 @@ module Dogfood
113
113
  end
114
114
  require_then_block!(step, stage, idx, "maybe")
115
115
  validate_flat_block!(step["then"], stage, idx, "then")
116
+ if step.key?("else")
117
+ validate_flat_block!(step["else"], stage, idx, "else")
118
+ end
116
119
  end
117
120
 
118
121
  def require_then_block!(step, stage, idx, kind)
@@ -29,6 +29,12 @@ module Dogfood
29
29
  raise NotImplementedError
30
30
  end
31
31
 
32
+ def money(cents)
33
+ return "$0" if cents.nil? || cents == 0
34
+ dollars = (cents / 100.0).round
35
+ "$#{dollars.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}"
36
+ end
37
+
32
38
  protected
33
39
 
34
40
  def step(actor:, event:, action:, notes: nil)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dogfood
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
data/lib/dogfood.rb CHANGED
@@ -18,6 +18,7 @@ require_relative "dogfood/dsl/schema"
18
18
  require_relative "dogfood/dsl/pool_schema"
19
19
  require_relative "dogfood/dsl/compiler"
20
20
  require_relative "dogfood/dsl/explain_renderer"
21
+ require_relative "dogfood/dsl/audit_report"
21
22
  require_relative "dogfood/dsl/audit_renderer"
22
23
  require_relative "dogfood/test/smoke"
23
24
  require_relative "dogfood/cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dogfood
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben D'Angelo
@@ -80,6 +80,7 @@ files:
80
80
  - lib/dogfood/cli.rb
81
81
  - lib/dogfood/day_generator.rb
82
82
  - lib/dogfood/dsl/audit_renderer.rb
83
+ - lib/dogfood/dsl/audit_report.rb
83
84
  - lib/dogfood/dsl/compiler.rb
84
85
  - lib/dogfood/dsl/evaluator.rb
85
86
  - lib/dogfood/dsl/explain_renderer.rb