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.
- checksums.yaml +7 -0
- data/.claude-plugin/marketplace.json +15 -0
- data/.claude-plugin/plugin.json +9 -0
- data/.rspec +3 -0
- data/AGENTS.md +150 -0
- data/CHANGELOG.md +766 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +555 -0
- data/UPGRADING.md +957 -0
- data/USAGE.md +1291 -0
- data/docs/getting_started.md +611 -0
- data/lib/actionable/action.rb +1220 -0
- data/lib/actionable/batch_result.rb +92 -0
- data/lib/actionable/errors.rb +36 -0
- data/lib/actionable/history.rb +91 -0
- data/lib/actionable/input_dispatch.rb +94 -0
- data/lib/actionable/job.rb +157 -0
- data/lib/actionable/measurement.rb +64 -0
- data/lib/actionable/rails/after_commit.rb +142 -0
- data/lib/actionable/rails/proxy_validator.rb +59 -0
- data/lib/actionable/rails/transactions.rb +95 -0
- data/lib/actionable/rails.rb +24 -0
- data/lib/actionable/railtie.rb +10 -0
- data/lib/actionable/rbs.rb +502 -0
- data/lib/actionable/registry.rb +64 -0
- data/lib/actionable/result.rb +166 -0
- data/lib/actionable/results/failure.rb +43 -0
- data/lib/actionable/results/skipped.rb +30 -0
- data/lib/actionable/results/success.rb +14 -0
- data/lib/actionable/rspec/coverage/report.rb +264 -0
- data/lib/actionable/rspec/coverage/tracker.rb +330 -0
- data/lib/actionable/rspec/coverage.rb +45 -0
- data/lib/actionable/rspec/matchers.rb +266 -0
- data/lib/actionable/rspec/stubs.rb +250 -0
- data/lib/actionable/rspec.rb +40 -0
- data/lib/actionable/rubocop/config.yml +8 -0
- data/lib/actionable/rubocop/cop/actionable/verb_not_last.rb +62 -0
- data/lib/actionable/rubocop/plugin.rb +31 -0
- data/lib/actionable/rubocop.rb +25 -0
- data/lib/actionable/runner.rb +300 -0
- data/lib/actionable/steps/action.rb +76 -0
- data/lib/actionable/steps/base.rb +107 -0
- data/lib/actionable/steps/case.rb +124 -0
- data/lib/actionable/steps/method.rb +39 -0
- data/lib/actionable/steps.rb +27 -0
- data/lib/actionable/value_match.rb +22 -0
- data/lib/actionable/verb_position.rb +574 -0
- data/lib/actionable/version.rb +6 -0
- data/lib/actionable-rb.rb +6 -0
- data/lib/actionable.rb +59 -0
- data/sig/actionable/dsl.rbs +42 -0
- data/sig/actionable/rails.rbs +16 -0
- data/sig/actionable.rbs +2593 -0
- data/skills/actionable/SKILL.md +214 -0
- 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
|