actionable-rb 1.9.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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.claude-plugin/marketplace.json +15 -0
  3. data/.claude-plugin/plugin.json +9 -0
  4. data/.rspec +3 -0
  5. data/AGENTS.md +150 -0
  6. data/CHANGELOG.md +766 -0
  7. data/CODE_OF_CONDUCT.md +132 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +555 -0
  10. data/UPGRADING.md +957 -0
  11. data/USAGE.md +1291 -0
  12. data/docs/getting_started.md +611 -0
  13. data/lib/actionable/action.rb +1220 -0
  14. data/lib/actionable/batch_result.rb +92 -0
  15. data/lib/actionable/errors.rb +36 -0
  16. data/lib/actionable/history.rb +91 -0
  17. data/lib/actionable/input_dispatch.rb +94 -0
  18. data/lib/actionable/job.rb +157 -0
  19. data/lib/actionable/measurement.rb +64 -0
  20. data/lib/actionable/rails/after_commit.rb +142 -0
  21. data/lib/actionable/rails/proxy_validator.rb +59 -0
  22. data/lib/actionable/rails/transactions.rb +95 -0
  23. data/lib/actionable/rails.rb +24 -0
  24. data/lib/actionable/railtie.rb +10 -0
  25. data/lib/actionable/rbs.rb +502 -0
  26. data/lib/actionable/registry.rb +64 -0
  27. data/lib/actionable/result.rb +166 -0
  28. data/lib/actionable/results/failure.rb +43 -0
  29. data/lib/actionable/results/skipped.rb +30 -0
  30. data/lib/actionable/results/success.rb +14 -0
  31. data/lib/actionable/rspec/coverage/report.rb +264 -0
  32. data/lib/actionable/rspec/coverage/tracker.rb +330 -0
  33. data/lib/actionable/rspec/coverage.rb +45 -0
  34. data/lib/actionable/rspec/matchers.rb +266 -0
  35. data/lib/actionable/rspec/stubs.rb +250 -0
  36. data/lib/actionable/rspec.rb +40 -0
  37. data/lib/actionable/rubocop/config.yml +8 -0
  38. data/lib/actionable/rubocop/cop/actionable/verb_not_last.rb +62 -0
  39. data/lib/actionable/rubocop/plugin.rb +31 -0
  40. data/lib/actionable/rubocop.rb +25 -0
  41. data/lib/actionable/runner.rb +300 -0
  42. data/lib/actionable/steps/action.rb +76 -0
  43. data/lib/actionable/steps/base.rb +107 -0
  44. data/lib/actionable/steps/case.rb +124 -0
  45. data/lib/actionable/steps/method.rb +39 -0
  46. data/lib/actionable/steps.rb +27 -0
  47. data/lib/actionable/value_match.rb +22 -0
  48. data/lib/actionable/verb_position.rb +574 -0
  49. data/lib/actionable/version.rb +6 -0
  50. data/lib/actionable-rb.rb +6 -0
  51. data/lib/actionable.rb +59 -0
  52. data/sig/actionable/dsl.rbs +42 -0
  53. data/sig/actionable/rails.rbs +16 -0
  54. data/sig/actionable.rbs +2593 -0
  55. data/skills/actionable/SKILL.md +214 -0
  56. metadata +132 -0
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ # The single value object a run returns. {Success} and {Failure} are the
5
+ # concrete subclasses; +Result+ itself is abstract and defines the shared,
6
+ # FieldStruct-backed shape (decision D5).
7
+ #
8
+ # @abstract Instantiate {Success} or {Failure}, not +Result+ directly.
9
+ class Result < FieldStruct::Base
10
+ # @return [Symbol] +:success+ for a {Success}; the error symbol for a {Failure}
11
+ required :code, :symbol
12
+
13
+ # @return [String, nil] human-readable description of the outcome
14
+ optional :message, :string
15
+
16
+ # NOTE: +errors+ is intentionally NOT a declared field. It is FieldStruct's
17
+ # own per-instance, Hash-like {FieldStruct::Errors} collection (decision D5,
18
+ # "integrates with FieldStruct"); declaring a field of the same name would
19
+ # shadow the framework's validation machinery.
20
+
21
+ # @return [Object] the run's declared, typed output payload (empty by default).
22
+ # Firms up into an output FieldStruct once output schemas land (D6).
23
+ optional :output, :value, default: -> { {} }
24
+
25
+ # @return [Object] the run's execution history (empty by default).
26
+ # Firms up into a +History+ once measurement lands (D10).
27
+ optional :history, :value, default: -> { [] }
28
+
29
+ # The output fields the run actually set (captured instance variables and
30
+ # +succeed+/+skip+ kwargs), as opposed to fields left nil or at their
31
+ # default. A parent absorbing a nested action's output copies only these
32
+ # (decision D3). +nil+ when unknown, e.g. a hand-built result.
33
+ #
34
+ # @api private
35
+ # @return [Array<Symbol>, nil]
36
+ attr_accessor :output_fields_set
37
+
38
+ # @return [Boolean] whether the run succeeded. Abstract here; each
39
+ # subclass flips the predicate that applies to it.
40
+ def success?
41
+ false
42
+ end
43
+
44
+ # @return [Boolean] whether the run failed.
45
+ def failure?
46
+ false
47
+ end
48
+
49
+ # @return [Boolean] whether the run was skipped — nothing to do, neither a
50
+ # success nor a failure (decision D17). Abstract here; {Skipped} flips it.
51
+ def skipped?
52
+ false
53
+ end
54
+
55
+ # @return [Boolean] alias of {#success?}
56
+ def successful?
57
+ success?
58
+ end
59
+
60
+ # @return [Boolean] alias of {#failure?}
61
+ def failed?
62
+ failure?
63
+ end
64
+
65
+ # @return [Boolean] whether the run did not fail — true for both a
66
+ # {Success} and a {Skipped}. The "happy path didn't error" check.
67
+ def ok?
68
+ !failure?
69
+ end
70
+
71
+ # @return [String] a compact, field-sorted, deterministic representation —
72
+ # the declared attributes plus the +errors+ collection, sorted by name,
73
+ # so equal results always render identically.
74
+ def inspect
75
+ pairs = attributes.merge(errors: errors.to_h)
76
+ .sort_by { |name, _| name.to_s }
77
+ .map { |name, value| "#{name}=#{value.inspect}" }
78
+ "#<#{self.class.name} #{pairs.join(" ")}>"
79
+ end
80
+
81
+ # @return [String] same deterministic representation as {#inspect}
82
+ def to_s
83
+ inspect
84
+ end
85
+
86
+ # @return [Symbol] the outcome as a single symbol — +:success+, +:failure+,
87
+ # or +:skipped+. The coarse-grained companion to the specific {#code}.
88
+ def status
89
+ return :success if success?
90
+ return :skipped if skipped?
91
+
92
+ :failure
93
+ end
94
+
95
+ # A plain-Hash view of the result — +code+, +message+, the +output+ and
96
+ # +history+ rendered to primitives, and the +errors+ collection as a Hash
97
+ # (decision D19). The serializable companion to {#inspect}.
98
+ #
99
+ # @return [Hash{Symbol=>Object}]
100
+ def to_h
101
+ {
102
+ code: code,
103
+ message: message,
104
+ output: output.respond_to?(:to_h) ? output.to_h : output,
105
+ history: history.is_a?(History) ? history.as_json : history,
106
+ errors: errors.to_h
107
+ }
108
+ end
109
+
110
+ # A compact element for an HTTP/batch response: the item's position, its
111
+ # {#status}, the id read off the typed output, and its errors (decision
112
+ # D19). Lets a controller map a batch of results without hand-rolling the
113
+ # shape.
114
+ #
115
+ # results.each_with_index.map { |r, i| r.to_api_h(index: i) }
116
+ #
117
+ # @param index [Integer, nil] the element's position in a batch
118
+ # @param id_field [Symbol] the output field to surface as +:id+ (default +:id+)
119
+ # @return [Hash{Symbol=>Object}]
120
+ def to_api_h(index: nil, id_field: :id)
121
+ {index: index, status: status, id: output_field(id_field), errors: errors.to_h}
122
+ end
123
+
124
+ # Convenience delegation: a declared output field is readable straight off
125
+ # the result (+result.invoice+ → +result.output.invoice+). Only fields
126
+ # declared on a typed output schema delegate; incidental data never does
127
+ # (decision D6).
128
+ #
129
+ # @param name [Symbol] the method name asked about (Ruby passes a Symbol)
130
+ # @param include_private [Boolean] whether private methods count
131
+ # @return [Boolean]
132
+ def respond_to_missing?(name, include_private = false)
133
+ delegates_to_output?(name) || super
134
+ end
135
+
136
+ # @see #respond_to_missing?
137
+ # @param name [Symbol] the method called
138
+ # @param args [Object] its arguments, forwarded to the output
139
+ # @return [undefined] the output field's value: left untyped on purpose,
140
+ # like any field reader whose type the schema decides
141
+ def method_missing(name, *args, &block)
142
+ return output.public_send(name, *args, &block) if delegates_to_output?(name)
143
+
144
+ super
145
+ end
146
+
147
+ private
148
+
149
+ # @param name [Symbol]
150
+ # @return [Boolean] whether +name+ is a declared field on a typed output
151
+ def delegates_to_output?(name)
152
+ output.is_a?(FieldStruct::Base) && output.attribute_names.include?(name)
153
+ end
154
+
155
+ # Read +name+ off the output — a declared field on a typed output, a key on
156
+ # a Hash output, or +nil+ when absent.
157
+ #
158
+ # @param name [Symbol]
159
+ # @return [Object, nil]
160
+ def output_field(name)
161
+ return output.public_send(name) if delegates_to_output?(name)
162
+
163
+ output[name] if output.respond_to?(:[])
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ # A failed run's result. Its +code+ is the error symbol describing why the
5
+ # run failed (e.g. +:not_found+, +:invalid_input+), defaulting to +:error+.
6
+ class Failure < Result
7
+ # @return [Symbol] the error code; defaults to +:error+
8
+ required :code, :symbol, default: :error
9
+
10
+ # @return [Hash{Symbol=>Object}] the metadata the action declared for this
11
+ # code with {Action.fails} (decision D20), so an adapter can read it off the
12
+ # result without the action class. Empty when the code isn't declared.
13
+ # The gem stores and passes it through but never interprets it.
14
+ optional :metadata, :value, default: -> { {} }
15
+
16
+ # @return [true]
17
+ def failure?
18
+ true
19
+ end
20
+
21
+ # A plain-Hash view of the failure: {Result#to_h} plus its declared
22
+ # +metadata+ (decision D20).
23
+ #
24
+ # @return [Hash{Symbol=>Object}]
25
+ def to_h
26
+ super.merge(metadata: metadata)
27
+ end
28
+
29
+ # Copy another object's FieldStruct-style errors onto this failure's own
30
+ # errors collection. The shared mechanism behind +Failure(:invalid_input)+,
31
+ # +Failure(:invalid_output)+, and the {Action#fail_with} verb (decision D19).
32
+ #
33
+ # @param source [#errors] any object whose +errors+ responds to +to_h+
34
+ # (a +FieldStruct::Base+ instance, or another result)
35
+ # @return [self] so callers can build-and-absorb in one expression
36
+ def absorb_errors_from(source)
37
+ source.errors.to_h.each do |field, messages|
38
+ Array(messages).each { |message| errors.add(field, message) }
39
+ end
40
+ self
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ # A skipped run's result (decision D17): the action had nothing to do — not a
5
+ # success (no real work), but not a failure either. Its +code+ is the skip
6
+ # reason (e.g. +:not_ready+), defaulting to +:skipped+.
7
+ class Skipped < Result
8
+ # @return [Symbol] the skip reason; defaults to +:skipped+
9
+ required :code, :symbol, default: :skipped
10
+
11
+ # @return [Hash{Symbol=>Object}] the metadata the action declared for this
12
+ # code with {Action.skips} (decision D21), so an adapter can read it off the
13
+ # result without the action class. Empty when the code isn't declared.
14
+ # The gem stores and passes it through but never interprets it.
15
+ optional :metadata, :value, default: -> { {} }
16
+
17
+ # @return [true]
18
+ def skipped?
19
+ true
20
+ end
21
+
22
+ # A plain-Hash view of the skip: {Result#to_h} plus its declared +metadata+
23
+ # (decision D21).
24
+ #
25
+ # @return [Hash{Symbol=>Object}]
26
+ def to_h
27
+ super.merge(metadata: metadata)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ # A successful run's result. Its +code+ defaults to +:success+.
5
+ class Success < Result
6
+ # @return [Symbol] always +:success+ unless explicitly overridden
7
+ required :code, :symbol, default: :success
8
+
9
+ # @return [true]
10
+ def success?
11
+ true
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oj'
4
+ require 'yaml'
5
+ require_relative 'tracker'
6
+
7
+ module Actionable
8
+ module Coverage
9
+ # Raised when the outcomes no spec reached differ from the committed
10
+ # baseline (decision D22). Its message says exactly what to change.
11
+ class BaselineMismatch < Actionable::Error; end
12
+
13
+ class << self
14
+ # Build the {Report}, print it to +io+, and hold it to the baseline: the
15
+ # end-of-suite step, and the merge step of a sharded suite.
16
+ #
17
+ # With +ACTIONABLE_COVERAGE_JSON+ set, also writes the JSON report to
18
+ # that path. With +ACTIONABLE_COVERAGE_UPDATE_BASELINE=true+, rewrites
19
+ # {.baseline} from the current gaps instead of checking it.
20
+ #
21
+ # @param files [Array<String>, String] shard files from {.write}, or a glob
22
+ # @param io [IO] where the text report goes
23
+ # @raise [BaselineMismatch] when the gaps differ from the baseline
24
+ # @return [Report]
25
+ def report!(files: [], io: $stdout)
26
+ report = Report.new(files: files)
27
+ io.puts report.to_s
28
+ json_path = ENV['ACTIONABLE_COVERAGE_JSON'].to_s
29
+ File.write(json_path, report.to_json) unless json_path.empty?
30
+
31
+ if ENV['ACTIONABLE_COVERAGE_UPDATE_BASELINE'].to_s == 'true'
32
+ io.puts "Actionable outcome coverage: wrote the baseline to #{report.write_baseline}"
33
+ elsif !report.passed?
34
+ raise BaselineMismatch, report.baseline_message
35
+ end
36
+ report
37
+ end
38
+ end
39
+
40
+ # The outcome-coverage report (decision D22): for each reported action
41
+ # ({Coverage.actions}), the outcomes it can end with
42
+ # ({Coverage.expected_outcomes}) against the outcomes real runs reached.
43
+ # A text table for people, JSON for machines, and a check against the
44
+ # baseline of known gaps in {Coverage.baseline}.
45
+ class Report
46
+ # @param files [Array<String>, String] shard files written by
47
+ # {Coverage.write}, or a glob for them, merged with +observed+ and
48
+ # +stubbed+
49
+ # @param observed [Enumerable<Array(String, Symbol, Symbol)>] real runs,
50
+ # as +[class name, outcome, code]+; defaults to {Coverage.observed}
51
+ # @param stubbed [Enumerable<Array(String, Symbol, Symbol)>] runs answered
52
+ # by a stub helper; defaults to {Coverage.stubbed}
53
+ # @raise [Actionable::Error] when +files+ is a glob that matches nothing
54
+ def initialize(files: [], observed: Coverage.observed, stubbed: Coverage.stubbed)
55
+ observed = observed.to_set
56
+ stubbed = stubbed.to_set
57
+ shard_files(files).each do |path|
58
+ shard = Coverage.read(path)
59
+ observed.merge(shard[:observed])
60
+ stubbed.merge(shard[:stubbed])
61
+ end
62
+ @observed = keys_by_action(observed)
63
+ @stubbed = keys_by_action(stubbed)
64
+ end
65
+
66
+ # One row per reported action. Under {Coverage.only_observed}, only
67
+ # actions with at least one real run are reported.
68
+ #
69
+ # @return [Array<Hash{Symbol=>Object}>] +{action:, expected:, reached:,
70
+ # missing:, stubbed_only:}+, outcome lists as outcome keys
71
+ def rows
72
+ @rows ||= reported_actions.map do |klass|
73
+ expected = Coverage.expected_outcomes(klass)
74
+ seen = @observed.fetch(klass.name, Set.new)
75
+ stubbed = @stubbed.fetch(klass.name, Set.new)
76
+ missing = expected.reject { |key| seen.include?(key) }
77
+ {
78
+ action: klass.name,
79
+ expected: expected,
80
+ reached: expected.select { |key| seen.include?(key) },
81
+ missing: missing,
82
+ stubbed_only: missing.select { |key| stubbed.include?(key) }
83
+ }
84
+ end
85
+ end
86
+
87
+ # @return [Hash{Symbol=>Integer}] +{actions:, expected:, reached:, missing:}+
88
+ def totals
89
+ {
90
+ actions: rows.size,
91
+ expected: rows.sum { |row| row[:expected].size },
92
+ reached: rows.sum { |row| row[:reached].size },
93
+ missing: rows.sum { |row| row[:missing].size }
94
+ }
95
+ end
96
+
97
+ # A text table of each action with never-reached outcomes, the subset
98
+ # only ever stubbed, and the totals.
99
+ #
100
+ # @return [String]
101
+ def to_s
102
+ lines = ['Actionable outcome coverage']
103
+ gaps = rows.reject { |row| row[:missing].empty? }
104
+ if gaps.empty?
105
+ lines << ' every expected outcome was reached'
106
+ else
107
+ table = [['Action', 'Never reached', 'Stubbed only']] +
108
+ gaps.map { |row| [row[:action], row[:missing].join(', '), row[:stubbed_only].join(', ')] }
109
+ widths = table.transpose.map { |column| column.map(&:length).max }
110
+ table.each { |cells| lines << " #{cells.each_with_index.map { |cell, i| cell.ljust(widths[i]) }.join(" ").rstrip}" }
111
+ end
112
+ t = totals
113
+ lines << "Actions: #{t[:actions]} Expected outcomes: #{t[:expected]} " \
114
+ "Reached: #{t[:reached]} Missing: #{t[:missing]}"
115
+ lines.join("\n")
116
+ end
117
+
118
+ # @return [Hash{Symbol=>Object}] +{actions: rows, totals:}+
119
+ def to_h
120
+ {actions: rows, totals: totals}
121
+ end
122
+
123
+ # @return [String] {#to_h} as JSON
124
+ def to_json(*)
125
+ Oj.dump(to_h, mode: :compat)
126
+ end
127
+
128
+ # The current gaps in baseline form: each action with never-reached
129
+ # outcomes, mapped to them.
130
+ #
131
+ # @return [Hash{String=>Array<String>}]
132
+ def to_baseline
133
+ rows.reject { |row| row[:missing].empty? }.to_h { |row| [row[:action], row[:missing]] }
134
+ end
135
+
136
+ # Write {#to_baseline} to +path+, as YAML for a +.yml+ / +.yaml+ path
137
+ # and JSON otherwise. For adoption day, and for accepting a change in
138
+ # the gaps deliberately.
139
+ #
140
+ # @param path [String]
141
+ # @return [String] +path+
142
+ def write_baseline(path = Coverage.baseline)
143
+ raise ArgumentError, 'no baseline path given and Coverage.baseline is not set' if path.nil?
144
+
145
+ contents = yaml?(path) ? YAML.dump(to_baseline) : "#{Oj.dump(to_baseline, mode: :compat, indent: 2)}\n"
146
+ File.write(path, contents)
147
+ path
148
+ end
149
+
150
+ # The differences from {Coverage.baseline}: never-reached outcomes it
151
+ # doesn't list (+new_gaps+), and entries that are now reached, no longer
152
+ # expected, or name an action that no longer exists (+stale+). Under
153
+ # {Coverage.only_observed}, an existing action with no run is not
154
+ # judged. Both empty without a baseline.
155
+ #
156
+ # @return [Hash{Symbol=>Hash{String=>Array<String>}}] +{new_gaps:, stale:}+
157
+ # @raise [Actionable::Error] when the baseline file doesn't exist
158
+ def baseline_problems
159
+ @baseline_problems ||= compare_with_baseline
160
+ end
161
+
162
+ # Whether the report agrees with {Coverage.baseline}. Always true
163
+ # without a baseline: missing outcomes alone never fail.
164
+ #
165
+ # @return [Boolean]
166
+ def passed?
167
+ baseline_problems.values.all?(&:empty?)
168
+ end
169
+
170
+ # What to change so the report agrees with the baseline, or +nil+ when
171
+ # it already does.
172
+ #
173
+ # @return [String, nil]
174
+ def baseline_message
175
+ return if passed?
176
+
177
+ problems = baseline_problems
178
+ lines = ["Actionable outcome coverage differs from the baseline in #{Coverage.baseline}."]
179
+ unless problems[:new_gaps].empty?
180
+ lines << "New untested outcomes. #{Coverage.new_gap_hint}"
181
+ lines.concat(entry_lines(problems[:new_gaps]))
182
+ end
183
+ unless problems[:stale].empty?
184
+ lines << 'Covered, or no longer declared. Remove each one from the baseline:'
185
+ lines.concat(entry_lines(problems[:stale]))
186
+ end
187
+ lines << 'To rewrite the baseline from the current gaps, run the report with ' \
188
+ 'ACTIONABLE_COVERAGE_UPDATE_BASELINE=true.'
189
+ lines.join("\n")
190
+ end
191
+
192
+ private
193
+
194
+ # An empty merge would report every outcome missing, so a glob that
195
+ # matches nothing is an error rather than a report.
196
+ def shard_files(files)
197
+ return Array(files) unless files.is_a?(String)
198
+
199
+ paths = Dir.glob(files)
200
+ raise Actionable::Error, "no coverage files match #{files}" if paths.empty?
201
+
202
+ paths
203
+ end
204
+
205
+ # @return [Hash{String=>Set<String>}] outcome keys reached, by action name
206
+ def keys_by_action(observations)
207
+ observations.each_with_object({}) do |(name, outcome, code), by_action|
208
+ (by_action[name.to_s] ||= Set.new) << Coverage.outcome_key(outcome, code)
209
+ end
210
+ end
211
+
212
+ def all_actions
213
+ @all_actions ||= Coverage.actions
214
+ end
215
+
216
+ def reported_actions
217
+ return all_actions unless Coverage.only_observed
218
+
219
+ all_actions.select { |klass| @observed.key?(klass.name) }
220
+ end
221
+
222
+ def compare_with_baseline
223
+ return {new_gaps: {}, stale: {}} if Coverage.baseline.nil?
224
+
225
+ baseline = load_baseline(Coverage.baseline)
226
+ gaps = to_baseline
227
+ new_gaps = gaps.filter_map do |action, missing|
228
+ added = missing - baseline.fetch(action, [])
229
+ [action, added] unless added.empty?
230
+ end.to_h
231
+ {new_gaps: new_gaps, stale: stale_entries(baseline, gaps)}
232
+ end
233
+
234
+ # Baseline entries to remove: the action is gone, or it is reported and
235
+ # the outcome is no longer a gap (reached, or no longer expected).
236
+ def stale_entries(baseline, gaps)
237
+ known = all_actions.map(&:name)
238
+ reported = rows.map { |row| row[:action] }
239
+ baseline.filter_map do |action, keys|
240
+ stale = if !known.include?(action) then keys
241
+ elsif reported.include?(action) then keys - gaps.fetch(action, [])
242
+ else []
243
+ end
244
+ [action, stale] unless stale.empty?
245
+ end.to_h
246
+ end
247
+
248
+ def load_baseline(path)
249
+ raise Actionable::Error, "Coverage.baseline #{path} does not exist; write it with Report#write_baseline" unless File.exist?(path)
250
+
251
+ data = yaml?(path) ? YAML.safe_load_file(path) : Oj.load(File.read(path), mode: :compat)
252
+ (data || {}).to_h { |action, keys| [action.to_s, Array(keys).map(&:to_s)] }
253
+ end
254
+
255
+ def entry_lines(entries)
256
+ entries.map { |action, keys| " #{action}: #{keys.join(", ")}" }
257
+ end
258
+
259
+ def yaml?(path)
260
+ path.to_s.end_with?('.yml', '.yaml')
261
+ end
262
+ end
263
+ end
264
+ end