dogfood 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93baf06e414b181624c84decd7ba9fb434c983cbdb72c46c98fed78e597ff1c3
4
- data.tar.gz: 2d8cc07a607a74be6fa67f1e04279e3e600c250230b6ec3accfea0f6afd1a3a8
3
+ metadata.gz: 5f3da6c7dfbeb9f09325d741ae81a835d65da453a17f5ca6deeb3cfde8826f73
4
+ data.tar.gz: 258a2ad0661d1aa5234e18305f75a39a0a696f8108fdef39988299db8e07d709
5
5
  SHA512:
6
- metadata.gz: 480f7e8463937d4f2ea1ba4f393dd2b816956898cc9f70db90d9c3477d624519c0ad3728a0d98f34f7769aec2efe60c6c0f427046b2a3f9dd603f89cb5acb0bd
7
- data.tar.gz: ff98caf2d27c13948dc4b87097502f85e5cbe3ed979545a919cb33cd467ad9655cefef852c5d97e113b7a576680ae3771f581ea5ab1be1db2ee1ad6063b3ae19
6
+ metadata.gz: a6f7f694fbad5f13ff0d058eb819b005bc0cbd3464a3a692d022273759ec5f55bdc38e2206d99d2221fd458db732f948929d47949feccb44ddf2a210191fa624
7
+ data.tar.gz: 5a23dcbaf64043a23160180459ec1cf73fb39ac83adb630bd010affb36e42303c6f1edc7609c5193e7a30e31583ae8bd6fab845b4ed323385a9c31195de72d69
data/bin/dogfood CHANGED
@@ -1,5 +1,13 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
5
  require "dogfood"
5
- Dogfood::CLI.new(ARGV).run
6
+ cli = Dogfood::CLI.new(ARGV)
7
+ begin
8
+ cli.run
9
+ rescue Dogfood::CLI::UsageError => e
10
+ warn "Error: #{e.message}"
11
+ cli.print_help
12
+ exit 1
13
+ end
data/lib/dogfood/cli.rb CHANGED
@@ -4,13 +4,14 @@ require "fileutils"
4
4
 
5
5
  module Dogfood
6
6
  class CLI
7
+ UsageError = Class.new(StandardError)
8
+
7
9
  def initialize(argv)
8
10
  @argv = argv
9
11
  @options = {
10
12
  output: default_output_path,
11
13
  days: 1,
12
- wo_per_day: (3..6),
13
- fleet_ratio: 0.25,
14
+ stories_per_day: (3..6),
14
15
  seed: nil,
15
16
  scenario: nil
16
17
  }
@@ -26,10 +27,9 @@ module Dogfood
26
27
  return
27
28
  end
28
29
 
29
- unless @options[:pack]
30
- warn "Error: --pack PATH is required"
31
- print_help
32
- exit 1
30
+ @options[:pack] ||= default_pack_path
31
+ unless @options[:pack] && File.exist?(@options[:pack])
32
+ raise UsageError, "no --pack given and no config/dogfood.rb found in the current directory"
33
33
  end
34
34
 
35
35
  load_pack(@options[:pack])
@@ -40,7 +40,8 @@ module Dogfood
40
40
  end
41
41
 
42
42
  if @options[:explain]
43
- explain_file(@options[:explain])
43
+ target = @options[:explain] == true ? @options[:scenario] : @options[:explain]
44
+ explain(target)
44
45
  return
45
46
  end
46
47
 
@@ -52,8 +53,7 @@ module Dogfood
52
53
  generator = Dogfood::DayGenerator.new(
53
54
  pack: Dogfood::Pack.current,
54
55
  days: @options[:days],
55
- wo_per_day: @options[:wo_per_day],
56
- fleet_ratio: @options[:fleet_ratio],
56
+ stories_per_day: @options[:stories_per_day],
57
57
  seed: @options[:seed],
58
58
  scenario: @options[:scenario]
59
59
  )
@@ -82,8 +82,13 @@ module Dogfood
82
82
  when "--list", "-l"
83
83
  @options[:list] = true
84
84
  when "--explain"
85
- @options[:explain] = @argv[i + 1]
86
- i += 1
85
+ val = @argv[i + 1]
86
+ if val && !val.start_with?("-")
87
+ @options[:explain] = val
88
+ i += 1
89
+ else
90
+ @options[:explain] = true
91
+ end
87
92
  when "--dry-run"
88
93
  @options[:dry_run] = true
89
94
  when "--random", "-r"
