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 +4 -4
- data/lib/dogfood/cli.rb +27 -3
- data/lib/dogfood/dsl/audit_renderer.rb +10 -382
- data/lib/dogfood/dsl/audit_report.rb +777 -0
- data/lib/dogfood/dsl/compiler.rb +11 -5
- data/lib/dogfood/dsl/schema.rb +3 -0
- data/lib/dogfood/story_base.rb +6 -0
- data/lib/dogfood/version.rb +1 -1
- data/lib/dogfood.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 340e952c9eb89332501cff612428e4b2bf34a0a75af4f2d73efb0461cc8e8180
|
|
4
|
+
data.tar.gz: 0c7edf6a45de18e1827522185f418ec1058c29d03f9fcdf410b0bbf127d080b4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
|
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)
|
|
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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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 =
|
|
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
|
-
|
|
21
|
-
|
|
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
|
data/lib/dogfood/dsl/compiler.rb
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
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]
|
|
252
|
+
collect_block_names(step[:then]) + collect_block_names(step[:else])
|
|
247
253
|
else
|
|
248
254
|
[step[:name]]
|
|
249
255
|
end
|
data/lib/dogfood/dsl/schema.rb
CHANGED
|
@@ -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)
|
data/lib/dogfood/story_base.rb
CHANGED
|
@@ -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)
|
data/lib/dogfood/version.rb
CHANGED
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.
|
|
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
|