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 +4 -4
- data/bin/dogfood +9 -1
- data/lib/dogfood/cli.rb +77 -41
- data/lib/dogfood/day_generator.rb +7 -8
- data/lib/dogfood/dsl/audit_renderer.rb +396 -0
- data/lib/dogfood/dsl/compiler.rb +10 -9
- data/lib/dogfood/dsl/evaluator.rb +110 -15
- data/lib/dogfood/dsl/pool_schema.rb +69 -0
- data/lib/dogfood/dsl/schema.rb +0 -7
- data/lib/dogfood/faker_proxy.rb +61 -0
- data/lib/dogfood/pack.rb +18 -2
- data/lib/dogfood/pool.rb +119 -0
- data/lib/dogfood/scheduler.rb +3 -8
- data/lib/dogfood/steps/builtins.rb +32 -0
- data/lib/dogfood/story_builder.rb +2 -2
- data/lib/dogfood/templates/day_log.md.erb +9 -9
- data/lib/dogfood/version.rb +1 -1
- data/lib/dogfood.rb +5 -0
- metadata +24 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5f3da6c7dfbeb9f09325d741ae81a835d65da453a17f5ca6deeb3cfde8826f73
|
|
4
|
+
data.tar.gz: 258a2ad0661d1aa5234e18305f75a39a0a696f8108fdef39988299db8e07d709
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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 /^--
|
|
95
|
-
@options[:
|
|
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
|
|
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.
|
|
133
|
-
|
|
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::
|
|
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::
|
|
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: '
|
|
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,
|
|
7
|
+
DEFAULTS = { days: 1, stories_per_day: (3..6), seed: nil }.freeze
|
|
8
8
|
|
|
9
|
-
def initialize(pack:, days: 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[:
|
|
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 =
|
|
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(
|
|
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
|
|
80
|
-
range = @config[:
|
|
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
|