@@ -91,11 +96,8 @@ module Dogfood
91
96
  when /^--days(?:=(.+))?$/
92
97
  @options[:days] = ($1 || @argv[i + 1]).to_i
93
98
  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
+ when /^--stories-per-day(?:=(.+))?$/
100
+ @options[:stories_per_day] = parse_stories_per_day($1 || @argv[i + 1])
99
101
  i += 1 unless $1
100
102
  when /^--seed(?:=(.+))?$/
101
103
  @options[:seed] = ($1 || @argv[i + 1]).to_i
@@ -105,11 +107,39 @@ module Dogfood
105
107
  i += 1 unless $1
106
108
  when "--help", "-h"
107
109
  @options[:help] = true
110
+ else
111
+ @options[:scenario] ||= arg
108
112
  end
109
113
  i += 1
110
114
  end
111
115
  end
112
116
 
117
+ def print_help
118
+ puts <<~HELP
119
+ Usage: dogfood [options] [scenario_name]
120
+
121
+ --pack PATH Ruby file that registers a Dogfood::Pack
122
+ (default: config/dogfood.rb in the current directory)
123
+ --scenario NAME Run a specific named scenario (otherwise: random mix)
124
+ --days N Number of simulated days (default: 1)
125
+ --stories-per-day N or M-N Stories per day (default: 3-6)
126
+ --seed N Set random seed for reproducibility
127
+ --random Use a random seed (prints it)
128
+ --output PATH, -o PATH Output file path (default: tmp/dogfood/<timestamp>.md)
129
+ --list, -l List available scenarios
130
+ --explain [NAME|FILE] Print the stage/decision tree for a scenario name
131
+ or YAML file, no RNG
132
+ --dry-run --scenario NAME Same as --explain NAME
133
+ --help, -h Show help
134
+
135
+ Examples:
136
+ dogfood --list
137
+ dogfood --explain supplemental_authorization
138
+ dogfood supplemental_authorization --explain
139
+ dogfood --scenario brake_repair_with_parts_order --days 3
140
+ HELP
141
+ end
142
+
113
143
  private
114
144
 
115
145
  def load_pack(path)
@@ -118,7 +148,12 @@ module Dogfood
118
148
  raise "Pack file did not set Dogfood::Pack.current" unless Dogfood::Pack.current
119
149
  end
120
150
 
121
- def parse_wo_per_day(value)
151
+ def default_pack_path
152
+ path = File.expand_path("config/dogfood.rb", Dir.pwd)
153
+ File.exist?(path) ? path : nil
154
+ end
155
+
156
+ def parse_stories_per_day(value)
122
157
  if value.include?("-")
123
158
  min, max = value.split("-").map(&:to_i)
124
159
  (min..max)
@@ -129,8 +164,28 @@ module Dogfood
129
164
 
130
165
  def list_scenarios
131
166
  puts "Available scenarios:"
132
- Dogfood::Pack.current.scenario_keys.each do |key|
133
- puts " - #{key}"
167
+ Dogfood::Pack.current.scenarios.each do |key, klass|
168
+ type = klass.respond_to?(:compiled_ast) ? "YAML" : "Ruby"
169
+ title = safe_title(klass)
170
+ puts " %-40s [%s] %s" % [key, type, title]
171
+ end
172
+ end
173
+
174
+ def safe_title(klass)
175
+ klass.new(rng: Dogfood::Randomness.new(seed: 0), id: "S-000").title
176
+ rescue StandardError
177
+ ""
178
+ end
179
+
180
+ def explain(target)
181
+ if target.nil?
182
+ raise UsageError, "--explain needs a scenario name or a YAML file path"
183
+ end
184
+
185
+ if File.exist?(target) || target.end_with?(".yml", ".yaml")
186
+ explain_file(target)
187
+ else
188
+ explain_scenario(target)
134
189
  end
135
190
  end
136
191
 
@@ -139,14 +194,14 @@ module Dogfood
139
194
  yaml = YAML.load_file(path)
140
195
  Dogfood::DSL::Schema.validate!(yaml)
141
196
  klass = Dogfood::DSL::Compiler.compile(yaml, pack: Dogfood::Pack.current)
142
- puts Dogfood::DSL::ExplainRenderer.new(klass.compiled_ast).render
197
+ puts Dogfood::DSL::AuditRenderer.new(klass.compiled_ast, pack: Dogfood::Pack.current).render
143
198
  end
144
199
 
145
200
  def explain_scenario(name)
146
201
  klass = Dogfood::Pack.current.find_scenario(name)
147
202
 
148
203
  if klass.respond_to?(:compiled_ast)
149
- puts Dogfood::DSL::ExplainRenderer.new(klass.compiled_ast).render
204
+ puts Dogfood::DSL::AuditRenderer.new(klass.compiled_ast, pack: Dogfood::Pack.current).render
150
205
  else
151
206
  puts render_ruby_story(name, klass)
152
207
  end
@@ -157,7 +212,7 @@ module Dogfood
157
212
  def render_ruby_story(name, klass)
158
213
  lines = []
159
214
  lines << "Scenario: #{name}"
160
- lines << "Title: #{klass.new(rng: Dogfood::Randomness.new(seed: 0), id: 'WO-000').title}"
215
+ lines << "Title: #{klass.new(rng: Dogfood::Randomness.new(seed: 0), id: 'S-000').title}"
161
216
  lines << "Type: Ruby story class"
162
217
  lines << ""
163
218
  lines << "This scenario is implemented as a Ruby class (#{klass.name}) rather than a YAML file."
@@ -169,24 +224,5 @@ module Dogfood
169
224
  timestamp = Time.now.strftime("%Y-%m-%d-%H%M%S")
170
225
  "tmp/dogfood/#{timestamp}-dogfood.md"
171
226
  end
172
-
173
- def print_help
174
- puts <<~HELP
175
- Usage: dogfood [options]
176
-
177
- --pack PATH Ruby file that registers a Dogfood::Pack (required)
178
- --scenario NAME Run a specific named scenario (otherwise: random mix)
179
- --days N Number of simulated days (default: 1)
180
- --wo-per-day N or M-N Work orders per day (default: 3-6)
181
- --fleet-ratio RATIO Fraction of fleet stories (default: 0.25)
182
- --seed N Set random seed for reproducibility
183
- --random Use a random seed (prints it)
184
- --output PATH, -o PATH Output file path (default: tmp/dogfood/<timestamp>.md)
185
- --list, -l List available scenarios
186
- --explain FILE Compile FILE and print the stage/decision tree, no RNG
187
- --dry-run --scenario N Same as --explain but takes a registered name
188
- --help, -h Show help
189
- HELP
190
- end
191
227
  end
192
228
  end
@@ -4,14 +4,13 @@ require "date"
4
4
 
5
5
  module Dogfood
6
6
  class DayGenerator
7
- DEFAULTS = { days: 1, wo_per_day: (3..6), fleet_ratio: 0.25, seed: nil }.freeze
7
+ DEFAULTS = { days: 1, stories_per_day: (3..6), seed: nil }.freeze
8
8
 
9
- def initialize(pack:, days: nil, wo_per_day: nil, fleet_ratio: nil, seed: nil, scenario: nil)
9
+ def initialize(pack:, days: nil, stories_per_day: nil, seed: nil, scenario: nil)
10
10
  @pack = pack
11
11
  @config = DEFAULTS.dup
12
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
13
+ @config[:stories_per_day] = stories_per_day if stories_per_day
15
14
  @config[:seed] = seed if seed
16
15
  @scenario = scenario&.to_sym
17
16
  @rng = Dogfood::Randomness.new(seed: @config[:seed])
@@ -54,10 +53,10 @@ module Dogfood
54
53
  end
55
54
 
56
55
  def build_new_stories(day_index, carry_over_count)
57
- target = wo_count_for_day
56
+ target = story_count_for_day
58
57
  count = [target - carry_over_count, 0].max
59
58
  count.times.map do
60
- story = @builder.build(fleet_ratio: @config[:fleet_ratio], include: @scenario)
59
+ story = @builder.build(include: @scenario)
61
60
  @ledger.register({ story: story, id: story.id, status: :estimate })
62
61
  story
63
62
  end
@@ -76,8 +75,8 @@ module Dogfood
76
75
  end
77
76
  end
78
77
 
79
- def wo_count_for_day
80
- range = @config[:wo_per_day]
78
+ def story_count_for_day
79
+ range = @config[:stories_per_day]
81
80
  range.is_a?(Range) ? @rng.rand(range) : range
82
81
  end
83
82
 
@@ -0,0 +1,396 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dogfood
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
11
+ class AuditRenderer
12
+ MAX_PATHS = 50
13
+
14
+ def initialize(ast, pack: nil)
15
+ @ast = ast
16
+ @pack = pack
17
+ end
18
+
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
393
+ end
394
+ end
395
+ end
396
+ end